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