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.fetchDataByteArrayFromBinary(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
305                        String packageDesc = truncateStorageString(npmPackage.description());
306                        String packageAuthor = truncateStorageString(npmPackage.getNpm().asString("author"));
307
308                        if (currentVersion) {
309                                getProcessingMessages(npmPackage)
310                                                .add("Marking package " + packageId + "#" + initialPackageVersionId + " as current version");
311                                pkg.setCurrentVersionId(packageVersionId);
312                                pkg.setDescription(packageDesc);
313                                myPackageDao.save(pkg);
314                        } else {
315                                getProcessingMessages(npmPackage)
316                                                .add("Package " + packageId + "#" + initialPackageVersionId + " is not the newest version");
317                        }
318
319                        packageVersion = new NpmPackageVersionEntity();
320                        packageVersion.setPackageId(packageId);
321                        packageVersion.setVersionId(packageVersionId);
322                        packageVersion.setPackage(pkg);
323                        packageVersion.setPackageBinary(persistedPackage);
324                        packageVersion.setSavedTime(new Date());
325                        packageVersion.setAuthor(packageAuthor);
326                        packageVersion.setDescription(packageDesc);
327                        packageVersion.setFhirVersionId(npmPackage.fhirVersion());
328                        packageVersion.setFhirVersion(fhirVersion);
329                        packageVersion.setCurrentVersion(currentVersion);
330                        packageVersion.setPackageSizeBytes(bytes.length);
331                        packageVersion = myPackageVersionDao.save(packageVersion);
332
333                        String dirName = "package";
334                        NpmPackage.NpmPackageFolder packageFolder = npmPackage.getFolders().get(dirName);
335                        Map<String, List<String>> packageFolderTypes = null;
336                        try {
337                                packageFolderTypes = packageFolder.getTypes();
338                        } catch (IOException e) {
339                                throw new InternalErrorException(Msg.code(2371) + e);
340                        }
341                        for (Map.Entry<String, List<String>> nextTypeToFiles : packageFolderTypes.entrySet()) {
342                                String nextType = nextTypeToFiles.getKey();
343                                for (String nextFile : nextTypeToFiles.getValue()) {
344
345                                        byte[] contents;
346                                        String contentsString;
347                                        try {
348                                                contents = packageFolder.fetchFile(nextFile);
349                                                contentsString = toUtf8String(contents);
350                                        } catch (IOException e) {
351                                                throw new InternalErrorException(Msg.code(1300) + e);
352                                        }
353
354                                        IBaseResource resource;
355                                        if (nextFile.toLowerCase().endsWith(".xml")) {
356                                                resource = packageContext.newXmlParser().parseResource(contentsString);
357                                        } else if (nextFile.toLowerCase().endsWith(".json")) {
358                                                resource = packageContext.newJsonParser().parseResource(contentsString);
359                                        } else {
360                                                getProcessingMessages(npmPackage).add("Not indexing file: " + nextFile);
361                                                continue;
362                                        }
363
364                                        /*
365                                         * Re-encode the resource as JSON with the narrative removed in order to reduce the footprint.
366                                         * This is useful since we'll be loading these resources back and hopefully keeping lots of
367                                         * them in memory in order to speed up validation activities.
368                                         */
369                                        String contentType = Constants.CT_FHIR_JSON_NEW;
370                                        ResourceUtil.removeNarrative(packageContext, resource);
371                                        byte[] minimizedContents = packageContext
372                                                        .newJsonParser()
373                                                        .encodeResourceToString(resource)
374                                                        .getBytes(StandardCharsets.UTF_8);
375
376                                        IBaseBinary resourceBinary = createPackageResourceBinary(nextFile, minimizedContents, contentType);
377                                        ResourceTable persistedResource = createResourceBinary(resourceBinary);
378
379                                        NpmPackageVersionResourceEntity resourceEntity = new NpmPackageVersionResourceEntity();
380                                        resourceEntity.setPackageVersion(packageVersion);
381                                        resourceEntity.setResourceBinary(persistedResource);
382                                        resourceEntity.setDirectory(dirName);
383                                        resourceEntity.setFhirVersionId(npmPackage.fhirVersion());
384                                        resourceEntity.setFhirVersion(fhirVersion);
385                                        resourceEntity.setFilename(nextFile);
386                                        resourceEntity.setResourceType(nextType);
387                                        resourceEntity.setResSizeBytes(contents.length);
388                                        BaseRuntimeChildDefinition urlChild =
389                                                        packageContext.getResourceDefinition(nextType).getChildByName("url");
390                                        BaseRuntimeChildDefinition versionChild =
391                                                        packageContext.getResourceDefinition(nextType).getChildByName("version");
392                                        String url = null;
393                                        String version = null;
394                                        if (urlChild != null) {
395                                                url = urlChild.getAccessor()
396                                                                .getFirstValueOrNull(resource)
397                                                                .map(t -> ((IPrimitiveType<?>) t).getValueAsString())
398                                                                .orElse(null);
399                                                resourceEntity.setCanonicalUrl(url);
400                                                version = versionChild
401                                                                .getAccessor()
402                                                                .getFirstValueOrNull(resource)
403                                                                .map(t -> ((IPrimitiveType<?>) t).getValueAsString())
404                                                                .orElse(null);
405                                                resourceEntity.setCanonicalVersion(version);
406                                        }
407                                        myPackageVersionResourceDao.save(resourceEntity);
408
409                                        String resType = packageContext.getResourceType(resource);
410                                        String msg = "Indexing " + resType + " Resource[" + dirName + '/' + nextFile + "] with URL: "
411                                                        + defaultString(url) + "|" + defaultString(version);
412                                        getProcessingMessages(npmPackage).add(msg);
413                                        ourLog.info("Package[{}#{}] " + msg, packageId, packageVersionId);
414                                }
415                        }
416
417                        getProcessingMessages(npmPackage)
418                                        .add("Successfully added package " + npmPackage.id() + "#" + npmPackage.version() + " to registry");
419
420                        return npmPackage;
421                });
422        }
423
424        @Override
425        public NpmPackage addPackageToCache(
426                        String thePackageId, String thePackageVersionId, InputStream thePackageTgzInputStream, String theSourceDesc)
427                        throws IOException {
428                NpmPackageData npmData = myPackageLoaderSvc.createNpmPackageDataFromData(
429                                thePackageId, thePackageVersionId, theSourceDesc, thePackageTgzInputStream);
430
431                return addPackageToCacheInternal(npmData);
432        }
433
434        private ResourceTable createResourceBinary(IBaseBinary theResourceBinary) {
435
436                if (myPartitionSettings.isPartitioningEnabled()) {
437                        SystemRequestDetails requestDetails = new SystemRequestDetails();
438                        if (myPartitionSettings.isUnnamedPartitionMode() && myPartitionSettings.getDefaultPartitionId() != null) {
439                                requestDetails.setRequestPartitionId(
440                                                RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()));
441                        } else {
442                                requestDetails.setTenantId(JpaConstants.DEFAULT_PARTITION_NAME);
443                        }
444                        return (ResourceTable)
445                                        getBinaryDao().create(theResourceBinary, requestDetails).getEntity();
446                } else {
447                        return (ResourceTable) getBinaryDao().create(theResourceBinary).getEntity();
448                }
449        }
450
451        private boolean updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion(
452                        String thePackageId, String thePackageVersion) {
453                Collection<NpmPackageVersionEntity> existingVersions = myPackageVersionDao.findByPackageId(thePackageId);
454                boolean retVal = true;
455
456                for (NpmPackageVersionEntity next : existingVersions) {
457                        int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), thePackageVersion);
458                        assert cmp != 0;
459                        if (cmp < 0) {
460                                if (next.isCurrentVersion()) {
461                                        next.setCurrentVersion(false);
462                                        myPackageVersionDao.save(next);
463                                }
464                        } else {
465                                retVal = false;
466                        }
467                }
468
469                return retVal;
470        }
471
472        @Nonnull
473        public FhirContext getFhirContext(FhirVersionEnum theFhirVersion) {
474                return myVersionToContext.computeIfAbsent(theFhirVersion, v -> new FhirContext(v));
475        }
476
477        private IBaseBinary createPackageBinary(byte[] theBytes) {
478                IBaseBinary binary = BinaryUtil.newBinary(myCtx);
479                BinaryUtil.setData(myCtx, binary, theBytes, Constants.CT_APPLICATION_GZIP);
480                return binary;
481        }
482
483        private IBaseBinary createPackageResourceBinary(String theFileName, byte[] theBytes, String theContentType) {
484                IBaseBinary binary = BinaryUtil.newBinary(myCtx);
485                BinaryUtil.setData(myCtx, binary, theBytes, theContentType);
486                return binary;
487        }
488
489        private NpmPackageEntity createPackage(NpmPackage theNpmPackage) {
490                NpmPackageEntity entity = new NpmPackageEntity();
491                entity.setPackageId(theNpmPackage.id());
492                entity.setCurrentVersionId(theNpmPackage.version());
493                return myPackageDao.save(entity);
494        }
495
496        @Override
497        @Transactional
498        public NpmPackage loadPackage(String thePackageId, String thePackageVersion) throws FHIRException, IOException {
499                // check package cache
500                NpmPackage cachedPackage = loadPackageFromCacheOnly(thePackageId, thePackageVersion);
501                if (cachedPackage != null) {
502                        return cachedPackage;
503                }
504
505                // otherwise we have to load it from packageloader
506                NpmPackageData pkgData = myPackageLoaderSvc.fetchPackageFromPackageSpec(thePackageId, thePackageVersion);
507
508                try {
509                        // and add it to the cache
510                        NpmPackage retVal = addPackageToCacheInternal(pkgData);
511                        getProcessingMessages(retVal)
512                                        .add(
513                                                        0,
514                                                        "Package fetched from server at: "
515                                                                        + pkgData.getPackage().url());
516                        return retVal;
517                } finally {
518                        pkgData.getInputStream().close();
519                }
520        }
521
522        @Override
523        public NpmPackage loadPackage(String theS) throws FHIRException, IOException {
524                return loadPackage(theS, null);
525        }
526
527        private TransactionTemplate newTxTemplate() {
528                return new TransactionTemplate(myTxManager);
529        }
530
531        @Override
532        @Transactional(propagation = Propagation.NEVER)
533        public NpmPackage installPackage(PackageInstallationSpec theInstallationSpec) throws IOException {
534                Validate.notBlank(theInstallationSpec.getName(), "thePackageId must not be blank");
535                Validate.notBlank(theInstallationSpec.getVersion(), "thePackageVersion must not be blank");
536
537                String sourceDescription = "Embedded content";
538                if (isNotBlank(theInstallationSpec.getPackageUrl())) {
539                        byte[] contents = myPackageLoaderSvc.loadPackageUrlContents(theInstallationSpec.getPackageUrl());
540                        theInstallationSpec.setPackageContents(contents);
541                        sourceDescription = theInstallationSpec.getPackageUrl();
542                }
543
544                if (theInstallationSpec.getPackageContents() != null) {
545                        return addPackageToCache(
546                                        theInstallationSpec.getName(),
547                                        theInstallationSpec.getVersion(),
548                                        new ByteArrayInputStream(theInstallationSpec.getPackageContents()),
549                                        sourceDescription);
550                }
551
552                return newTxTemplate().execute(tx -> {
553                        try {
554                                return loadPackage(theInstallationSpec.getName(), theInstallationSpec.getVersion());
555                        } catch (IOException e) {
556                                throw new InternalErrorException(Msg.code(1302) + e);
557                        }
558                });
559        }
560
561        @Override
562        @Transactional(readOnly = true)
563        public IBaseResource loadPackageAssetByUrl(FhirVersionEnum theFhirVersion, String theCanonicalUrl) {
564
565                String canonicalUrl = theCanonicalUrl;
566
567                int versionSeparator = canonicalUrl.lastIndexOf('|');
568                Slice<NpmPackageVersionResourceEntity> slice;
569                if (versionSeparator != -1) {
570                        String canonicalVersion = canonicalUrl.substring(versionSeparator + 1);
571                        canonicalUrl = canonicalUrl.substring(0, versionSeparator);
572                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersion(
573                                        PageRequest.of(0, 1), theFhirVersion, canonicalUrl, canonicalVersion);
574                } else {
575                        slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrl(
576                                        PageRequest.of(0, 1), theFhirVersion, canonicalUrl);
577                }
578
579                if (slice.isEmpty()) {
580                        return null;
581                } else {
582                        NpmPackageVersionResourceEntity contents = slice.getContent().get(0);
583                        return loadPackageEntity(contents);
584                }
585        }
586
587        private IBaseResource loadPackageEntity(NpmPackageVersionResourceEntity contents) {
588                try {
589                        JpaPid binaryPid = JpaPid.fromId(contents.getResourceBinary().getId());
590                        IBaseBinary binary = getBinaryDao().readByPid(binaryPid);
591                        byte[] resourceContentsBytes = fetchBlobFromBinary(binary);
592                        String resourceContents = new String(resourceContentsBytes, StandardCharsets.UTF_8);
593                        FhirContext packageContext = getFhirContext(contents.getFhirVersion());
594                        return EncodingEnum.detectEncoding(resourceContents)
595                                        .newParser(packageContext)
596                                        .parseResource(resourceContents);
597                } catch (Exception e) {
598                        throw new RuntimeException(Msg.code(1305) + "Failed to load package resource " + contents, e);
599                }
600        }
601
602        @Override
603        @Transactional
604        public NpmPackageMetadataJson loadPackageMetadata(String thePackageId) {
605                NpmPackageMetadataJson retVal = new NpmPackageMetadataJson();
606
607                Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(thePackageId);
608                if (!pkg.isPresent()) {
609                        throw new ResourceNotFoundException(Msg.code(1306) + "Unknown package ID: " + thePackageId);
610                }
611
612                List<NpmPackageVersionEntity> packageVersions =
613                                new ArrayList<>(myPackageVersionDao.findByPackageId(thePackageId));
614                packageVersions.sort(new ReverseComparator<>(
615                                (o1, o2) -> PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId())));
616
617                for (NpmPackageVersionEntity next : packageVersions) {
618                        if (next.isCurrentVersion()) {
619                                retVal.setDistTags(new NpmPackageMetadataJson.DistTags().setLatest(next.getVersionId()));
620                        }
621
622                        NpmPackageMetadataJson.Version version = new NpmPackageMetadataJson.Version();
623                        version.setFhirVersion(next.getFhirVersionId());
624                        version.setAuthor(next.getAuthor());
625                        version.setDescription(next.getDescription());
626                        version.setName(next.getPackageId());
627                        version.setVersion(next.getVersionId());
628                        version.setBytes(next.getPackageSizeBytes());
629                        retVal.addVersion(version);
630                }
631
632                return retVal;
633        }
634
635        @Override
636        @Transactional
637        public PackageContents loadPackageContents(String thePackageId, String theVersion) {
638                Optional<NpmPackageVersionEntity> entity = loadPackageVersionEntity(thePackageId, theVersion);
639                return entity.map(t -> loadPackageContents(t)).orElse(null);
640        }
641
642        @Override
643        @Transactional
644        public NpmPackageSearchResultJson search(PackageSearchSpec thePackageSearchSpec) {
645                NpmPackageSearchResultJson retVal = new NpmPackageSearchResultJson();
646
647                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
648
649                // Query for total
650                {
651                        CriteriaQuery<Long> countCriteriaQuery = cb.createQuery(Long.class);
652                        Root<NpmPackageVersionEntity> countCriteriaRoot = countCriteriaQuery.from(NpmPackageVersionEntity.class);
653                        countCriteriaQuery.multiselect(cb.countDistinct(countCriteriaRoot.get("myPackageId")));
654
655                        List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, countCriteriaRoot);
656
657                        countCriteriaQuery.where(toPredicateArray(predicates));
658                        Long total = myEntityManager.createQuery(countCriteriaQuery).getSingleResult();
659                        retVal.setTotal(Math.toIntExact(total));
660                }
661
662                // Query for results
663                {
664                        CriteriaQuery<NpmPackageVersionEntity> criteriaQuery = cb.createQuery(NpmPackageVersionEntity.class);
665                        Root<NpmPackageVersionEntity> root = criteriaQuery.from(NpmPackageVersionEntity.class);
666
667                        List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, root);
668
669                        criteriaQuery.where(toPredicateArray(predicates));
670                        criteriaQuery.orderBy(cb.asc(root.get("myPackageId")));
671                        TypedQuery<NpmPackageVersionEntity> query = myEntityManager.createQuery(criteriaQuery);
672                        query.setFirstResult(thePackageSearchSpec.getStart());
673                        query.setMaxResults(thePackageSearchSpec.getSize());
674
675                        List<NpmPackageVersionEntity> resultList = query.getResultList();
676                        for (NpmPackageVersionEntity next : resultList) {
677
678                                if (!retVal.hasPackageWithId(next.getPackageId())) {
679                                        retVal.addObject()
680                                                        .getPackage()
681                                                        .setName(next.getPackageId())
682                                                        .setAuthor(next.getAuthor())
683                                                        .setDescription(next.getDescription())
684                                                        .setVersion(next.getVersionId())
685                                                        .addFhirVersion(next.getFhirVersionId())
686                                                        .setBytes(next.getPackageSizeBytes());
687                                } else {
688                                        NpmPackageSearchResultJson.Package retPackage = retVal.getPackageWithId(next.getPackageId());
689                                        retPackage.addFhirVersion(next.getFhirVersionId());
690
691                                        int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), retPackage.getVersion());
692                                        if (cmp > 0) {
693                                                retPackage.setVersion(next.getVersionId());
694                                        }
695                                }
696                        }
697                }
698
699                return retVal;
700        }
701
702        @Override
703        @Transactional
704        public PackageDeleteOutcomeJson uninstallPackage(String thePackageId, String theVersion) {
705                PackageDeleteOutcomeJson retVal = new PackageDeleteOutcomeJson();
706
707                Optional<NpmPackageVersionEntity> packageVersion =
708                                myPackageVersionDao.findByPackageIdAndVersion(thePackageId, theVersion);
709                if (packageVersion.isPresent()) {
710
711                        String msg = "Deleting package " + thePackageId + "#" + theVersion;
712                        ourLog.info(msg);
713                        retVal.getMessage().add(msg);
714
715                        for (NpmPackageVersionResourceEntity next : packageVersion.get().getResources()) {
716                                msg = "Deleting package +" + thePackageId + "#" + theVersion + "resource: " + next.getCanonicalUrl();
717                                ourLog.info(msg);
718                                retVal.getMessage().add(msg);
719
720                                myPackageVersionResourceDao.delete(next);
721
722                                ExpungeOptions options = new ExpungeOptions();
723                                options.setExpungeDeletedResources(true).setExpungeOldVersions(true);
724                                deleteAndExpungeResourceBinary(
725                                                next.getResourceBinary().getIdDt().toVersionless(), options);
726                        }
727
728                        myPackageVersionDao.delete(packageVersion.get());
729
730                        ExpungeOptions options = new ExpungeOptions();
731                        options.setExpungeDeletedResources(true).setExpungeOldVersions(true);
732                        deleteAndExpungeResourceBinary(
733                                        packageVersion.get().getPackageBinary().getIdDt().toVersionless(), options);
734
735                        Collection<NpmPackageVersionEntity> remainingVersions = myPackageVersionDao.findByPackageId(thePackageId);
736                        if (remainingVersions.size() == 0) {
737                                msg = "No versions of package " + thePackageId + " remain";
738                                ourLog.info(msg);
739                                retVal.getMessage().add(msg);
740                                Optional<NpmPackageEntity> pkgEntity = myPackageDao.findByPackageId(thePackageId);
741                                myPackageDao.delete(pkgEntity.get());
742                        } else {
743
744                                List<NpmPackageVersionEntity> versions = remainingVersions.stream()
745                                                .sorted((o1, o2) ->
746                                                                PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId()))
747                                                .collect(Collectors.toList());
748                                for (int i = 0; i < versions.size(); i++) {
749                                        boolean isCurrent = i == versions.size() - 1;
750                                        if (isCurrent != versions.get(i).isCurrentVersion()) {
751                                                versions.get(i).setCurrentVersion(isCurrent);
752                                                myPackageVersionDao.save(versions.get(i));
753                                        }
754                                }
755                        }
756
757                } else {
758
759                        String msg = "No package found with the given ID";
760                        retVal.getMessage().add(msg);
761                }
762
763                return retVal;
764        }
765
766        @Override
767        @Transactional
768        public List<IBaseResource> loadPackageAssetsByType(FhirVersionEnum theFhirVersion, String theResourceType) {
769                //              List<NpmPackageVersionResourceEntity> outcome = myPackageVersionResourceDao.findAll();
770                Slice<NpmPackageVersionResourceEntity> outcome = myPackageVersionResourceDao.findCurrentVersionByResourceType(
771                                PageRequest.of(0, 1000), theFhirVersion, theResourceType);
772                return outcome.stream().map(t -> loadPackageEntity(t)).collect(Collectors.toList());
773        }
774
775        private void deleteAndExpungeResourceBinary(IIdType theResourceBinaryId, ExpungeOptions theOptions) {
776                getBinaryDao().delete(theResourceBinaryId, new SystemRequestDetails()).getEntity();
777                getBinaryDao().forceExpungeInExistingTransaction(theResourceBinaryId, theOptions, new SystemRequestDetails());
778        }
779
780        @Nonnull
781        public List<Predicate> createSearchPredicates(
782                        PackageSearchSpec thePackageSearchSpec, CriteriaBuilder theCb, Root<NpmPackageVersionEntity> theRoot) {
783                List<Predicate> predicates = new ArrayList<>();
784
785                if (isNotBlank(thePackageSearchSpec.getResourceUrl())) {
786                        Join<NpmPackageVersionEntity, NpmPackageVersionResourceEntity> resources =
787                                        theRoot.join("myResources", JoinType.LEFT);
788
789                        predicates.add(theCb.equal(resources.get("myCanonicalUrl"), thePackageSearchSpec.getResourceUrl()));
790                }
791
792                if (isNotBlank(thePackageSearchSpec.getVersion())) {
793                        String searchTerm = thePackageSearchSpec.getVersion() + "%";
794                        predicates.add(theCb.like(theRoot.get("myVersionId"), searchTerm));
795                }
796
797                if (isNotBlank(thePackageSearchSpec.getDescription())) {
798                        String searchTerm = "%" + thePackageSearchSpec.getDescription() + "%";
799                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
800                        predicates.add(theCb.like(theCb.upper(theRoot.get("myDescriptionUpper")), searchTerm));
801                }
802
803                if (isNotBlank(thePackageSearchSpec.getAuthor())) {
804                        String searchTerm = "%" + thePackageSearchSpec.getAuthor() + "%";
805                        searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm);
806                        predicates.add(theCb.like(theRoot.get("myAuthorUpper"), searchTerm));
807                }
808
809                if (isNotBlank(thePackageSearchSpec.getFhirVersion())) {
810                        if (!thePackageSearchSpec.getFhirVersion().matches("([0-9]+\\.)+[0-9]+")) {
811                                FhirVersionEnum versionEnum = FhirVersionEnum.forVersionString(thePackageSearchSpec.getFhirVersion());
812                                if (versionEnum != null) {
813                                        predicates.add(theCb.equal(theRoot.get("myFhirVersion").as(String.class), versionEnum.name()));
814                                }
815                        } else {
816                                predicates.add(theCb.like(theRoot.get("myFhirVersionId"), thePackageSearchSpec.getFhirVersion() + "%"));
817                        }
818                }
819
820                return predicates;
821        }
822
823        @SuppressWarnings("unchecked")
824        public static List<String> getProcessingMessages(NpmPackage thePackage) {
825                return (List<String>)
826                                thePackage.getUserData().computeIfAbsent("JpPackageCache_ProcessingMessages", t -> new ArrayList<>());
827        }
828
829        /**
830         * Truncates a string to {@link NpmPackageVersionEntity#PACKAGE_DESC_LENGTH} which is
831         * the maximum length used on several columns in {@link NpmPackageVersionEntity}. If the
832         * string is longer than the maximum allowed, the last 3 characters are replaced with "..."
833         */
834        private static String truncateStorageString(String theInput) {
835                String retVal = null;
836                if (theInput != null) {
837                        if (theInput.length() > NpmPackageVersionEntity.PACKAGE_DESC_LENGTH) {
838                                retVal = theInput.substring(0, NpmPackageVersionEntity.PACKAGE_DESC_LENGTH - 4) + "...";
839                        } else {
840                                retVal = theInput;
841                        }
842                }
843                return retVal;
844        }
845}