001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2025 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.packages;
021
022import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.context.FhirVersionEnum;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
027import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
028import ca.uhn.fhir.jpa.api.model.ExpungeOptions;
029import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc;
030import ca.uhn.fhir.jpa.binary.svc.NullBinaryStorageSvcImpl;
031import ca.uhn.fhir.jpa.dao.data.INpmPackageDao;
032import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionDao;
033import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionResourceDao;
034import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService;
035import ca.uhn.fhir.jpa.model.config.PartitionSettings;
036import ca.uhn.fhir.jpa.model.dao.JpaPid;
037import ca.uhn.fhir.jpa.model.entity.NpmPackageEntity;
038import ca.uhn.fhir.jpa.model.entity.NpmPackageVersionEntity;
039import ca.uhn.fhir.jpa.model.entity.NpmPackageVersionResourceEntity;
040import ca.uhn.fhir.jpa.model.entity.ResourceTable;
041import ca.uhn.fhir.jpa.model.util.JpaConstants;
042import ca.uhn.fhir.jpa.packages.loader.NpmPackageData;
043import ca.uhn.fhir.jpa.packages.loader.PackageLoaderSvc;
044import ca.uhn.fhir.rest.api.Constants;
045import ca.uhn.fhir.rest.api.EncodingEnum;
046import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
047import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
048import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
049import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
050import ca.uhn.fhir.util.BinaryUtil;
051import ca.uhn.fhir.util.ResourceUtil;
052import ca.uhn.fhir.util.StringUtil;
053import jakarta.annotation.Nonnull;
054import jakarta.annotation.Nullable;
055import jakarta.persistence.EntityManager;
056import jakarta.persistence.PersistenceContext;
057import jakarta.persistence.TypedQuery;
058import jakarta.persistence.criteria.CriteriaBuilder;
059import jakarta.persistence.criteria.CriteriaQuery;
060import jakarta.persistence.criteria.Join;
061import jakarta.persistence.criteria.JoinType;
062import jakarta.persistence.criteria.Predicate;
063import jakarta.persistence.criteria.Root;
064import org.apache.commons.collections4.comparators.ReverseComparator;
065import org.apache.commons.lang3.Validate;
066import org.hl7.fhir.exceptions.FHIRException;
067import org.hl7.fhir.instance.model.api.IBase;
068import org.hl7.fhir.instance.model.api.IBaseBackboneElement;
069import org.hl7.fhir.instance.model.api.IBaseBinary;
070import org.hl7.fhir.instance.model.api.IBaseResource;
071import org.hl7.fhir.instance.model.api.IIdType;
072import org.hl7.fhir.instance.model.api.IPrimitiveType;
073import org.hl7.fhir.utilities.npm.BasePackageCacheManager;
074import org.hl7.fhir.utilities.npm.NpmPackage;
075import org.hl7.fhir.utilities.npm.PackageServer;
076import org.slf4j.Logger;
077import org.slf4j.LoggerFactory;
078import org.springframework.beans.factory.annotation.Autowired;
079import org.springframework.data.domain.PageRequest;
080import org.springframework.data.domain.Slice;
081import org.springframework.transaction.PlatformTransactionManager;
082import org.springframework.transaction.annotation.Propagation;
083import org.springframework.transaction.annotation.Transactional;
084import org.springframework.transaction.support.TransactionTemplate;
085
086import java.io.ByteArrayInputStream;
087import java.io.IOException;
088import java.io.InputStream;
089import java.nio.charset.StandardCharsets;
090import java.util.ArrayList;
091import java.util.Collection;
092import java.util.Collections;
093import java.util.Date;
094import java.util.HashMap;
095import java.util.List;
096import java.util.Map;
097import java.util.Optional;
098import java.util.regex.Pattern;
099import java.util.stream.Collectors;
100
101import static ca.uhn.fhir.jpa.util.QueryParameterUtils.toPredicateArray;
102import static ca.uhn.fhir.util.StringUtil.toUtf8String;
103import static org.apache.commons.lang3.StringUtils.defaultString;
104import static org.apache.commons.lang3.StringUtils.isNotBlank;
105
106public class JpaPackageCache extends BasePackageCacheManager implements IHapiPackageCacheManager {
107
108        private static final Logger ourLog = LoggerFactory.getLogger(JpaPackageCache.class);
109        private static final Pattern PATTERN_FHIR_VERSION = Pattern.compile("^[0-9]+\\.[0-9]+$");
110
111        private final Map<FhirVersionEnum, FhirContext> myVersionToContext = Collections.synchronizedMap(new HashMap<>());
112
113        @PersistenceContext
114        protected EntityManager myEntityManager;
115
116        @Autowired
117        private INpmPackageDao myPackageDao;
118
119        @Autowired
120        private INpmPackageVersionDao myPackageVersionDao;
121
122        @Autowired
123        private INpmPackageVersionResourceDao myPackageVersionResourceDao;
124
125        @Autowired
126        private DaoRegistry myDaoRegistry;
127
128        @Autowired
129        private FhirContext myCtx;
130
131        @Autowired
132        private PlatformTransactionManager myTxManager;
133
134        @Autowired
135        private PartitionSettings myPartitionSettings;
136
137        @Autowired
138        private PackageLoaderSvc myPackageLoaderSvc;
139
140        @Autowired(required = false) // It is possible that some implementers will not create such a bean.
141        private IBinaryStorageSvc myBinaryStorageSvc;
142
143        @Autowired
144        private HapiTransactionService myTransactionService;
145
146        @Override
147        public void addPackageServer(@Nonnull PackageServer thePackageServer) {
148                assert myPackageLoaderSvc != null;
149                myPackageLoaderSvc.addPackageServer(thePackageServer);
150        }
151
152        @Override
153        public String getPackageId(String theS) throws IOException {
154                return myPackageLoaderSvc.getPackageId(theS);
155        }
156
157        @Override
158        public String getPackageUrl(String theS) throws IOException {
159                return myPackageLoaderSvc.getPackageUrl(theS);
160        }
161
162        @Override
163        public List<PackageServer> getPackageServers() {
164                return myPackageLoaderSvc.getPackageServers();
165        }
166
167        @Override
168        protected BasePackageCacheManager.InputStreamWithSrc loadFromPackageServer(String id, String version) {
169                throw new UnsupportedOperationException(Msg.code(2220) + "Use PackageLoaderSvc for loading packages.");
170        }
171
172        @Override
173        @Transactional
174        public NpmPackage loadPackageFromCacheOnly(String theId, @Nullable String theVersion) {
175                return loadPackageFromCacheOnlyInner(theId, theVersion);
176        }
177
178        @Nullable
179        private NpmPackage loadPackageFromCacheOnlyInner(String theId, @Nullable String theVersion) {
180                Optional<NpmPackageVersionEntity> packageVersion = loadPackageVersionEntity(theId, theVersion);
181                if (theVersion != null && packageVersion.isEmpty() && theVersion.endsWith(".x")) {
182                        String lookupVersion = theVersion;
183                        do {
184                                lookupVersion = lookupVersion.substring(0, lookupVersion.length() - 2);
185                        } while (lookupVersion.endsWith(".x"));
186
187                        List<String> candidateVersionIds =
188                                        myPackageVersionDao.findVersionIdsByPackageIdAndLikeVersion(theId, lookupVersion + ".%");
189                        if (!candidateVersionIds.isEmpty()) {
190                                candidateVersionIds.sort(PackageVersionComparator.INSTANCE);
191                                packageVersion =
192                                                loadPackageVersionEntity(theId, candidateVersionIds.get(candidateVersionIds.size() - 1));
193                        }
194                }
195
196                return packageVersion.map(this::loadPackage).orElse(null);
197        }
198
199        private Optional<NpmPackageVersionEntity> loadPackageVersionEntity(String theId, @Nullable String theVersion) {
200                Validate.notBlank(theId, "theId must be populated");
201
202                Optional<NpmPackageVersionEntity> packageVersion = Optional.empty();
203                if (isNotBlank(theVersion) && !"latest".equals(theVersion)) {
204                        packageVersion = myPackageVersionDao.findByPackageIdAndVersion(theId, theVersion);
205                } else {
206                        Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(theId);
207                        if (pkg.isPresent()) {
208                                packageVersion = myPackageVersionDao.findByPackageIdAndVersion(
209                                                theId, pkg.get().getCurrentVersionId());
210                        }
211                }
212                return packageVersion;
213        }
214
215        private NpmPackage loadPackage(NpmPackageVersionEntity thePackageVersion) {
216                PackageContents content = loadPackageContents(thePackageVersion);
217                ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes());
218                try {
219                        return NpmPackage.fromPackage(inputStream);
220                } catch (IOException e) {
221                        throw new InternalErrorException(Msg.code(1294) + e);
222                }
223        }
224
225        private IHapiPackageCacheManager.PackageContents loadPackageContents(NpmPackageVersionEntity thePackageVersion) {
226                IFhirResourceDao<? extends IBaseBinary> binaryDao = getBinaryDao();
227                IBaseBinary binary =
228                                binaryDao.readByPid(thePackageVersion.getPackageBinary().getId());
229                try {
230                        byte[] content = fetchBlobFromBinary(binary);
231                        return new PackageContents()
232                                        .setBytes(content)
233                                        .setPackageId(thePackageVersion.getPackageId())
234                                        .setVersion(thePackageVersion.getVersionId())
235                                        .setLastModified(thePackageVersion.getUpdatedTime());
236                } catch (IOException e) {
237                        throw new InternalErrorException(
238                                        Msg.code(1295) + "Failed to load package. There was a problem reading binaries", e);
239                }
240        }
241
242        /**
243         * Helper method which will attempt to use the IBinaryStorageSvc to resolve the binary blob if available. If
244         * the bean is unavailable, fallback to assuming we are using an embedded base64 in the data element.
245         * @param theBinary the Binary who's `data` blob you want to retrieve
246         * @return a byte array containing the blob.
247         */
248        private byte[] fetchBlobFromBinary(IBaseBinary theBinary) throws IOException {
249                if (myBinaryStorageSvc != null && !(myBinaryStorageSvc instanceof NullBinaryStorageSvcImpl)) {
250                        return myBinaryStorageSvc.fetchDataByteArrayFromBinary(theBinary);
251                } else {
252                        byte[] value = BinaryUtil.getOrCreateData(myCtx, theBinary).getValue();
253                        if (value == null) {
254                                throw new InternalErrorException(
255                                                Msg.code(1296) + "Failed to fetch blob from Binary/" + theBinary.getIdElement());
256                        }
257                        return value;
258                }
259        }
260
261        @SuppressWarnings("unchecked")
262        private IFhirResourceDao<IBaseBinary> getBinaryDao() {
263                return myDaoRegistry.getResourceDao("Binary");
264        }
265
266        private NpmPackage addPackageToCacheInternal(NpmPackageData thePackageData) {
267                NpmPackage npmPackage = thePackageData.getPackage();
268                String packageId = thePackageData.getPackageId();
269                String initialPackageVersionId = thePackageData.getPackageVersionId();
270                byte[] bytes = thePackageData.getBytes();
271
272                if (!npmPackage.id().equalsIgnoreCase(packageId)) {
273                        throw new InvalidRequestException(
274                                        Msg.code(1297) + "Package ID " + npmPackage.id() + " doesn't match expected: " + packageId);
275                }
276                if (!PackageVersionComparator.isEquivalent(initialPackageVersionId, npmPackage.version())) {
277                        throw new InvalidRequestException(Msg.code(1298) + "Package ID " + npmPackage.version()
278                                        + " doesn't match expected: " + initialPackageVersionId);
279                }
280
281                String packageVersionId = npmPackage.version();
282                FhirVersionEnum fhirVersion = FhirVersionEnum.forVersionString(npmPackage.fhirVersion());
283                if (fhirVersion == null) {
284                        throw new InvalidRequestException(Msg.code(1299) + "Unknown FHIR version: " + npmPackage.fhirVersion());
285                }
286                FhirContext packageContext = getFhirContext(fhirVersion);
287
288                IBaseBinary binary = createPackageBinary(bytes);
289
290                return newTxTemplate().execute(tx -> {
291                        ResourceTable persistedPackage = createResourceBinary(binary);
292                        NpmPackageEntity pkg = myPackageDao.findByPackageId(packageId).orElseGet(() -> createPackage(npmPackage));
293                        NpmPackageVersionEntity packageVersion = myPackageVersionDao
294                                        .findByPackageIdAndVersion(packageId, packageVersionId)
295                                        .orElse(null);
296                        if (packageVersion != null) {
297                                NpmPackage existingPackage =
298                                                loadPackageFromCacheOnlyInner(packageVersion.getPackageId(), packageVersion.getVersionId());
299                                if (existingPackage == null) {
300                                        return null;
301                                }
302                                String msg = "Package version already exists in local storage, no action taken: " + packageId + "#"
303                                                + packageVersionId;
304                                getProcessingMessages(existingPackage).add(msg);
305                                ourLog.info(msg);
306                                return existingPackage;
307                        }
308
309                        boolean currentVersion =
310                                        updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion(packageId, packageVersionId);
311
312                        String packageDesc = truncateStorageString(npmPackage.description());
313                        String packageAuthor = truncateStorageString(npmPackage.getNpm().asString("author"));
314
315                        if (currentVersion) {
316                                getProcessingMessages(npmPackage)
317                                                .add("Marking package " + packageId + "#" + initialPackageVersionId + " as current version");
318                                pkg.setCurrentVersionId(packageVersionId);
319                                pkg.setDescription(packageDesc);
320                                myPackageDao.save(pkg);
321                        } else {
322                                getProcessingMessages(npmPackage)
323                                                .add("Package " + packageId + "#" + initialPackageVersionId + " is not the newest version");
324                        }
325
326                        packageVersion = new NpmPackageVersionEntity();
327                        packageVersion.setPackageId(packageId);
328                        packageVersion.setVersionId(packageVersionId);
329                        packageVersion.setPackage(pkg);
330                        packageVersion.setPackageBinary(persistedPackage);
331                        packageVersion.setSavedTime(new Date());
332                        packageVersion.setAuthor(packageAuthor);
333                        packageVersion.setDescription(packageDesc);
334                        packageVersion.setFhirVersionId(npmPackage.fhirVersion());
335                        packageVersion.setFhirVersion(fhirVersion);
336                        packageVersion.setCurrentVersion(currentVersion);
337                        packageVersion.setPackageSizeBytes(bytes.length);
338                        packageVersion = myPackageVersionDao.save(packageVersion);
339
340                        String dirName = "package";
341                        NpmPackage.NpmPackageFolder packageFolder = npmPackage.getFolders().get(dirName);
342                        Map<String, List<String>> packageFolderTypes;
343                        try {
344                                packageFolderTypes = packageFolder.getTypes();
345                        } catch (IOException e) {
346                                throw new InternalErrorException(Msg.code(2371) + e);
347                        }
348                        for (Map.Entry<String, List<String>> nextTypeToFiles : packageFolderTypes.entrySet()) {
349                                String nextType = nextTypeToFiles.getKey();
350                                for (String nextFile : nextTypeToFiles.getValue()) {
351
352                                        byte[] contents;
353                                        String contentsString;
354                                        try {
355                                                contents = packageFolder.fetchFile(nextFile);
356                                                contentsString = toUtf8String(contents);
357                                        } catch (IOException e) {
358                                                throw new InternalErrorException(Msg.code(1300) + e);
359                                        }
360
361                                        IBaseResource resource;
362                                        if (nextFile.toLowerCase().endsWith(".xml")) {
363                                                resource = packageContext.newXmlParser().parseResource(contentsString);
364                                        } else if (nextFile.toLowerCase().endsWith(".json")) {
365                                                resource = packageContext.newJsonParser().parseResource(contentsString);
366                                        } else {
367                                                getProcessingMessages(npmPackage).add("Not indexing file: " + nextFile);
368                                                continue;
369                                        }
370
371                                        /*
372                                         * Re-encode the resource as JSON with the narrative removed in order to reduce the footprint.
373                                         * This is useful since we'll be loading these resources back and hopefully keeping lots of
374                                         * them in memory in order to speed up validation activities.
375                                         */
376                                        String contentType = Constants.CT_FHIR_JSON_NEW;
377                                        ResourceUtil.removeNarrative(packageContext, resource);
378                                        byte[] minimizedContents = packageContext
379                                                        .newJsonParser()
380                                                        .encodeResourceToString(resource)
381                                                        .getBytes(StandardCharsets.UTF_8);
382
383                                        IBaseBinary resourceBinary = createPackageResourceBinary(minimizedContents, contentType);
384                                        ResourceTable persistedResource = createResourceBinary(resourceBinary);
385
386                                        NpmPackageVersionResourceEntity resourceEntity = new NpmPackageVersionResourceEntity();
387                                        resourceEntity.setPackageVersion(packageVersion);
388                                        resourceEntity.setResourceBinary(persistedResource);
389                                        resourceEntity.setDirectory(dirName);
390                                        resourceEntity.setFhirVersionId(npmPackage.fhirVersion());
391                                        resourceEntity.setFhirVersion(fhirVersion);
392                                        resourceEntity.setFilename(nextFile);
393                                        resourceEntity.setResourceType(nextType);
394                                        resourceEntity.setResSizeBytes(contents.length);
395                                        BaseRuntimeChildDefinition urlChild =
396                                                        packageContext.getResourceDefinition(nextType).getChildByName("url");
397                                        BaseRuntimeChildDefinition versionChild =
398                                                        packageContext.getResourceDefinition(nextType).getChildByName("version");
399                                        String url = null;
400                                        String version = null;
401                                        if (urlChild != null) {
402                                                url = urlChild.getAccessor()
403                                                                .getFirstValueOrNull(resource)
404                                                                .map(t -> ((IPrimitiveType<?>) t).getValueAsString())
405                                                                .orElse(null);
406                                                resourceEntity.setCanonicalUrl(url);
407
408                                                Optional<IBase> resourceVersion =
409                                                                versionChild.getAccessor().getFirstValueOrNull(resource);
410                                                if (resourceVersion.isPresent() && resourceVersion.get() instanceof IPrimitiveType) {
411                                                        version = ((IPrimitiveType<?>) resourceVersion.get()).getValueAsString();
412                                                } else if (resourceVersion.isPresent()
413                                                                && resourceVersion.get() instanceof IBaseBackboneElement) {
414                                                        version = String.valueOf(myCtx.newFhirPath()
415                                                                        .evaluateFirst(resourceVersion.get(), "value", IPrimitiveType.class)
416                                                                        .orElse(null));
417                                                }
418                                                resourceEntity.setCanonicalVersion(version);
419                                        }
420                                        myPackageVersionResourceDao.save(resourceEntity);
421
422                                        String resType = packageContext.getResourceType(resource);
423                                        String msg = "Indexing " + resType + " Resource[" + dirName + '/' + nextFile + "] with URL: "
424                                                        + defaultString(url) + "|" + defaultString(version);
425                                        getProcessingMessages(npmPackage).add(msg);
426                                        ourLog.info("{}: Package[{}#{}] ", msg, packageId, packageVersionId);
427                                }
428                        }
429
430                        getProcessingMessages(npmPackage)
431                                        .add("Successfully added package " + npmPackage.id() + "#" + npmPackage.version() + " to registry");
432
433                        return npmPackage;
434                });
435        }
436
437        @Override
438        public NpmPackage addPackageToCache(
439                        String thePackageId, String thePackageVersionId, InputStream thePackageTgzInputStream, String theSourceDesc)
440                        throws IOException {
441                NpmPackageData npmData = myPackageLoaderSvc.createNpmPackageDataFromData(
442                                thePackageId, thePackageVersionId, theSourceDesc, thePackageTgzInputStream);
443
444                return addPackageToCacheInternal(npmData);
445        }
446
447        private ResourceTable createResourceBinary(IBaseBinary theResourceBinary) {
448
449                if (myPartitionSettings.isPartitioningEnabled()) {
450                        SystemRequestDetails requestDetails = new SystemRequestDetails();
451                        if (myPartitionSettings.isUnnamedPartitionMode() && myPartitionSettings.getDefaultPartitionId() != null) {
452                                requestDetails.setRequestPartitionId(myPartitionSettings.getDefaultRequestPartitionId());
453                        } else {
454                                requestDetails.setTenantId(JpaConstants.DEFAULT_PARTITION_NAME);
455                        }
456                        return (ResourceTable)
457                                        getBinaryDao().create(theResourceBinary, requestDetails).getEntity();
458                } else {
459                        return (ResourceTable) getBinaryDao()
460                                        .create(theResourceBinary, new SystemRequestDetails())
461                                        .getEntity();
462                }
463        }
464
465        private boolean updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion(
466                        String thePackageId, String thePackageVersion) {
467                Collection<NpmPackageVersionEntity> existingVersions = myPackageVersionDao.findByPackageId(thePackageId);
468                boolean retVal = true;
469
470                for (NpmPackageVersionEntity next : existingVersions) {
471                        int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), thePackageVersion);
472                        assert cmp != 0;
473                        if (cmp < 0) {
474                                if (next.isCurrentVersion()) {
475                                        next.setCurrentVersion(false);
476                                        myPackageVersionDao.save(next);
477                                }
478                        } else {
479                                retVal = false;
480                        }
481                }
482
483                return retVal;
484        }
485
486        @Nonnull
487        public FhirContext getFhirContext(FhirVersionEnum theFhirVersion) {
488                return myVersionToContext.computeIfAbsent(theFhirVersion, FhirContext::new);
489        }
490
491        private IBaseBinary createPackageBinary(byte[] theBytes) {
492                IBaseBinary binary = BinaryUtil.newBinary(myCtx);
493                BinaryUtil.setData(myCtx, binary, theBytes, Constants.CT_APPLICATION_GZIP);
494                return binary;
495        }
496
497        private IBaseBinary createPackageResourceBinary(byte[] theBytes, String theContentType) {
498                IBaseBinary binary = BinaryUtil.newBinary(myCtx);
499                BinaryUtil.setData(myCtx, binary, theBytes, theContentType);
500                return binary;
501        }
502
503        private NpmPackageEntity createPackage(NpmPackage theNpmPackage) {
504                NpmPackageEntity entity = new NpmPackageEntity();
505                entity.setPackageId(theNpmPackage.id());
506                entity.setCurrentVersionId(theNpmPackage.version());
507                return myPackageDao.save(entity);
508        }
509
510        @Override
511        @Transactional
512        public NpmPackage loadPackage(String thePackageId, String thePackageVersion) throws FHIRException, IOException {
513                return loadPackageInner(thePackageId, thePackageVersion);
514        }
515
516        @Nonnull
517        private NpmPackage loadPackageInner(String thePackageId, String thePackageVersion) throws IOException {
518                // check package cache
519                NpmPackage cachedPackage = loadPackageFromCacheOnlyInner(thePackageId, thePackageVersion);
520                if (cachedPackage != null) {
521                        return cachedPackage;
522                }
523
524                // otherwise we have to load it from packageloader
525                NpmPackageData pkgData = myPackageLoaderSvc.fetchPackageFromPackageSpec(thePackageId, thePackageVersion);
526
527                try {
528                        // and add it to the cache
529                        NpmPackage retVal = addPackageToCacheInternal(pkgData);
530                        getProcessingMessages(retVal)
531                                        .add(
532                                                        0,
533                                                        "Package fetched from server at: "
534                                                                        + pkgData.getPackage().url());
535                        return retVal;
536                } finally {
537                        pkgData.getInputStream().close();
538                }
539        }
540
541        @Override
542        @Transactional
543        public NpmPackage loadPackage(String theS) throws FHIRException, IOException {
544                return loadPackageInner(theS, null);
545        }
546
547        private TransactionTemplate newTxTemplate() {
548                return new TransactionTemplate(myTxManager);
549        }
550
551        @Override
552        @Transactional(propagation = Propagation.NEVER)
553        public NpmPackage installPackage(PackageInstallationSpec theInstallationSpec) throws IOException {
554                Validate.notBlank(theInstallationSpec.getName(), "thePackageId must not be blank");
555                Validate.notBlank(theInstallationSpec.getVersion(), "thePackageVersion must not be blank");
556
557                String sourceDescription = "Embedded content";
558                if (isNotBlank(theInstallationSpec.getPackageUrl())) {
559                        byte[] contents = myPackageLoaderSvc.loadPackageUrlContents(theInstallationSpec.getPackageUrl());
560                        theInstallationSpec.setPackageContents(contents);
561                        sourceDescription = theInstallationSpec.getPackageUrl();
562                }
563
564                if (theInstallationSpec.getPackageContents() != null) {
565                        return addPackageToCache(
566                                        theInstallationSpec.getName(),
567                                        theInstallationSpec.getVersion(),
568                                        new ByteArrayInputStream(theInstallationSpec.getPackageContents()),
569                                        sourceDescription);
570                }
571
572                return newTxTemplate().execute(tx -> {
573                        try {
574                                return loadPackageInner(theInstallationSpec.getName(), theInstallationSpec.getVersion());
575                        } catch (IOException e) {
576                                throw new InternalErrorException(Msg.code(1302) + e, e);
577                        }
578                });
579        }
580
581        @Override
582        @Transactional(readOnly = true)
583        public IBaseResource loadPackageAssetByUrl(FhirVersionEnum theFhirVersion, String theCanonicalUrl) {
584
585                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
586                                loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, 2, null, null);
587
588                if (npmPackageVersionResourceEntities.isEmpty()) {
589                        return null;
590                } else {
591                        if (npmPackageVersionResourceEntities.size() > 1) {
592                                ourLog.warn(
593                                                "Found multiple package versions for FHIR version: {} and canonical URL: {}",
594                                                theFhirVersion,
595                                                theCanonicalUrl);
596                        }
597                        final NpmPackageVersionResourceEntity contents = npmPackageVersionResourceEntities.get(0);
598                        return loadPackageEntity(contents);
599                }
600        }
601
602        @Override
603        @Transactional(readOnly = true)
604        public IBaseResource findPackageAsset(FindPackageAssetRequest theRequest) {
605
606                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = loadPackageInfoByCanonicalUrl(
607                                theRequest.getFhirVersion(),
608                                theRequest.getCanonicalUrl(),
609                                2, // We set it to 2 so that if we get more than one we can warn
610                                theRequest.getPackageId(),
611                                theRequest.getVersion());
612
613                if (npmPackageVersionResourceEntities.isEmpty()) {
614                        throw new ResourceNotFoundException(
615                                        "%s Could not find asset for FHIR version: %s, canonical URL: %s, package ID: %s and package version: %s"
616                                                        .formatted(
617                                                                        Msg.code(2644),
618                                                                        theRequest.getFhirVersion(),
619                                                                        theRequest.getCanonicalUrl(),
620                                                                        theRequest.getPackageId(),
621                                                                        Optional.ofNullable(theRequest.getVersion()).orElse("[none]")));
622                } else {
623                        if (npmPackageVersionResourceEntities.size() > 1) {
624                                ourLog.warn(
625                                                "Found multiple package versions for FHIR version: {} and canonical URL: {}",
626                                                theRequest.getFhirVersion(),
627                                                theRequest.getCanonicalUrl());
628                        }
629                        final NpmPackageVersionResourceEntity contents = npmPackageVersionResourceEntities.get(0);
630                        return loadPackageEntity(contents);
631                }
632        }
633
634        @Override
635        @Transactional(readOnly = true)
636        public List<NpmPackageAssetInfoJson> findPackageAssetInfoByUrl(
637                        FhirVersionEnum theFhirVersion, String theCanonicalUrl) {
638                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
639                                loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, 20, null, null);
640
641                return npmPackageVersionResourceEntities.stream()
642                                .map(entity -> new NpmPackageAssetInfoJson(
643                                                entity.getResourceBinary().asTypedFhirResourceId(),
644                                                entity.getCanonicalUrl(),
645                                                entity.getFhirVersion(),
646                                                entity.getPackageId(),
647                                                entity.getPackageVersion()))
648                                .toList();
649        }
650
651        private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrl(
652                        FhirVersionEnum theFhirVersion,
653                        String theCanonicalUrl,
654                        int thePageSize,
655                        @Nullable String thePackageId,
656                        @Nullable String theVersionId) {
657                String canonicalUrl = theCanonicalUrl;
658
659                int versionSeparator = canonicalUrl.lastIndexOf('|');
660                Slice<NpmPackageVersionResourceEntity> slice;
661
662                final PageRequest pageRequest = PageRequest.of(0, thePageSize);
663
664                if (versionSeparator != -1) {
665                        String canonicalVersion = canonicalUrl.substring(versionSeparator + 1);
666                        canonicalUrl = canonicalUrl.substring(0, versionSeparator);
667
668                        if (thePackageId != null) {
669                                if (theVersionId != null) {
670                                        slice =
671                                                        myPackageVersionResourceDao
672                                                                        .findCurrentVersionByCanonicalUrlAndVersionAndPackageIdAndVersion(
673                                                                                        pageRequest,
674                                                                                        theFhirVersion,
675                                                                                        canonicalUrl,
676                                                                                        canonicalVersion,
677                                                                                        thePackageId,
678                                                                                        theVersionId);
679                                } else {
680                                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersionAndPackageId(
681                                                        pageRequest, theFhirVersion, canonicalUrl, canonicalVersion, thePackageId);
682                                }
683                        } else {
684                                slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersion(
685                                                pageRequest, theFhirVersion, canonicalUrl, canonicalVersion);
686                        }
687
688                } else {
689                        if (thePackageId != null) {
690                                if (theVersionId != null) {
691                                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndPackageIdAndVersion(
692                                                        pageRequest, theFhirVersion, canonicalUrl, thePackageId, theVersionId);
693                                } else {
694                                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndPackageId(
695                                                        pageRequest, theFhirVersion, canonicalUrl, thePackageId);
696                                }
697                        } else {
698                                slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrl(
699                                                pageRequest, theFhirVersion, canonicalUrl);
700                        }
701                }
702
703                if (slice.isEmpty()) {
704                        return List.of();
705                } else {
706                        return slice.getContent();
707                }
708        }
709
710        private IBaseResource loadPackageEntity(NpmPackageVersionResourceEntity contents) {
711                return loadPackageAssetByVersionAndId(
712                                contents.getFhirVersion(), contents.getResourceBinary().getResourceId());
713        }
714
715        private IBaseResource loadPackageAssetByVersionAndId(FhirVersionEnum theFhirVersion, JpaPid theBinaryPid) {
716                try {
717                        IBaseBinary binary = getBinaryDao().readByPid(theBinaryPid);
718                        byte[] resourceContentsBytes = fetchBlobFromBinary(binary);
719                        String resourceContents = new String(resourceContentsBytes, StandardCharsets.UTF_8);
720                        FhirContext packageContext = getFhirContext(theFhirVersion);
721                        return EncodingEnum.detectEncoding(resourceContents)
722                                        .newParser(packageContext)
723                                        .parseResource(resourceContents);
724                } catch (Exception exception) {
725                        throw new InvalidRequestException(
726                                        String.format(
727                                                        "%sFailed to load package resource for FHIR version: %s and binary PID: %s",
728                                                        Msg.code(1305), theFhirVersion, theBinaryPid),
729                                        exception);
730                }
731        }
732
733        @Override
734        @Transactional
735        public NpmPackageMetadataJson loadPackageMetadata(String thePackageId) {
736                NpmPackageMetadataJson retVal = new NpmPackageMetadataJson();
737
738                Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(thePackageId);
739                if (pkg.isEmpty()) {
740                        throw new ResourceNotFoundException(Msg.code(1306) + "Unknown package ID: " + thePackageId);
741                }
742
743                List<NpmPackageVersionEntity> packageVersions =
744                                new ArrayList<>(myPackageVersionDao.findByPackageId(thePackageId));
745                packageVersions.sort(new ReverseComparator<>(
746                                (o1, o2) -> PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId())));
747
748                for (NpmPackageVersionEntity next : packageVersions) {
749                        if (next.isCurrentVersion()) {
750                                retVal.setDistTags(new NpmPackageMetadataJson.DistTags().setLatest(next.getVersionId()));
751                        }
752
753                        NpmPackageMetadataJson.Version version = new NpmPackageMetadataJson.Version();
754                        version.setFhirVersion(next.getFhirVersionId());
755                        version.setAuthor(next.getAuthor());
756                        version.setDescription(next.getDescription());
757                        version.setName(next.getPackageId());
758                        version.setVersion(next.getVersionId());
759                        version.setBytes(next.getPackageSizeBytes());
760                        retVal.addVersion(version);
761                }
762
763                return retVal;
764        }
765
766        @Override
767        @Transactional
768        public PackageContents loadPackageContents(String thePackageId, @Nullable String theVersion) {
769                Optional<NpmPackageVersionEntity> entity = loadPackageVersionEntity(thePackageId, theVersion);
770                return entity.map(this::loadPackageContents).orElse(null);
771        }
772
773        @Override
774        @Transactional
775        public NpmPackageSearchResultJson search(PackageSearchSpec thePackageSearchSpec) {
776                NpmPackageSearchResultJson retVal = new NpmPackageSearchResultJson();
777
778                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
779
780                // Query for total
781                queryForTool(thePackageSearchSpec, cb, retVal);
782
783                // Query for results
784                queryForResults(thePackageSearchSpec, cb, retVal);
785
786                return retVal;
787        }
788
789        private void queryForTool(
790                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) {
791                CriteriaQuery<Long> countCriteriaQuery = cb.createQuery(Long.class);
792                Root<NpmPackageVersionEntity> countCriteriaRoot = countCriteriaQuery.from(NpmPackageVersionEntity.class);
793                countCriteriaQuery.multiselect(cb.countDistinct(countCriteriaRoot.get("myPackageId")));
794
795                List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, countCriteriaRoot);
796
797                countCriteriaQuery.where(toPredicateArray(predicates));
798                Long total = myEntityManager.createQuery(countCriteriaQuery).getSingleResult();
799                retVal.setTotal(Math.toIntExact(total));
800        }
801
802        private void queryForResults(
803                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) {
804                CriteriaQuery<NpmPackageVersionEntity> criteriaQuery = cb.createQuery(NpmPackageVersionEntity.class);
805                Root<NpmPackageVersionEntity> root = criteriaQuery.from(NpmPackageVersionEntity.class);
806
807                List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, root);
808
809                criteriaQuery.where(toPredicateArray(predicates));
810                criteriaQuery.orderBy(cb.asc(root.get("myPackageId")));
811                TypedQuery<NpmPackageVersionEntity> query = myEntityManager.createQuery(criteriaQuery);
812                query.setFirstResult(thePackageSearchSpec.getStart());
813                query.setMaxResults(thePackageSearchSpec.getSize());
814
815                List<NpmPackageVersionEntity> resultList = query.getResultList();
816                for (NpmPackageVersionEntity next : resultList) {
817
818                        if (!retVal.hasPackageWithId(next.getPackageId())) {
819                                retVal.addObject()
820                                                .getPackage()
821                                                .setName(next.getPackageId())
822                                                .setAuthor(next.getAuthor())
823                                                .setDescription(next.getDescription())
824                                                .setVersion(next.getVersionId())
825                                                .addFhirVersion(next.getFhirVersionId())
826                                                .setBytes(next.getPackageSizeBytes());
827                        } else {
828                                NpmPackageSearchResultJson.Package retPackage = retVal.getPackageWithId(next.getPackageId());
829                                retPackage.addFhirVersion(next.getFhirVersionId());
830
831                                int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), retPackage.getVersion());
832                                if (cmp > 0) {
833                                        retPackage.setVersion(next.getVersionId());
834                                }
835                        }
836                }
837        }
838
839        @Override
840        public PackageDeleteOutcomeJson uninstallPackage(String thePackageId, String theVersion) {
841                SystemRequestDetails requestDetails =
842                                new SystemRequestDetails().setRequestPartitionId(myPartitionSettings.getDefaultRequestPartitionId());
843                return myTransactionService
844                                .withRequest(requestDetails)
845                                .execute(() -> doUninstallPackage(thePackageId, theVersion));
846        }
847
848        private PackageDeleteOutcomeJson doUninstallPackage(String thePackageId, String theVersion) {
849                PackageDeleteOutcomeJson retVal = new PackageDeleteOutcomeJson();
850
851                Optional<NpmPackageVersionEntity> packageVersion =
852                                myPackageVersionDao.findByPackageIdAndVersion(thePackageId, theVersion);
853                if (packageVersion.isPresent()) {
854
855                        String msg = "Deleting package " + thePackageId + "#" + theVersion;
856                        ourLog.info(msg);
857                        retVal.getMessage().add(msg);
858
859                        for (NpmPackageVersionResourceEntity next : packageVersion.get().getResources()) {
860                                msg = "Deleting package +" + thePackageId + "#" + theVersion + "resource: " + next.getCanonicalUrl();
861                                ourLog.info(msg);
862                                retVal.getMessage().add(msg);
863
864                                myPackageVersionResourceDao.delete(next);
865
866                                ExpungeOptions options = new ExpungeOptions();
867                                options.setExpungeDeletedResources(true).setExpungeOldVersions(true);
868                                deleteAndExpungeResourceBinary(
869                                                next.getResourceBinary().getIdDt().toVersionless(), options);
870                        }
871
872                        myPackageVersionDao.delete(packageVersion.get());
873
874                        ExpungeOptions options = new ExpungeOptions();
875                        options.setExpungeDeletedResources(true).setExpungeOldVersions(true);
876                        deleteAndExpungeResourceBinary(
877                                        packageVersion.get().getPackageBinary().getIdDt().toVersionless(), options);
878
879                        Collection<NpmPackageVersionEntity> remainingVersions = myPackageVersionDao.findByPackageId(thePackageId);
880                        if (remainingVersions.isEmpty()) {
881                                msg = "No versions of package " + thePackageId + " remain";
882                                ourLog.info(msg);
883                                retVal.getMessage().add(msg);
884                                Optional<NpmPackageEntity> pkgEntity = myPackageDao.findByPackageId(thePackageId);
885                                pkgEntity.ifPresent(pkgEntityPresent -> myPackageDao.delete(pkgEntityPresent));
886                        } else {
887
888                                List<NpmPackageVersionEntity> versions = remainingVersions.stream()
889                                                .sorted((o1, o2) ->
890                                                                PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId()))
891                                                .toList();
892                                for (int i = 0; i < versions.size(); i++) {
893                                        boolean isCurrent = i == versions.size() - 1;
894                                        if (isCurrent != versions.get(i).isCurrentVersion()) {
895                                                versions.get(i).setCurrentVersion(isCurrent);
896                                                myPackageVersionDao.save(versions.get(i));
897                                        }
898                                }
899                        }
900
901                } else {
902
903                        String msg = "No package found with the given ID";
904                        retVal.getMessage().add(msg);
905                }
906
907                return retVal;
908        }
909
910        @Override
911        @Transactional
912        public List<IBaseResource> loadPackageAssetsByType(FhirVersionEnum theFhirVersion, String theResourceType) {
913                Slice<NpmPackageVersionResourceEntity> outcome = myPackageVersionResourceDao.findCurrentVersionByResourceType(
914                                PageRequest.of(0, 1000), theFhirVersion, theResourceType);
915                return outcome.stream().map(this::loadPackageEntity).collect(Collectors.toList());
916        }
917
918        private void deleteAndExpungeResourceBinary(IIdType theResourceBinaryId, ExpungeOptions theOptions) {
919                SystemRequestDetails requestDetails = new SystemRequestDetails()
920                                .setRequestPartitionId(HapiTransactionService.getRequestPartitionAssociatedWithThread());
921
922                getBinaryDao().delete(theResourceBinaryId, requestDetails);
923                getBinaryDao().forceExpungeInExistingTransaction(theResourceBinaryId, theOptions, requestDetails);
924        }
925
926        @Nonnull
927        public List<Predicate> createSearchPredicates(
928                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder theCb, Root<NpmPackageVersionEntity> theRoot) {
929                List<Predicate> predicates = new ArrayList<>();
930
931                if (isNotBlank(thePackageSearchSpec.getResourceUrl())) {
932                        Join<NpmPackageVersionEntity, NpmPackageVersionResourceEntity> resources =
933                                        theRoot.join("myResources", JoinType.LEFT);
934
935                        predicates.add(theCb.equal(resources.get("myCanonicalUrl"), thePackageSearchSpec.getResourceUrl()));
936                }
937
938                if (isNotBlank(thePackageSearchSpec.getVersion())) {
939                        String searchTerm = thePackageSearchSpec.getVersion() + "%";
940                        predicates.add(theCb.like(theRoot.get("myVersionId"), searchTerm));
941                }
942
943                if (isNotBlank(thePackageSearchSpec.getDescription())) {
944                        String searchTerm = "%" + thePackageSearchSpec.getDescription() + "%";
945                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
946                        predicates.add(theCb.like(theCb.upper(theRoot.get("myDescriptionUpper")), searchTerm));
947                }
948
949                if (isNotBlank(thePackageSearchSpec.getAuthor())) {
950                        String searchTerm = "%" + thePackageSearchSpec.getAuthor() + "%";
951                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
952                        predicates.add(theCb.like(theRoot.get("myAuthorUpper"), searchTerm));
953                }
954
955                if (isNotBlank(thePackageSearchSpec.getFhirVersion())) {
956                        if (!PATTERN_FHIR_VERSION
957                                        .matcher(thePackageSearchSpec.getFhirVersion())
958                                        .matches()) {
959                                FhirVersionEnum versionEnum = FhirVersionEnum.forVersionString(thePackageSearchSpec.getFhirVersion());
960                                if (versionEnum != null) {
961                                        predicates.add(theCb.equal(theRoot.get("myFhirVersion").as(String.class), versionEnum.name()));
962                                }
963                        } else {
964                                predicates.add(theCb.like(theRoot.get("myFhirVersionId"), thePackageSearchSpec.getFhirVersion() + "%"));
965                        }
966                }
967
968                return predicates;
969        }
970
971        @SuppressWarnings("unchecked")
972        public static List<String> getProcessingMessages(NpmPackage thePackage) {
973                return (List<String>)
974                                thePackage.getUserData().computeIfAbsent("JpPackageCache_ProcessingMessages", t -> new ArrayList<>());
975        }
976
977        /**
978         * Truncates a string to {@link NpmPackageVersionEntity#PACKAGE_DESC_LENGTH} which is
979         * the maximum length used on several columns in {@link NpmPackageVersionEntity}. If the
980         * string is longer than the maximum allowed, the last 3 characters are replaced with "..."
981         */
982        private static String truncateStorageString(String theInput) {
983                String retVal = null;
984                if (theInput != null) {
985                        if (theInput.length() > NpmPackageVersionEntity.PACKAGE_DESC_LENGTH) {
986                                retVal = theInput.substring(0, NpmPackageVersionEntity.PACKAGE_DESC_LENGTH - 4) + "...";
987                        } else {
988                                retVal = theInput;
989                        }
990                }
991                return retVal;
992        }
993}