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.interceptor.model.RequestPartitionId;
027import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
028import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
029import ca.uhn.fhir.jpa.api.model.ExpungeOptions;
030import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc;
031import ca.uhn.fhir.jpa.binary.svc.NullBinaryStorageSvcImpl;
032import ca.uhn.fhir.jpa.dao.data.INpmPackageDao;
033import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionDao;
034import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionResourceDao;
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        @Override
144        public void addPackageServer(@Nonnull PackageServer thePackageServer) {
145                assert myPackageLoaderSvc != null;
146                myPackageLoaderSvc.addPackageServer(thePackageServer);
147        }
148
149        @Override
150        public String getPackageId(String theS) throws IOException {
151                return myPackageLoaderSvc.getPackageId(theS);
152        }
153
154        @Override
155        public void setSilent(boolean silent) {
156                myPackageLoaderSvc.setSilent(silent);
157        }
158
159        @Override
160        public String getPackageUrl(String theS) throws IOException {
161                return myPackageLoaderSvc.getPackageUrl(theS);
162        }
163
164        @Override
165        public List<PackageServer> getPackageServers() {
166                return myPackageLoaderSvc.getPackageServers();
167        }
168
169        @Override
170        protected BasePackageCacheManager.InputStreamWithSrc loadFromPackageServer(String id, String version) {
171                throw new UnsupportedOperationException(Msg.code(2220) + "Use PackageLoaderSvc for loading packages.");
172        }
173
174        @Override
175        @Transactional
176        public NpmPackage loadPackageFromCacheOnly(String theId, @Nullable String theVersion) {
177                return loadPackageFromCacheOnlyInner(theId, theVersion);
178        }
179
180        @Nullable
181        private NpmPackage loadPackageFromCacheOnlyInner(String theId, @Nullable String theVersion) {
182                Optional<NpmPackageVersionEntity> packageVersion = loadPackageVersionEntity(theId, theVersion);
183                if (theVersion != null && packageVersion.isEmpty() && theVersion.endsWith(".x")) {
184                        String lookupVersion = theVersion;
185                        do {
186                                lookupVersion = lookupVersion.substring(0, lookupVersion.length() - 2);
187                        } while (lookupVersion.endsWith(".x"));
188
189                        List<String> candidateVersionIds =
190                                        myPackageVersionDao.findVersionIdsByPackageIdAndLikeVersion(theId, lookupVersion + ".%");
191                        if (!candidateVersionIds.isEmpty()) {
192                                candidateVersionIds.sort(PackageVersionComparator.INSTANCE);
193                                packageVersion =
194                                                loadPackageVersionEntity(theId, candidateVersionIds.get(candidateVersionIds.size() - 1));
195                        }
196                }
197
198                return packageVersion.map(this::loadPackage).orElse(null);
199        }
200
201        private Optional<NpmPackageVersionEntity> loadPackageVersionEntity(String theId, @Nullable String theVersion) {
202                Validate.notBlank(theId, "theId must be populated");
203
204                Optional<NpmPackageVersionEntity> packageVersion = Optional.empty();
205                if (isNotBlank(theVersion) && !"latest".equals(theVersion)) {
206                        packageVersion = myPackageVersionDao.findByPackageIdAndVersion(theId, theVersion);
207                } else {
208                        Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(theId);
209                        if (pkg.isPresent()) {
210                                packageVersion = myPackageVersionDao.findByPackageIdAndVersion(
211                                                theId, pkg.get().getCurrentVersionId());
212                        }
213                }
214                return packageVersion;
215        }
216
217        private NpmPackage loadPackage(NpmPackageVersionEntity thePackageVersion) {
218                PackageContents content = loadPackageContents(thePackageVersion);
219                ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes());
220                try {
221                        return NpmPackage.fromPackage(inputStream);
222                } catch (IOException e) {
223                        throw new InternalErrorException(Msg.code(1294) + e);
224                }
225        }
226
227        private IHapiPackageCacheManager.PackageContents loadPackageContents(NpmPackageVersionEntity thePackageVersion) {
228                IFhirResourceDao<? extends IBaseBinary> binaryDao = getBinaryDao();
229                IBaseBinary binary =
230                                binaryDao.readByPid(thePackageVersion.getPackageBinary().getId());
231                try {
232                        byte[] content = fetchBlobFromBinary(binary);
233                        return new PackageContents()
234                                        .setBytes(content)
235                                        .setPackageId(thePackageVersion.getPackageId())
236                                        .setVersion(thePackageVersion.getVersionId())
237                                        .setLastModified(thePackageVersion.getUpdatedTime());
238                } catch (IOException e) {
239                        throw new InternalErrorException(
240                                        Msg.code(1295) + "Failed to load package. There was a problem reading binaries", e);
241                }
242        }
243
244        /**
245         * Helper method which will attempt to use the IBinaryStorageSvc to resolve the binary blob if available. If
246         * the bean is unavailable, fallback to assuming we are using an embedded base64 in the data element.
247         * @param theBinary the Binary who's `data` blob you want to retrieve
248         * @return a byte array containing the blob.
249         *
250         * @throws IOException
251         */
252        private byte[] fetchBlobFromBinary(IBaseBinary theBinary) throws IOException {
253                if (myBinaryStorageSvc != null && !(myBinaryStorageSvc instanceof NullBinaryStorageSvcImpl)) {
254                        return myBinaryStorageSvc.fetchDataByteArrayFromBinary(theBinary);
255                } else {
256                        byte[] value = BinaryUtil.getOrCreateData(myCtx, theBinary).getValue();
257                        if (value == null) {
258                                throw new InternalErrorException(
259                                                Msg.code(1296) + "Failed to fetch blob from Binary/" + theBinary.getIdElement());
260                        }
261                        return value;
262                }
263        }
264
265        @SuppressWarnings("unchecked")
266        private IFhirResourceDao<IBaseBinary> getBinaryDao() {
267                return myDaoRegistry.getResourceDao("Binary");
268        }
269
270        private NpmPackage addPackageToCacheInternal(NpmPackageData thePackageData) {
271                NpmPackage npmPackage = thePackageData.getPackage();
272                String packageId = thePackageData.getPackageId();
273                String initialPackageVersionId = thePackageData.getPackageVersionId();
274                byte[] bytes = thePackageData.getBytes();
275
276                if (!npmPackage.id().equalsIgnoreCase(packageId)) {
277                        throw new InvalidRequestException(
278                                        Msg.code(1297) + "Package ID " + npmPackage.id() + " doesn't match expected: " + packageId);
279                }
280                if (!PackageVersionComparator.isEquivalent(initialPackageVersionId, npmPackage.version())) {
281                        throw new InvalidRequestException(Msg.code(1298) + "Package ID " + npmPackage.version()
282                                        + " doesn't match expected: " + initialPackageVersionId);
283                }
284
285                String packageVersionId = npmPackage.version();
286                FhirVersionEnum fhirVersion = FhirVersionEnum.forVersionString(npmPackage.fhirVersion());
287                if (fhirVersion == null) {
288                        throw new InvalidRequestException(Msg.code(1299) + "Unknown FHIR version: " + npmPackage.fhirVersion());
289                }
290                FhirContext packageContext = getFhirContext(fhirVersion);
291
292                IBaseBinary binary = createPackageBinary(bytes);
293
294                return newTxTemplate().execute(tx -> {
295                        ResourceTable persistedPackage = createResourceBinary(binary);
296                        NpmPackageEntity pkg = myPackageDao.findByPackageId(packageId).orElseGet(() -> createPackage(npmPackage));
297                        NpmPackageVersionEntity packageVersion = myPackageVersionDao
298                                        .findByPackageIdAndVersion(packageId, packageVersionId)
299                                        .orElse(null);
300                        if (packageVersion != null) {
301                                NpmPackage existingPackage =
302                                                loadPackageFromCacheOnlyInner(packageVersion.getPackageId(), packageVersion.getVersionId());
303                                if (existingPackage == null) {
304                                        return null;
305                                }
306                                String msg = "Package version already exists in local storage, no action taken: " + packageId + "#"
307                                                + packageVersionId;
308                                getProcessingMessages(existingPackage).add(msg);
309                                ourLog.info(msg);
310                                return existingPackage;
311                        }
312
313                        boolean currentVersion =
314                                        updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion(packageId, packageVersionId);
315
316                        String packageDesc = truncateStorageString(npmPackage.description());
317                        String packageAuthor = truncateStorageString(npmPackage.getNpm().asString("author"));
318
319                        if (currentVersion) {
320                                getProcessingMessages(npmPackage)
321                                                .add("Marking package " + packageId + "#" + initialPackageVersionId + " as current version");
322                                pkg.setCurrentVersionId(packageVersionId);
323                                pkg.setDescription(packageDesc);
324                                myPackageDao.save(pkg);
325                        } else {
326                                getProcessingMessages(npmPackage)
327                                                .add("Package " + packageId + "#" + initialPackageVersionId + " is not the newest version");
328                        }
329
330                        packageVersion = new NpmPackageVersionEntity();
331                        packageVersion.setPackageId(packageId);
332                        packageVersion.setVersionId(packageVersionId);
333                        packageVersion.setPackage(pkg);
334                        packageVersion.setPackageBinary(persistedPackage);
335                        packageVersion.setSavedTime(new Date());
336                        packageVersion.setAuthor(packageAuthor);
337                        packageVersion.setDescription(packageDesc);
338                        packageVersion.setFhirVersionId(npmPackage.fhirVersion());
339                        packageVersion.setFhirVersion(fhirVersion);
340                        packageVersion.setCurrentVersion(currentVersion);
341                        packageVersion.setPackageSizeBytes(bytes.length);
342                        packageVersion = myPackageVersionDao.save(packageVersion);
343
344                        String dirName = "package";
345                        NpmPackage.NpmPackageFolder packageFolder = npmPackage.getFolders().get(dirName);
346                        Map<String, List<String>> packageFolderTypes = null;
347                        try {
348                                packageFolderTypes = packageFolder.getTypes();
349                        } catch (IOException e) {
350                                throw new InternalErrorException(Msg.code(2371) + e);
351                        }
352                        for (Map.Entry<String, List<String>> nextTypeToFiles : packageFolderTypes.entrySet()) {
353                                String nextType = nextTypeToFiles.getKey();
354                                for (String nextFile : nextTypeToFiles.getValue()) {
355
356                                        byte[] contents;
357                                        String contentsString;
358                                        try {
359                                                contents = packageFolder.fetchFile(nextFile);
360                                                contentsString = toUtf8String(contents);
361                                        } catch (IOException e) {
362                                                throw new InternalErrorException(Msg.code(1300) + e);
363                                        }
364
365                                        IBaseResource resource;
366                                        if (nextFile.toLowerCase().endsWith(".xml")) {
367                                                resource = packageContext.newXmlParser().parseResource(contentsString);
368                                        } else if (nextFile.toLowerCase().endsWith(".json")) {
369                                                resource = packageContext.newJsonParser().parseResource(contentsString);
370                                        } else {
371                                                getProcessingMessages(npmPackage).add("Not indexing file: " + nextFile);
372                                                continue;
373                                        }
374
375                                        /*
376                                         * Re-encode the resource as JSON with the narrative removed in order to reduce the footprint.
377                                         * This is useful since we'll be loading these resources back and hopefully keeping lots of
378                                         * them in memory in order to speed up validation activities.
379                                         */
380                                        String contentType = Constants.CT_FHIR_JSON_NEW;
381                                        ResourceUtil.removeNarrative(packageContext, resource);
382                                        byte[] minimizedContents = packageContext
383                                                        .newJsonParser()
384                                                        .encodeResourceToString(resource)
385                                                        .getBytes(StandardCharsets.UTF_8);
386
387                                        IBaseBinary resourceBinary = createPackageResourceBinary(minimizedContents, contentType);
388                                        ResourceTable persistedResource = createResourceBinary(resourceBinary);
389
390                                        NpmPackageVersionResourceEntity resourceEntity = new NpmPackageVersionResourceEntity();
391                                        resourceEntity.setPackageVersion(packageVersion);
392                                        resourceEntity.setResourceBinary(persistedResource);
393                                        resourceEntity.setDirectory(dirName);
394                                        resourceEntity.setFhirVersionId(npmPackage.fhirVersion());
395                                        resourceEntity.setFhirVersion(fhirVersion);
396                                        resourceEntity.setFilename(nextFile);
397                                        resourceEntity.setResourceType(nextType);
398                                        resourceEntity.setResSizeBytes(contents.length);
399                                        BaseRuntimeChildDefinition urlChild =
400                                                        packageContext.getResourceDefinition(nextType).getChildByName("url");
401                                        BaseRuntimeChildDefinition versionChild =
402                                                        packageContext.getResourceDefinition(nextType).getChildByName("version");
403                                        String url = null;
404                                        String version = null;
405                                        if (urlChild != null) {
406                                                url = urlChild.getAccessor()
407                                                                .getFirstValueOrNull(resource)
408                                                                .map(t -> ((IPrimitiveType<?>) t).getValueAsString())
409                                                                .orElse(null);
410                                                resourceEntity.setCanonicalUrl(url);
411
412                                                Optional<IBase> resourceVersion =
413                                                                versionChild.getAccessor().getFirstValueOrNull(resource);
414                                                if (resourceVersion.isPresent() && resourceVersion.get() instanceof IPrimitiveType) {
415                                                        version = ((IPrimitiveType<?>) resourceVersion.get()).getValueAsString();
416                                                } else if (resourceVersion.isPresent()
417                                                                && resourceVersion.get() instanceof IBaseBackboneElement) {
418                                                        version = String.valueOf(myCtx.newFhirPath()
419                                                                        .evaluateFirst(resourceVersion.get(), "value", IPrimitiveType.class)
420                                                                        .orElse(null));
421                                                } else {
422                                                        version = null;
423                                                }
424                                                resourceEntity.setCanonicalVersion(version);
425                                        }
426                                        myPackageVersionResourceDao.save(resourceEntity);
427
428                                        String resType = packageContext.getResourceType(resource);
429                                        String msg = "Indexing " + resType + " Resource[" + dirName + '/' + nextFile + "] with URL: "
430                                                        + defaultString(url) + "|" + defaultString(version);
431                                        getProcessingMessages(npmPackage).add(msg);
432                                        ourLog.info("{}: Package[{}#{}] ", msg, packageId, packageVersionId);
433                                }
434                        }
435
436                        getProcessingMessages(npmPackage)
437                                        .add("Successfully added package " + npmPackage.id() + "#" + npmPackage.version() + " to registry");
438
439                        return npmPackage;
440                });
441        }
442
443        @Override
444        public NpmPackage addPackageToCache(
445                        String thePackageId, String thePackageVersionId, InputStream thePackageTgzInputStream, String theSourceDesc)
446                        throws IOException {
447                NpmPackageData npmData = myPackageLoaderSvc.createNpmPackageDataFromData(
448                                thePackageId, thePackageVersionId, theSourceDesc, thePackageTgzInputStream);
449
450                return addPackageToCacheInternal(npmData);
451        }
452
453        private ResourceTable createResourceBinary(IBaseBinary theResourceBinary) {
454
455                if (myPartitionSettings.isPartitioningEnabled()) {
456                        SystemRequestDetails requestDetails = new SystemRequestDetails();
457                        if (myPartitionSettings.isUnnamedPartitionMode() && myPartitionSettings.getDefaultPartitionId() != null) {
458                                requestDetails.setRequestPartitionId(
459                                                RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()));
460                        } else {
461                                requestDetails.setTenantId(JpaConstants.DEFAULT_PARTITION_NAME);
462                        }
463                        return (ResourceTable)
464                                        getBinaryDao().create(theResourceBinary, requestDetails).getEntity();
465                } else {
466                        return (ResourceTable) getBinaryDao()
467                                        .create(theResourceBinary, new SystemRequestDetails())
468                                        .getEntity();
469                }
470        }
471
472        private boolean updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion(
473                        String thePackageId, String thePackageVersion) {
474                Collection<NpmPackageVersionEntity> existingVersions = myPackageVersionDao.findByPackageId(thePackageId);
475                boolean retVal = true;
476
477                for (NpmPackageVersionEntity next : existingVersions) {
478                        int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), thePackageVersion);
479                        assert cmp != 0;
480                        if (cmp < 0) {
481                                if (next.isCurrentVersion()) {
482                                        next.setCurrentVersion(false);
483                                        myPackageVersionDao.save(next);
484                                }
485                        } else {
486                                retVal = false;
487                        }
488                }
489
490                return retVal;
491        }
492
493        @Nonnull
494        public FhirContext getFhirContext(FhirVersionEnum theFhirVersion) {
495                return myVersionToContext.computeIfAbsent(theFhirVersion, FhirContext::new);
496        }
497
498        private IBaseBinary createPackageBinary(byte[] theBytes) {
499                IBaseBinary binary = BinaryUtil.newBinary(myCtx);
500                BinaryUtil.setData(myCtx, binary, theBytes, Constants.CT_APPLICATION_GZIP);
501                return binary;
502        }
503
504        private IBaseBinary createPackageResourceBinary(byte[] theBytes, String theContentType) {
505                IBaseBinary binary = BinaryUtil.newBinary(myCtx);
506                BinaryUtil.setData(myCtx, binary, theBytes, theContentType);
507                return binary;
508        }
509
510        private NpmPackageEntity createPackage(NpmPackage theNpmPackage) {
511                NpmPackageEntity entity = new NpmPackageEntity();
512                entity.setPackageId(theNpmPackage.id());
513                entity.setCurrentVersionId(theNpmPackage.version());
514                return myPackageDao.save(entity);
515        }
516
517        @Override
518        @Transactional
519        public NpmPackage loadPackage(String thePackageId, String thePackageVersion) throws FHIRException, IOException {
520                return loadPackageInner(thePackageId, thePackageVersion);
521        }
522
523        @Nonnull
524        private NpmPackage loadPackageInner(String thePackageId, String thePackageVersion) throws IOException {
525                // check package cache
526                NpmPackage cachedPackage = loadPackageFromCacheOnlyInner(thePackageId, thePackageVersion);
527                if (cachedPackage != null) {
528                        return cachedPackage;
529                }
530
531                // otherwise we have to load it from packageloader
532                NpmPackageData pkgData = myPackageLoaderSvc.fetchPackageFromPackageSpec(thePackageId, thePackageVersion);
533
534                try {
535                        // and add it to the cache
536                        NpmPackage retVal = addPackageToCacheInternal(pkgData);
537                        getProcessingMessages(retVal)
538                                        .add(
539                                                        0,
540                                                        "Package fetched from server at: "
541                                                                        + pkgData.getPackage().url());
542                        return retVal;
543                } finally {
544                        pkgData.getInputStream().close();
545                }
546        }
547
548        @Override
549        @Transactional
550        public NpmPackage loadPackage(String theS) throws FHIRException, IOException {
551                return loadPackageInner(theS, null);
552        }
553
554        private TransactionTemplate newTxTemplate() {
555                return new TransactionTemplate(myTxManager);
556        }
557
558        @Override
559        @Transactional(propagation = Propagation.NEVER)
560        public NpmPackage installPackage(PackageInstallationSpec theInstallationSpec) throws IOException {
561                Validate.notBlank(theInstallationSpec.getName(), "thePackageId must not be blank");
562                Validate.notBlank(theInstallationSpec.getVersion(), "thePackageVersion must not be blank");
563
564                String sourceDescription = "Embedded content";
565                if (isNotBlank(theInstallationSpec.getPackageUrl())) {
566                        byte[] contents = myPackageLoaderSvc.loadPackageUrlContents(theInstallationSpec.getPackageUrl());
567                        theInstallationSpec.setPackageContents(contents);
568                        sourceDescription = theInstallationSpec.getPackageUrl();
569                }
570
571                if (theInstallationSpec.getPackageContents() != null) {
572                        return addPackageToCache(
573                                        theInstallationSpec.getName(),
574                                        theInstallationSpec.getVersion(),
575                                        new ByteArrayInputStream(theInstallationSpec.getPackageContents()),
576                                        sourceDescription);
577                }
578
579                return newTxTemplate().execute(tx -> {
580                        try {
581                                return loadPackageInner(theInstallationSpec.getName(), theInstallationSpec.getVersion());
582                        } catch (IOException e) {
583                                throw new InternalErrorException(Msg.code(1302) + e);
584                        }
585                });
586        }
587
588        @Override
589        @Transactional(readOnly = true)
590        public IBaseResource loadPackageAssetByUrl(FhirVersionEnum theFhirVersion, String theCanonicalUrl) {
591
592                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
593                                loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, 2, null, null);
594
595                if (npmPackageVersionResourceEntities.isEmpty()) {
596                        return null;
597                } else {
598                        if (npmPackageVersionResourceEntities.size() > 1) {
599                                ourLog.warn(
600                                                "Found multiple package versions for FHIR version: {} and canonical URL: {}",
601                                                theFhirVersion,
602                                                theCanonicalUrl);
603                        }
604                        final NpmPackageVersionResourceEntity contents = npmPackageVersionResourceEntities.get(0);
605                        return loadPackageEntity(contents);
606                }
607        }
608
609        @Override
610        @Transactional(readOnly = true)
611        public IBaseResource findPackageAsset(FindPackageAssetRequest theRequest) {
612
613                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = loadPackageInfoByCanonicalUrl(
614                                theRequest.getFhirVersion(),
615                                theRequest.getCanonicalUrl(),
616                                2, // We set it to 2 so that if we get more than one we can warn
617                                theRequest.getPackageId(),
618                                theRequest.getVersion());
619
620                if (npmPackageVersionResourceEntities.isEmpty()) {
621                        throw new ResourceNotFoundException(
622                                        "%s Could not find asset for FHIR version: %s, canonical URL: %s, package ID: %s and package version: %s"
623                                                        .formatted(
624                                                                        Msg.code(2644),
625                                                                        theRequest.getFhirVersion(),
626                                                                        theRequest.getCanonicalUrl(),
627                                                                        theRequest.getPackageId(),
628                                                                        Optional.ofNullable(theRequest.getVersion()).orElse("[none]")));
629                } else {
630                        if (npmPackageVersionResourceEntities.size() > 1) {
631                                ourLog.warn(
632                                                "Found multiple package versions for FHIR version: {} and canonical URL: {}",
633                                                theRequest.getFhirVersion(),
634                                                theRequest.getCanonicalUrl());
635                        }
636                        final NpmPackageVersionResourceEntity contents = npmPackageVersionResourceEntities.get(0);
637                        return loadPackageEntity(contents);
638                }
639        }
640
641        @Override
642        @Transactional(readOnly = true)
643        public List<NpmPackageAssetInfoJson> findPackageAssetInfoByUrl(
644                        FhirVersionEnum theFhirVersion, String theCanonicalUrl) {
645                final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities =
646                                loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, 20, null, null);
647
648                return npmPackageVersionResourceEntities.stream()
649                                .map(entity -> new NpmPackageAssetInfoJson(
650                                                entity.getResourceBinary().asTypedFhirResourceId(),
651                                                entity.getCanonicalUrl(),
652                                                entity.getFhirVersion(),
653                                                entity.getPackageId(),
654                                                entity.getPackageVersion()))
655                                .collect(Collectors.toUnmodifiableList());
656        }
657
658        private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrl(
659                        FhirVersionEnum theFhirVersion,
660                        String theCanonicalUrl,
661                        int thePageSize,
662                        @Nullable String thePackageId,
663                        @Nullable String theVersionId) {
664                String canonicalUrl = theCanonicalUrl;
665
666                int versionSeparator = canonicalUrl.lastIndexOf('|');
667                Slice<NpmPackageVersionResourceEntity> slice;
668
669                final PageRequest pageRequest = PageRequest.of(0, thePageSize);
670
671                if (versionSeparator != -1) {
672                        String canonicalVersion = canonicalUrl.substring(versionSeparator + 1);
673                        canonicalUrl = canonicalUrl.substring(0, versionSeparator);
674
675                        if (thePackageId != null) {
676                                if (theVersionId != null) {
677                                        slice =
678                                                        myPackageVersionResourceDao
679                                                                        .findCurrentVersionByCanonicalUrlAndVersionAndPackageIdAndVersion(
680                                                                                        pageRequest,
681                                                                                        theFhirVersion,
682                                                                                        canonicalUrl,
683                                                                                        canonicalVersion,
684                                                                                        thePackageId,
685                                                                                        theVersionId);
686                                } else {
687                                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersionAndPackageId(
688                                                        pageRequest, theFhirVersion, canonicalUrl, canonicalVersion, thePackageId);
689                                }
690                        } else {
691                                slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersion(
692                                                pageRequest, theFhirVersion, canonicalUrl, canonicalVersion);
693                        }
694
695                } else {
696                        if (thePackageId != null) {
697                                if (theVersionId != null) {
698                                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndPackageIdAndVersion(
699                                                        pageRequest, theFhirVersion, canonicalUrl, thePackageId, theVersionId);
700                                } else {
701                                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndPackageId(
702                                                        pageRequest, theFhirVersion, canonicalUrl, thePackageId);
703                                }
704                        } else {
705                                slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrl(
706                                                pageRequest, theFhirVersion, canonicalUrl);
707                        }
708                }
709
710                if (slice.isEmpty()) {
711                        return List.of();
712                } else {
713                        return slice.getContent();
714                }
715        }
716
717        private IBaseResource loadPackageEntity(NpmPackageVersionResourceEntity contents) {
718                return loadPackageAssetByVersionAndId(
719                                contents.getFhirVersion(), contents.getResourceBinary().getResourceId());
720        }
721
722        private IBaseResource loadPackageAssetByVersionAndId(FhirVersionEnum theFhirVersion, JpaPid theBinaryPid) {
723                try {
724                        IBaseBinary binary = getBinaryDao().readByPid(theBinaryPid);
725                        byte[] resourceContentsBytes = fetchBlobFromBinary(binary);
726                        String resourceContents = new String(resourceContentsBytes, StandardCharsets.UTF_8);
727                        FhirContext packageContext = getFhirContext(theFhirVersion);
728                        return EncodingEnum.detectEncoding(resourceContents)
729                                        .newParser(packageContext)
730                                        .parseResource(resourceContents);
731                } catch (Exception exception) {
732                        throw new InvalidRequestException(
733                                        String.format(
734                                                        "%sFailed to load package resource for FHIR version: %s and binary PID: %s",
735                                                        Msg.code(1305), theFhirVersion, theBinaryPid),
736                                        exception);
737                }
738        }
739
740        @Override
741        @Transactional
742        public NpmPackageMetadataJson loadPackageMetadata(String thePackageId) {
743                NpmPackageMetadataJson retVal = new NpmPackageMetadataJson();
744
745                Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(thePackageId);
746                if (!pkg.isPresent()) {
747                        throw new ResourceNotFoundException(Msg.code(1306) + "Unknown package ID: " + thePackageId);
748                }
749
750                List<NpmPackageVersionEntity> packageVersions =
751                                new ArrayList<>(myPackageVersionDao.findByPackageId(thePackageId));
752                packageVersions.sort(new ReverseComparator<>(
753                                (o1, o2) -> PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId())));
754
755                for (NpmPackageVersionEntity next : packageVersions) {
756                        if (next.isCurrentVersion()) {
757                                retVal.setDistTags(new NpmPackageMetadataJson.DistTags().setLatest(next.getVersionId()));
758                        }
759
760                        NpmPackageMetadataJson.Version version = new NpmPackageMetadataJson.Version();
761                        version.setFhirVersion(next.getFhirVersionId());
762                        version.setAuthor(next.getAuthor());
763                        version.setDescription(next.getDescription());
764                        version.setName(next.getPackageId());
765                        version.setVersion(next.getVersionId());
766                        version.setBytes(next.getPackageSizeBytes());
767                        retVal.addVersion(version);
768                }
769
770                return retVal;
771        }
772
773        @Override
774        @Transactional
775        public PackageContents loadPackageContents(String thePackageId, @Nullable String theVersion) {
776                Optional<NpmPackageVersionEntity> entity = loadPackageVersionEntity(thePackageId, theVersion);
777                return entity.map(this::loadPackageContents).orElse(null);
778        }
779
780        @Override
781        @Transactional
782        public NpmPackageSearchResultJson search(PackageSearchSpec thePackageSearchSpec) {
783                NpmPackageSearchResultJson retVal = new NpmPackageSearchResultJson();
784
785                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
786
787                // Query for total
788                queryForTool(thePackageSearchSpec, cb, retVal);
789
790                // Query for results
791                queryForResults(thePackageSearchSpec, cb, retVal);
792
793                return retVal;
794        }
795
796        private void queryForTool(
797                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) {
798                CriteriaQuery<Long> countCriteriaQuery = cb.createQuery(Long.class);
799                Root<NpmPackageVersionEntity> countCriteriaRoot = countCriteriaQuery.from(NpmPackageVersionEntity.class);
800                countCriteriaQuery.multiselect(cb.countDistinct(countCriteriaRoot.get("myPackageId")));
801
802                List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, countCriteriaRoot);
803
804                countCriteriaQuery.where(toPredicateArray(predicates));
805                Long total = myEntityManager.createQuery(countCriteriaQuery).getSingleResult();
806                retVal.setTotal(Math.toIntExact(total));
807        }
808
809        private void queryForResults(
810                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) {
811                CriteriaQuery<NpmPackageVersionEntity> criteriaQuery = cb.createQuery(NpmPackageVersionEntity.class);
812                Root<NpmPackageVersionEntity> root = criteriaQuery.from(NpmPackageVersionEntity.class);
813
814                List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, root);
815
816                criteriaQuery.where(toPredicateArray(predicates));
817                criteriaQuery.orderBy(cb.asc(root.get("myPackageId")));
818                TypedQuery<NpmPackageVersionEntity> query = myEntityManager.createQuery(criteriaQuery);
819                query.setFirstResult(thePackageSearchSpec.getStart());
820                query.setMaxResults(thePackageSearchSpec.getSize());
821
822                List<NpmPackageVersionEntity> resultList = query.getResultList();
823                for (NpmPackageVersionEntity next : resultList) {
824
825                        if (!retVal.hasPackageWithId(next.getPackageId())) {
826                                retVal.addObject()
827                                                .getPackage()
828                                                .setName(next.getPackageId())
829                                                .setAuthor(next.getAuthor())
830                                                .setDescription(next.getDescription())
831                                                .setVersion(next.getVersionId())
832                                                .addFhirVersion(next.getFhirVersionId())
833                                                .setBytes(next.getPackageSizeBytes());
834                        } else {
835                                NpmPackageSearchResultJson.Package retPackage = retVal.getPackageWithId(next.getPackageId());
836                                retPackage.addFhirVersion(next.getFhirVersionId());
837
838                                int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), retPackage.getVersion());
839                                if (cmp > 0) {
840                                        retPackage.setVersion(next.getVersionId());
841                                }
842                        }
843                }
844        }
845
846        @Override
847        @Transactional
848        public PackageDeleteOutcomeJson uninstallPackage(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                                                .collect(Collectors.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                getBinaryDao().delete(theResourceBinaryId, new SystemRequestDetails()).getEntity();
920                getBinaryDao().forceExpungeInExistingTransaction(theResourceBinaryId, theOptions, new SystemRequestDetails());
921        }
922
923        @Nonnull
924        public List<Predicate> createSearchPredicates(
925                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder theCb, Root<NpmPackageVersionEntity> theRoot) {
926                List<Predicate> predicates = new ArrayList<>();
927
928                if (isNotBlank(thePackageSearchSpec.getResourceUrl())) {
929                        Join<NpmPackageVersionEntity, NpmPackageVersionResourceEntity> resources =
930                                        theRoot.join("myResources", JoinType.LEFT);
931
932                        predicates.add(theCb.equal(resources.get("myCanonicalUrl"), thePackageSearchSpec.getResourceUrl()));
933                }
934
935                if (isNotBlank(thePackageSearchSpec.getVersion())) {
936                        String searchTerm = thePackageSearchSpec.getVersion() + "%";
937                        predicates.add(theCb.like(theRoot.get("myVersionId"), searchTerm));
938                }
939
940                if (isNotBlank(thePackageSearchSpec.getDescription())) {
941                        String searchTerm = "%" + thePackageSearchSpec.getDescription() + "%";
942                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
943                        predicates.add(theCb.like(theCb.upper(theRoot.get("myDescriptionUpper")), searchTerm));
944                }
945
946                if (isNotBlank(thePackageSearchSpec.getAuthor())) {
947                        String searchTerm = "%" + thePackageSearchSpec.getAuthor() + "%";
948                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
949                        predicates.add(theCb.like(theRoot.get("myAuthorUpper"), searchTerm));
950                }
951
952                if (isNotBlank(thePackageSearchSpec.getFhirVersion())) {
953                        if (!PATTERN_FHIR_VERSION
954                                        .matcher(thePackageSearchSpec.getFhirVersion())
955                                        .matches()) {
956                                FhirVersionEnum versionEnum = FhirVersionEnum.forVersionString(thePackageSearchSpec.getFhirVersion());
957                                if (versionEnum != null) {
958                                        predicates.add(theCb.equal(theRoot.get("myFhirVersion").as(String.class), versionEnum.name()));
959                                }
960                        } else {
961                                predicates.add(theCb.like(theRoot.get("myFhirVersionId"), thePackageSearchSpec.getFhirVersion() + "%"));
962                        }
963                }
964
965                return predicates;
966        }
967
968        @SuppressWarnings("unchecked")
969        public static List<String> getProcessingMessages(NpmPackage thePackage) {
970                return (List<String>)
971                                thePackage.getUserData().computeIfAbsent("JpPackageCache_ProcessingMessages", t -> new ArrayList<>());
972        }
973
974        /**
975         * Truncates a string to {@link NpmPackageVersionEntity#PACKAGE_DESC_LENGTH} which is
976         * the maximum length used on several columns in {@link NpmPackageVersionEntity}. If the
977         * string is longer than the maximum allowed, the last 3 characters are replaced with "..."
978         */
979        private static String truncateStorageString(String theInput) {
980                String retVal = null;
981                if (theInput != null) {
982                        if (theInput.length() > NpmPackageVersionEntity.PACKAGE_DESC_LENGTH) {
983                                retVal = theInput.substring(0, NpmPackageVersionEntity.PACKAGE_DESC_LENGTH - 4) + "...";
984                        } else {
985                                retVal = theInput;
986                        }
987                }
988                return retVal;
989        }
990}