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