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                // This is the only API where we're loading by "currentVersion = true" because that's the current behaviour
585                // in production and this is a widely used APIs
586                // The other APIs are newer and were introduced with newer NPM functionality in mind, including more refined
587                // handling of duplicate canonical URLs across packages
588                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
589                                loadPackageInfoByCanonicalUrlCurrentVersionOnly(theFhirVersion, theCanonicalUrl, PageRequest.of(0, 2));
590
591                final List<IBaseResource> resources = npmPackageVersionResourceEntities.stream()
592                                .map(this::loadPackageEntity)
593                                .toList();
594
595                if (resources.size() > 1) {
596                        ourLog.warn(
597                                        "Found multiple package versions for FHIR version: {} and canonical URL: {}",
598                                        theFhirVersion,
599                                        theCanonicalUrl);
600                } else if (resources.isEmpty()) {
601                        return null;
602                }
603                return resources.get(0);
604        }
605
606        @Override
607        @Transactional(readOnly = true)
608        public List<IBaseResource> loadPackageAssetsByUrl(
609                        FhirVersionEnum theFhirVersion, String theCanonicalUrl, PageRequest thePageRequest) {
610                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
611                                loadPackageInfoByCanonicalUrlAnyVersion(theFhirVersion, theCanonicalUrl, thePageRequest, null, null);
612
613                if (npmPackageVersionResourceEntities.isEmpty()) {
614                        return List.of();
615                } else {
616                        return npmPackageVersionResourceEntities.stream()
617                                        .map(this::loadPackageEntity)
618                                        .collect(Collectors.toList());
619                }
620        }
621
622        @Override
623        @Transactional(readOnly = true)
624        public IBaseResource findPackageAsset(FindPackageAssetRequest theRequest) {
625                List<IBaseResource> assets = findPackageAssets(theRequest);
626                if (assets.size() > 1) {
627                        ourLog.warn(
628                                        "Found multiple package versions for FHIR version: {} and canonical URL: {}",
629                                        theRequest.getFhirVersion(),
630                                        theRequest.getCanonicalUrl());
631                }
632                // assets will always have a single element because findPackageAssets throws if nothing is found
633                return assets.get(0);
634        }
635
636        @Override
637        @Transactional(readOnly = true)
638        public List<IBaseResource> findPackageAssets(FindPackageAssetRequest theRequest) {
639                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
640                                loadPackageInfoByCanonicalUrlAnyVersion(
641                                                theRequest.getFhirVersion(),
642                                                theRequest.getCanonicalUrl(),
643                                                theRequest.getPageRequest(),
644                                                theRequest.getPackageId(),
645                                                theRequest.getVersion());
646
647                if (npmPackageVersionResourceEntities.isEmpty()) {
648                        throw new ResourceNotFoundException(
649                                        "%s Could not find asset(s) for FHIR version: %s, canonical URL: %s, package ID: %s and package version: %s"
650                                                        .formatted(
651                                                                        Msg.code(2644),
652                                                                        theRequest.getFhirVersion(),
653                                                                        theRequest.getCanonicalUrl(),
654                                                                        theRequest.getPackageId(),
655                                                                        Optional.ofNullable(theRequest.getVersion()).orElse("[none]")));
656                } else {
657                        return npmPackageVersionResourceEntities.stream()
658                                        .map(this::loadPackageEntity)
659                                        .collect(Collectors.toList());
660                }
661        }
662
663        @Override
664        @Transactional(readOnly = true)
665        public List<NpmPackageAssetInfoJson> findPackageAssetInfoByUrl(
666                        FhirVersionEnum theFhirVersion, String theCanonicalUrl) {
667                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
668                                loadPackageInfoByCanonicalUrlAnyVersion(
669                                                theFhirVersion, theCanonicalUrl, PageRequest.of(0, 20), null, null);
670
671                return npmPackageVersionResourceEntities.stream()
672                                .map(entity -> new NpmPackageAssetInfoJson(
673                                                entity.getResourceBinary().asTypedFhirResourceId(),
674                                                entity.getCanonicalUrl(),
675                                                entity.getFhirVersion(),
676                                                entity.getPackageId(),
677                                                entity.getPackageVersion()))
678                                .toList();
679        }
680
681        // We want to load the packages marked as current version true only
682        private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrlCurrentVersionOnly(
683                        FhirVersionEnum theFhirVersion, String theCanonicalUrl, PageRequest thePageRequest) {
684                return loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, thePageRequest, null, null, true);
685        }
686
687        // We want to load the packages whether they're marked as current version or not
688        private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrlAnyVersion(
689                        FhirVersionEnum theFhirVersion,
690                        String theCanonicalUrl,
691                        PageRequest thePageRequest,
692                        @Nullable String thePackageId,
693                        @Nullable String theVersionId) {
694                return loadPackageInfoByCanonicalUrl(
695                                theFhirVersion, theCanonicalUrl, thePageRequest, thePackageId, theVersionId, null);
696        }
697
698        private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrl(
699                        FhirVersionEnum theFhirVersion,
700                        String theCanonicalUrl,
701                        PageRequest thePageRequest,
702                        @Nullable String thePackageId,
703                        @Nullable String theVersionId,
704                        Boolean theIsCurrentVersion) {
705                String canonicalUrl = theCanonicalUrl;
706
707                int versionSeparator = canonicalUrl.lastIndexOf('|');
708                Slice<NpmPackageVersionResourceEntity> slice;
709
710                if (versionSeparator != -1) {
711                        String canonicalVersion = canonicalUrl.substring(versionSeparator + 1);
712                        canonicalUrl = canonicalUrl.substring(0, versionSeparator);
713
714                        if (thePackageId != null) {
715                                if (theVersionId != null) {
716                                        slice = myPackageVersionResourceDao.findByCanonicalUrlAndVersionAndPackageIdAndVersion(
717                                                        thePageRequest,
718                                                        theFhirVersion,
719                                                        canonicalUrl,
720                                                        canonicalVersion,
721                                                        thePackageId,
722                                                        theVersionId,
723                                                        theIsCurrentVersion);
724                                } else {
725                                        slice = myPackageVersionResourceDao.findByCanonicalUrlAndVersionAndPackageId(
726                                                        thePageRequest,
727                                                        theFhirVersion,
728                                                        canonicalUrl,
729                                                        canonicalVersion,
730                                                        thePackageId,
731                                                        theIsCurrentVersion);
732                                }
733                        } else {
734                                slice = myPackageVersionResourceDao.findByCanonicalUrlAndVersion(
735                                                thePageRequest, theFhirVersion, canonicalUrl, canonicalVersion, theIsCurrentVersion);
736                        }
737
738                } else {
739                        if (thePackageId != null) {
740                                if (theVersionId != null) {
741                                        slice = myPackageVersionResourceDao.findByCanonicalUrlAndPackageIdAndVersion(
742                                                        thePageRequest,
743                                                        theFhirVersion,
744                                                        canonicalUrl,
745                                                        thePackageId,
746                                                        theVersionId,
747                                                        theIsCurrentVersion);
748                                } else {
749                                        slice = myPackageVersionResourceDao.findByCanonicalUrlAndPackageId(
750                                                        thePageRequest, theFhirVersion, canonicalUrl, thePackageId, theIsCurrentVersion);
751                                }
752                        } else {
753                                slice = myPackageVersionResourceDao.findByCanonicalUrl(
754                                                thePageRequest, theFhirVersion, canonicalUrl, theIsCurrentVersion);
755                        }
756                }
757
758                if (slice.isEmpty()) {
759                        return List.of();
760                } else {
761                        return slice.getContent();
762                }
763        }
764
765        private IBaseResource loadPackageEntity(NpmPackageVersionResourceEntity contents) {
766                return loadPackageAssetByVersionAndId(
767                                contents.getFhirVersion(), contents.getResourceBinary().getResourceId());
768        }
769
770        private IBaseResource loadPackageAssetByVersionAndId(FhirVersionEnum theFhirVersion, JpaPid theBinaryPid) {
771                try {
772                        IBaseBinary binary = getBinaryDao().readByPid(theBinaryPid);
773                        byte[] resourceContentsBytes = fetchBlobFromBinary(binary);
774                        String resourceContents = new String(resourceContentsBytes, StandardCharsets.UTF_8);
775                        FhirContext packageContext = getFhirContext(theFhirVersion);
776                        return EncodingEnum.detectEncoding(resourceContents)
777                                        .newParser(packageContext)
778                                        .parseResource(resourceContents);
779                } catch (Exception exception) {
780                        throw new InvalidRequestException(
781                                        String.format(
782                                                        "%sFailed to load package resource for FHIR version: %s and binary PID: %s",
783                                                        Msg.code(1305), theFhirVersion, theBinaryPid),
784                                        exception);
785                }
786        }
787
788        @Override
789        @Transactional
790        public NpmPackageMetadataJson loadPackageMetadata(String thePackageId) {
791                NpmPackageMetadataJson retVal = new NpmPackageMetadataJson();
792
793                Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(thePackageId);
794                if (pkg.isEmpty()) {
795                        throw new ResourceNotFoundException(Msg.code(1306) + "Unknown package ID: " + thePackageId);
796                }
797
798                List<NpmPackageVersionEntity> packageVersions =
799                                new ArrayList<>(myPackageVersionDao.findByPackageId(thePackageId));
800                packageVersions.sort(new ReverseComparator<>(
801                                (o1, o2) -> PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId())));
802
803                for (NpmPackageVersionEntity next : packageVersions) {
804                        if (next.isCurrentVersion()) {
805                                retVal.setDistTags(new NpmPackageMetadataJson.DistTags().setLatest(next.getVersionId()));
806                        }
807
808                        NpmPackageMetadataJson.Version version = new NpmPackageMetadataJson.Version();
809                        version.setFhirVersion(next.getFhirVersionId());
810                        version.setAuthor(next.getAuthor());
811                        version.setDescription(next.getDescription());
812                        version.setName(next.getPackageId());
813                        version.setVersion(next.getVersionId());
814                        version.setBytes(next.getPackageSizeBytes());
815                        retVal.addVersion(version);
816                }
817
818                return retVal;
819        }
820
821        @Override
822        @Transactional
823        public PackageContents loadPackageContents(String thePackageId, @Nullable String theVersion) {
824                Optional<NpmPackageVersionEntity> entity = loadPackageVersionEntity(thePackageId, theVersion);
825                return entity.map(this::loadPackageContents).orElse(null);
826        }
827
828        @Override
829        @Transactional
830        public NpmPackageSearchResultJson search(PackageSearchSpec thePackageSearchSpec) {
831                NpmPackageSearchResultJson retVal = new NpmPackageSearchResultJson();
832
833                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
834
835                // Query for total
836                queryForTool(thePackageSearchSpec, cb, retVal);
837
838                // Query for results
839                queryForResults(thePackageSearchSpec, cb, retVal);
840
841                return retVal;
842        }
843
844        private void queryForTool(
845                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) {
846                CriteriaQuery<Long> countCriteriaQuery = cb.createQuery(Long.class);
847                Root<NpmPackageVersionEntity> countCriteriaRoot = countCriteriaQuery.from(NpmPackageVersionEntity.class);
848                countCriteriaQuery.multiselect(cb.countDistinct(countCriteriaRoot.get("myPackageId")));
849
850                List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, countCriteriaRoot);
851
852                countCriteriaQuery.where(toPredicateArray(predicates));
853                Long total = myEntityManager.createQuery(countCriteriaQuery).getSingleResult();
854                retVal.setTotal(Math.toIntExact(total));
855        }
856
857        private void queryForResults(
858                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) {
859                CriteriaQuery<NpmPackageVersionEntity> criteriaQuery = cb.createQuery(NpmPackageVersionEntity.class);
860                Root<NpmPackageVersionEntity> root = criteriaQuery.from(NpmPackageVersionEntity.class);
861
862                List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, root);
863
864                criteriaQuery.where(toPredicateArray(predicates));
865                criteriaQuery.orderBy(cb.asc(root.get("myPackageId")));
866                TypedQuery<NpmPackageVersionEntity> query = myEntityManager.createQuery(criteriaQuery);
867                query.setFirstResult(thePackageSearchSpec.getStart());
868                query.setMaxResults(thePackageSearchSpec.getSize());
869
870                List<NpmPackageVersionEntity> resultList = query.getResultList();
871                for (NpmPackageVersionEntity next : resultList) {
872
873                        if (!retVal.hasPackageWithId(next.getPackageId())) {
874                                retVal.addObject()
875                                                .getPackage()
876                                                .setName(next.getPackageId())
877                                                .setAuthor(next.getAuthor())
878                                                .setDescription(next.getDescription())
879                                                .setVersion(next.getVersionId())
880                                                .addFhirVersion(next.getFhirVersionId())
881                                                .setBytes(next.getPackageSizeBytes());
882                        } else {
883                                NpmPackageSearchResultJson.Package retPackage = retVal.getPackageWithId(next.getPackageId());
884                                retPackage.addFhirVersion(next.getFhirVersionId());
885
886                                int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), retPackage.getVersion());
887                                if (cmp > 0) {
888                                        retPackage.setVersion(next.getVersionId());
889                                }
890                        }
891                }
892        }
893
894        @Override
895        public PackageDeleteOutcomeJson uninstallPackage(String thePackageId, String theVersion) {
896                SystemRequestDetails requestDetails =
897                                new SystemRequestDetails().setRequestPartitionId(myPartitionSettings.getDefaultRequestPartitionId());
898                return myTransactionService
899                                .withRequest(requestDetails)
900                                .execute(() -> doUninstallPackage(thePackageId, theVersion));
901        }
902
903        private PackageDeleteOutcomeJson doUninstallPackage(String thePackageId, String theVersion) {
904                PackageDeleteOutcomeJson retVal = new PackageDeleteOutcomeJson();
905
906                Optional<NpmPackageVersionEntity> packageVersion =
907                                myPackageVersionDao.findByPackageIdAndVersion(thePackageId, theVersion);
908                if (packageVersion.isPresent()) {
909
910                        String msg = "Deleting package " + thePackageId + "#" + theVersion;
911                        ourLog.info(msg);
912                        retVal.getMessage().add(msg);
913
914                        for (NpmPackageVersionResourceEntity next : packageVersion.get().getResources()) {
915                                msg = "Deleting package +" + thePackageId + "#" + theVersion + "resource: " + next.getCanonicalUrl();
916                                ourLog.info(msg);
917                                retVal.getMessage().add(msg);
918
919                                myPackageVersionResourceDao.delete(next);
920
921                                ExpungeOptions options = new ExpungeOptions();
922                                options.setExpungeDeletedResources(true).setExpungeOldVersions(true);
923                                deleteAndExpungeResourceBinary(
924                                                next.getResourceBinary().getIdDt().toVersionless(), options);
925                        }
926
927                        myPackageVersionDao.delete(packageVersion.get());
928
929                        ExpungeOptions options = new ExpungeOptions();
930                        options.setExpungeDeletedResources(true).setExpungeOldVersions(true);
931                        deleteAndExpungeResourceBinary(
932                                        packageVersion.get().getPackageBinary().getIdDt().toVersionless(), options);
933
934                        Collection<NpmPackageVersionEntity> remainingVersions = myPackageVersionDao.findByPackageId(thePackageId);
935                        if (remainingVersions.isEmpty()) {
936                                msg = "No versions of package " + thePackageId + " remain";
937                                ourLog.info(msg);
938                                retVal.getMessage().add(msg);
939                                Optional<NpmPackageEntity> pkgEntity = myPackageDao.findByPackageId(thePackageId);
940                                pkgEntity.ifPresent(pkgEntityPresent -> myPackageDao.delete(pkgEntityPresent));
941                        } else {
942
943                                List<NpmPackageVersionEntity> versions = remainingVersions.stream()
944                                                .sorted((o1, o2) ->
945                                                                PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId()))
946                                                .toList();
947                                for (int i = 0; i < versions.size(); i++) {
948                                        boolean isCurrent = i == versions.size() - 1;
949                                        if (isCurrent != versions.get(i).isCurrentVersion()) {
950                                                versions.get(i).setCurrentVersion(isCurrent);
951                                                myPackageVersionDao.save(versions.get(i));
952                                        }
953                                }
954                        }
955
956                } else {
957
958                        String msg = "No package found with the given ID";
959                        retVal.getMessage().add(msg);
960                }
961
962                return retVal;
963        }
964
965        @Override
966        @Transactional
967        public List<IBaseResource> loadPackageAssetsByType(FhirVersionEnum theFhirVersion, String theResourceType) {
968                Slice<NpmPackageVersionResourceEntity> outcome = myPackageVersionResourceDao.findByResourceType(
969                                PageRequest.of(0, 1000), theFhirVersion, theResourceType, true);
970                return outcome.stream().map(this::loadPackageEntity).collect(Collectors.toList());
971        }
972
973        private void deleteAndExpungeResourceBinary(IIdType theResourceBinaryId, ExpungeOptions theOptions) {
974                SystemRequestDetails requestDetails = new SystemRequestDetails()
975                                .setRequestPartitionId(HapiTransactionService.getRequestPartitionAssociatedWithThread());
976
977                getBinaryDao().delete(theResourceBinaryId, requestDetails);
978                getBinaryDao().forceExpungeInExistingTransaction(theResourceBinaryId, theOptions, requestDetails);
979        }
980
981        @Nonnull
982        public List<Predicate> createSearchPredicates(
983                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder theCb, Root<NpmPackageVersionEntity> theRoot) {
984                List<Predicate> predicates = new ArrayList<>();
985
986                if (isNotBlank(thePackageSearchSpec.getResourceUrl())) {
987                        Join<NpmPackageVersionEntity, NpmPackageVersionResourceEntity> resources =
988                                        theRoot.join("myResources", JoinType.LEFT);
989
990                        predicates.add(theCb.equal(resources.get("myCanonicalUrl"), thePackageSearchSpec.getResourceUrl()));
991                }
992
993                if (isNotBlank(thePackageSearchSpec.getVersion())) {
994                        String searchTerm = thePackageSearchSpec.getVersion() + "%";
995                        predicates.add(theCb.like(theRoot.get("myVersionId"), searchTerm));
996                }
997
998                if (isNotBlank(thePackageSearchSpec.getDescription())) {
999                        String searchTerm = "%" + thePackageSearchSpec.getDescription() + "%";
1000                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
1001                        predicates.add(theCb.like(theCb.upper(theRoot.get("myDescriptionUpper")), searchTerm));
1002                }
1003
1004                if (isNotBlank(thePackageSearchSpec.getAuthor())) {
1005                        String searchTerm = "%" + thePackageSearchSpec.getAuthor() + "%";
1006                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
1007                        predicates.add(theCb.like(theRoot.get("myAuthorUpper"), searchTerm));
1008                }
1009
1010                if (isNotBlank(thePackageSearchSpec.getFhirVersion())) {
1011                        if (!PATTERN_FHIR_VERSION
1012                                        .matcher(thePackageSearchSpec.getFhirVersion())
1013                                        .matches()) {
1014                                FhirVersionEnum versionEnum = FhirVersionEnum.forVersionString(thePackageSearchSpec.getFhirVersion());
1015                                if (versionEnum != null) {
1016                                        predicates.add(theCb.equal(theRoot.get("myFhirVersion").as(String.class), versionEnum.name()));
1017                                }
1018                        } else {
1019                                predicates.add(theCb.like(theRoot.get("myFhirVersionId"), thePackageSearchSpec.getFhirVersion() + "%"));
1020                        }
1021                }
1022
1023                return predicates;
1024        }
1025
1026        @SuppressWarnings("unchecked")
1027        public static List<String> getProcessingMessages(NpmPackage thePackage) {
1028                return (List<String>)
1029                                thePackage.getUserData().computeIfAbsent("JpPackageCache_ProcessingMessages", t -> new ArrayList<>());
1030        }
1031
1032        /**
1033         * Truncates a string to {@link NpmPackageVersionEntity#PACKAGE_DESC_LENGTH} which is
1034         * the maximum length used on several columns in {@link NpmPackageVersionEntity}. If the
1035         * string is longer than the maximum allowed, the last 3 characters are replaced with "..."
1036         */
1037        private static String truncateStorageString(String theInput) {
1038                String retVal = null;
1039                if (theInput != null) {
1040                        if (theInput.length() > NpmPackageVersionEntity.PACKAGE_DESC_LENGTH) {
1041                                retVal = theInput.substring(0, NpmPackageVersionEntity.PACKAGE_DESC_LENGTH - 4) + "...";
1042                        } else {
1043                                retVal = theInput;
1044                        }
1045                }
1046                return retVal;
1047        }
1048}