001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2025 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.packages;
021
022import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
023import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition;
024import ca.uhn.fhir.context.BaseRuntimeElementDefinition;
025import ca.uhn.fhir.context.FhirContext;
026import ca.uhn.fhir.context.FhirVersionEnum;
027import ca.uhn.fhir.context.support.IValidationSupport;
028import ca.uhn.fhir.context.support.ValidationSupportContext;
029import ca.uhn.fhir.i18n.Msg;
030import ca.uhn.fhir.interceptor.model.RequestPartitionId;
031import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
032import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
033import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
034import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome;
035import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionDao;
036import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService;
037import ca.uhn.fhir.jpa.dao.validation.SearchParameterDaoValidator;
038import ca.uhn.fhir.jpa.model.config.PartitionSettings;
039import ca.uhn.fhir.jpa.model.entity.NpmPackageVersionEntity;
040import ca.uhn.fhir.jpa.packages.loader.PackageResourceParsingSvc;
041import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
042import ca.uhn.fhir.jpa.searchparam.registry.ISearchParamRegistryController;
043import ca.uhn.fhir.jpa.searchparam.util.SearchParameterHelper;
044import ca.uhn.fhir.model.primitive.IdDt;
045import ca.uhn.fhir.rest.api.server.IBundleProvider;
046import ca.uhn.fhir.rest.api.server.RequestDetails;
047import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
048import ca.uhn.fhir.rest.param.StringParam;
049import ca.uhn.fhir.rest.param.TokenParam;
050import ca.uhn.fhir.rest.param.UriParam;
051import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException;
052import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
053import ca.uhn.fhir.util.FhirTerser;
054import ca.uhn.fhir.util.MetaUtil;
055import ca.uhn.fhir.util.SearchParameterUtil;
056import ca.uhn.hapi.converters.canonical.VersionCanonicalizer;
057import com.google.common.annotations.VisibleForTesting;
058import jakarta.annotation.PostConstruct;
059import org.apache.commons.lang3.Validate;
060import org.hl7.fhir.instance.model.api.IBase;
061import org.hl7.fhir.instance.model.api.IBaseResource;
062import org.hl7.fhir.instance.model.api.IIdType;
063import org.hl7.fhir.instance.model.api.IPrimitiveType;
064import org.hl7.fhir.r4.model.Identifier;
065import org.hl7.fhir.r4.model.MetadataResource;
066import org.hl7.fhir.r4.model.ResourceType;
067import org.hl7.fhir.utilities.json.model.JsonObject;
068import org.hl7.fhir.utilities.npm.IPackageCacheManager;
069import org.hl7.fhir.utilities.npm.NpmPackage;
070import org.slf4j.Logger;
071import org.slf4j.LoggerFactory;
072import org.springframework.beans.factory.annotation.Autowired;
073
074import java.io.IOException;
075import java.util.Collection;
076import java.util.HashSet;
077import java.util.List;
078import java.util.Optional;
079
080import static ca.uhn.fhir.jpa.packages.util.PackageUtils.DEFAULT_INSTALL_TYPES;
081import static ca.uhn.fhir.util.SearchParameterUtil.getBaseAsStrings;
082
083/**
084 * @since 5.1.0
085 */
086public class PackageInstallerSvcImpl implements IPackageInstallerSvc {
087
088        private static final Logger ourLog = LoggerFactory.getLogger(PackageInstallerSvcImpl.class);
089        private static final String OUR_PIPE_CHARACTER = "|";
090
091        boolean enabled = true;
092
093        @Autowired
094        private FhirContext myFhirContext;
095
096        @Autowired
097        private DaoRegistry myDaoRegistry;
098
099        @Autowired
100        private IValidationSupport validationSupport;
101
102        @Autowired
103        private IHapiPackageCacheManager myPackageCacheManager;
104
105        @Autowired
106        private IHapiTransactionService myTxService;
107
108        @Autowired
109        private INpmPackageVersionDao myPackageVersionDao;
110
111        @Autowired
112        private ISearchParamRegistryController mySearchParamRegistryController;
113
114        @Autowired
115        private PartitionSettings myPartitionSettings;
116
117        @Autowired
118        private SearchParameterHelper mySearchParameterHelper;
119
120        @Autowired
121        private PackageResourceParsingSvc myPackageResourceParsingSvc;
122
123        @Autowired
124        private JpaStorageSettings myStorageSettings;
125
126        @Autowired
127        private SearchParameterDaoValidator mySearchParameterDaoValidator;
128
129        @Autowired
130        private VersionCanonicalizer myVersionCanonicalizer;
131
132        /**
133         * Constructor
134         */
135        public PackageInstallerSvcImpl() {
136                super();
137        }
138
139        @PostConstruct
140        public void initialize() {
141                switch (myFhirContext.getVersion().getVersion()) {
142                        case R5:
143                        case R4B:
144                        case R4:
145                        case DSTU3:
146                                break;
147
148                        case DSTU2:
149                        case DSTU2_HL7ORG:
150                        case DSTU2_1:
151                        default: {
152                                ourLog.info(
153                                                "IG installation not supported for version: {}",
154                                                myFhirContext.getVersion().getVersion());
155                                enabled = false;
156                        }
157                }
158        }
159
160        @Override
161        public PackageDeleteOutcomeJson uninstall(PackageInstallationSpec theInstallationSpec) {
162                PackageDeleteOutcomeJson outcome =
163                                myPackageCacheManager.uninstallPackage(theInstallationSpec.getName(), theInstallationSpec.getVersion());
164                validationSupport.invalidateCaches();
165                return outcome;
166        }
167
168        /**
169         * Loads and installs an IG from a file on disk or the Simplifier repo using
170         * the {@link IPackageCacheManager}.
171         * <p>
172         * Installs the IG by persisting instances of the following types of resources:
173         * <p>
174         * - NamingSystem, CodeSystem, ValueSet, StructureDefinition (with snapshots),
175         * ConceptMap, SearchParameter, Subscription
176         * <p>
177         * Creates the resources if non-existent, updates them otherwise.
178         *
179         * @param theInstallationSpec The details about what should be installed
180         */
181        @SuppressWarnings("ConstantConditions")
182        @Override
183        public PackageInstallOutcomeJson install(PackageInstallationSpec theInstallationSpec)
184                        throws ImplementationGuideInstallationException {
185                PackageInstallOutcomeJson retVal = new PackageInstallOutcomeJson();
186                if (enabled) {
187                        try {
188
189                                boolean exists = myTxService
190                                                .withSystemRequest()
191                                                .withRequestPartitionId(RequestPartitionId.defaultPartition())
192                                                .execute(() -> {
193                                                        Optional<NpmPackageVersionEntity> existing = myPackageVersionDao.findByPackageIdAndVersion(
194                                                                        theInstallationSpec.getName(), theInstallationSpec.getVersion());
195                                                        return existing.isPresent();
196                                                });
197                                if (exists) {
198                                        ourLog.info(
199                                                        "Package {}#{} is already installed",
200                                                        theInstallationSpec.getName(),
201                                                        theInstallationSpec.getVersion());
202                                }
203
204                                NpmPackage npmPackage = myPackageCacheManager.installPackage(theInstallationSpec);
205                                if (npmPackage == null) {
206                                        throw new IOException(Msg.code(1284) + "Package not found");
207                                }
208
209                                retVal.getMessage().addAll(JpaPackageCache.getProcessingMessages(npmPackage));
210
211                                if (theInstallationSpec.isFetchDependencies()) {
212                                        fetchAndInstallDependencies(npmPackage, theInstallationSpec, retVal);
213                                }
214
215                                if (theInstallationSpec.getInstallMode() == PackageInstallationSpec.InstallModeEnum.STORE_AND_INSTALL) {
216                                        install(npmPackage, theInstallationSpec, retVal);
217
218                                        // If any SearchParameters were installed, let's load them right away
219                                        mySearchParamRegistryController.refreshCacheIfNecessary();
220                                }
221
222                                validationSupport.invalidateCaches();
223
224                        } catch (IOException e) {
225                                throw new ImplementationGuideInstallationException(
226                                                Msg.code(1285) + "Could not load NPM package " + theInstallationSpec.getName() + "#"
227                                                                + theInstallationSpec.getVersion(),
228                                                e);
229                        }
230                }
231
232                return retVal;
233        }
234
235        /**
236         * Installs a package and its dependencies.
237         * <p>
238         * Fails fast if one of its dependencies could not be installed.
239         *
240         * @throws ImplementationGuideInstallationException if installation fails
241         */
242        private void install(
243                        NpmPackage npmPackage, PackageInstallationSpec theInstallationSpec, PackageInstallOutcomeJson theOutcome)
244                        throws ImplementationGuideInstallationException {
245                String name = npmPackage.getNpm().get("name").asJsonString().getValue();
246                String version = npmPackage.getNpm().get("version").asJsonString().getValue();
247
248                String fhirVersion = npmPackage.fhirVersion();
249                String currentFhirVersion = myFhirContext.getVersion().getVersion().getFhirVersionString();
250                assertFhirVersionsAreCompatible(fhirVersion, currentFhirVersion);
251
252                List<String> installTypes;
253                if (!theInstallationSpec.getInstallResourceTypes().isEmpty()) {
254                        installTypes = theInstallationSpec.getInstallResourceTypes();
255                } else {
256                        installTypes = DEFAULT_INSTALL_TYPES;
257                }
258
259                ourLog.info("Installing package: {}#{}", name, version);
260                int[] count = new int[installTypes.size()];
261
262                for (int i = 0; i < installTypes.size(); i++) {
263                        String type = installTypes.get(i);
264
265                        Collection<IBaseResource> resources = myPackageResourceParsingSvc.parseResourcesOfType(type, npmPackage);
266                        count[i] = resources.size();
267
268                        for (IBaseResource next : resources) {
269                                try {
270                                        next = isStructureDefinitionWithoutSnapshot(next) ? generateSnapshot(next) : next;
271                                        install(next, theInstallationSpec, theOutcome);
272                                } catch (Exception e) {
273                                        ourLog.warn(
274                                                        "Failed to upload resource of type {} with ID {} - Error: {}",
275                                                        myFhirContext.getResourceType(next),
276                                                        next.getIdElement().getValue(),
277                                                        e.toString());
278                                        throw new ImplementationGuideInstallationException(
279                                                        Msg.code(1286) + String.format("Error installing IG %s#%s: %s", name, version, e), e);
280                                }
281                        }
282                }
283                ourLog.info(String.format("Finished installation of package %s#%s:", name, version));
284
285                for (int i = 0; i < count.length; i++) {
286                        ourLog.info(String.format("-- Created or updated %s resources of type %s", count[i], installTypes.get(i)));
287                }
288        }
289
290        private void fetchAndInstallDependencies(
291                        NpmPackage npmPackage, PackageInstallationSpec theInstallationSpec, PackageInstallOutcomeJson theOutcome)
292                        throws ImplementationGuideInstallationException {
293                if (npmPackage.getNpm().has("dependencies")) {
294                        JsonObject dependenciesElement =
295                                        npmPackage.getNpm().get("dependencies").asJsonObject();
296                        for (String id : dependenciesElement.getNames()) {
297                                String ver = dependenciesElement.getJsonString(id).asString();
298                                try {
299                                        theOutcome
300                                                        .getMessage()
301                                                        .add("Package " + npmPackage.id() + "#" + npmPackage.version() + " depends on package " + id
302                                                                        + "#" + ver);
303
304                                        boolean skip = false;
305                                        for (String next : theInstallationSpec.getDependencyExcludes()) {
306                                                if (id.matches(next)) {
307                                                        theOutcome
308                                                                        .getMessage()
309                                                                        .add("Not installing dependency " + id + " because it matches exclude criteria: "
310                                                                                        + next);
311                                                        skip = true;
312                                                        break;
313                                                }
314                                        }
315                                        if (skip) {
316                                                continue;
317                                        }
318
319                                        // resolve in local cache or on packages.fhir.org
320                                        NpmPackage dependency = myPackageCacheManager.loadPackage(id, ver);
321                                        // recursive call to install dependencies of a package before
322                                        // installing the package
323                                        fetchAndInstallDependencies(dependency, theInstallationSpec, theOutcome);
324
325                                        if (theInstallationSpec.getInstallMode()
326                                                        == PackageInstallationSpec.InstallModeEnum.STORE_AND_INSTALL) {
327                                                install(dependency, theInstallationSpec, theOutcome);
328                                        }
329
330                                } catch (IOException e) {
331                                        throw new ImplementationGuideInstallationException(
332                                                        Msg.code(1287) + String.format("Cannot resolve dependency %s#%s", id, ver), e);
333                                }
334                        }
335                }
336        }
337
338        /**
339         * Asserts if package FHIR version is compatible with current FHIR version
340         * by using semantic versioning rules.
341         */
342        protected void assertFhirVersionsAreCompatible(String fhirVersion, String currentFhirVersion)
343                        throws ImplementationGuideInstallationException {
344
345                FhirVersionEnum fhirVersionEnum = FhirVersionEnum.forVersionString(fhirVersion);
346                FhirVersionEnum currentFhirVersionEnum = FhirVersionEnum.forVersionString(currentFhirVersion);
347                Validate.notNull(fhirVersionEnum, "Invalid FHIR version string: %s", fhirVersion);
348                Validate.notNull(currentFhirVersionEnum, "Invalid FHIR version string: %s", currentFhirVersion);
349                boolean compatible = fhirVersionEnum.equals(currentFhirVersionEnum);
350                if (!compatible && fhirVersion.startsWith("R4") && currentFhirVersion.startsWith("R4")) {
351                        compatible = true;
352                }
353                if (!compatible) {
354                        throw new ImplementationGuideInstallationException(Msg.code(1288)
355                                        + String.format(
356                                                        "Cannot install implementation guide: FHIR versions mismatch (expected <=%s, package uses %s)",
357                                                        currentFhirVersion, fhirVersion));
358                }
359        }
360
361        /**
362         * ============================= Utility methods ===============================
363         */
364        @VisibleForTesting
365        void install(
366                        IBaseResource theResource,
367                        PackageInstallationSpec theInstallationSpec,
368                        PackageInstallOutcomeJson theOutcome) {
369
370                if (!validForUpload(theResource)) {
371                        ourLog.warn(
372                                        "Failed to upload resource of type {} with ID {} - Error: Resource failed validation",
373                                        theResource.fhirType(),
374                                        theResource.getIdElement().getValue());
375                        return;
376                }
377
378                IFhirResourceDao dao = myDaoRegistry.getResourceDao(theResource.getClass());
379                SearchParameterMap map = createSearchParameterMapFor(theResource);
380                IBundleProvider searchResult = searchResource(dao, map);
381
382                String resourceQuery = map.toNormalizedQueryString(myFhirContext);
383                if (!searchResult.isEmpty() && !theInstallationSpec.isReloadExisting()) {
384                        ourLog.info("Skipping update of existing resource matching {}", resourceQuery);
385                        return;
386                }
387                if (!searchResult.isEmpty()) {
388                        ourLog.info("Updating existing resource matching {}", resourceQuery);
389                }
390                IBaseResource existingResource =
391                                !searchResult.isEmpty() ? searchResult.getResources(0, 1).get(0) : null;
392                boolean isInstalled = createOrUpdateResource(dao, theResource, existingResource, theInstallationSpec);
393                if (isInstalled) {
394                        theOutcome.incrementResourcesInstalled(myFhirContext.getResourceType(theResource));
395                }
396        }
397
398        private Optional<IBaseResource> readResourceById(IFhirResourceDao dao, IIdType id) {
399                try {
400                        return Optional.ofNullable(dao.read(id.toUnqualifiedVersionless(), createRequestDetails()));
401
402                } catch (Exception exception) {
403                        // ignore because we're running this query to help build the log
404                        ourLog.warn("Exception when trying to read resource with ID: {}, message: {}", id, exception.getMessage());
405                }
406
407                return Optional.empty();
408        }
409
410        private IBundleProvider searchResource(IFhirResourceDao theDao, SearchParameterMap theMap) {
411                return theDao.search(theMap, createRequestDetails());
412        }
413
414        protected boolean createOrUpdateResource(
415                        IFhirResourceDao theDao,
416                        IBaseResource theResource,
417                        IBaseResource theExistingResource,
418                        PackageInstallationSpec thePackageInstallationSpec) {
419                final IIdType id = theResource.getIdElement();
420
421                if (theExistingResource == null) {
422                        if (!theResource.fhirType().equals(ResourceType.SearchParameter.name())) {
423                                // For any resource type except SearchParameter, we will use a server-assigned ID
424                                // This prevents FHIR ID conflicts for multiple versions of Conformance/Canonical resources (e.g.
425                                // StructureDefinition.version)
426                                // which is helpful for validation against versioned profiles.
427                                // (Note: This is not to be confused with meta.versionId)
428                                theResource.setId(new IdDt()); // Ignore the given ID
429                                if (thePackageInstallationSpec != null) {
430                                        String metaSourceUrl = thePackageInstallationSpec.getName()
431                                                        + OUR_PIPE_CHARACTER
432                                                        + thePackageInstallationSpec.getVersion();
433                                        MetaUtil.setSource(myFhirContext, theResource, metaSourceUrl);
434                                }
435                        }
436
437                        if (theResource.getIdElement().isEmpty()) {
438                                ourLog.debug("Installing resource with a server-assigned id");
439                                theDao.create(theResource, createRequestDetails());
440                                return true;
441                        }
442                }
443
444                if (theExistingResource == null && !id.isEmpty() && id.isIdPartValidLong()) {
445                        // When using the given FHIR ID, add a prefix since we don't allow purely numeric IDs by default
446                        String newIdPart = "npm-" + id.getIdPart();
447                        id.setParts(id.getBaseUrl(), id.getResourceType(), newIdPart, id.getVersionIdPart());
448                }
449
450                boolean isExistingUpdated =
451                                updateExistingSearchParameterBaseIfNecessary(theDao, theResource, theExistingResource);
452                // When an existing resource is found with the same URL/version, we will update this resource and force-use the
453                // old ID
454                // Except when we are updating a SearchParameter by changing its base
455                boolean shouldOverrideId = theExistingResource != null && !isExistingUpdated;
456
457                if (shouldOverrideId) {
458                        ourLog.debug(
459                                        "Existing resource {} will be overridden with installed resource {}",
460                                        theExistingResource.getIdElement(),
461                                        id);
462                        theResource.setId(theExistingResource.getIdElement().toUnqualifiedVersionless());
463                } else {
464                        ourLog.debug("Install resource {} will be created", id);
465                }
466
467                DaoMethodOutcome outcome = updateResource(theDao, theResource);
468                return outcome != null && !outcome.isNop();
469        }
470
471        /*
472         * This function helps preserve the resource types in the base of an existing SP when an overriding SP's base
473         * covers only a subset of the existing base.
474         *
475         * For example, say for an existing SP,
476         *  -  the current base is: [ResourceTypeA, ResourceTypeB]
477         *   - the new base is: [ResourceTypeB]
478         *
479         * If we were to overwrite the existing SP's base to the new base ([ResourceTypeB]) then the
480         * SP would stop working on ResourceTypeA, which would be a loss of functionality.
481         *
482         * Instead, this function updates the existing SP's base by removing the resource types that
483         * are covered by the overriding SP.
484         * In our example, this function updates the existing SP's base to [ResourceTypeA], so that the existing SP
485         * still works on ResourceTypeA, and the caller then creates a new SP that covers ResourceTypeB.
486         * https://github.com/hapifhir/hapi-fhir/issues/5366
487         */
488        private boolean updateExistingSearchParameterBaseIfNecessary(
489                        IFhirResourceDao theDao, IBaseResource theResource, IBaseResource theExistingResource) {
490                if (!"SearchParameter".equals(theResource.getClass().getSimpleName())) {
491                        return false;
492                }
493                if (theExistingResource == null) {
494                        return false;
495                }
496                if (theExistingResource
497                                .getIdElement()
498                                .getIdPart()
499                                .equals(theResource.getIdElement().getIdPart())) {
500                        return false;
501                }
502                Collection<String> remainingBaseList = new HashSet<>(getBaseAsStrings(myFhirContext, theExistingResource));
503                remainingBaseList.removeAll(getBaseAsStrings(myFhirContext, theResource));
504                if (remainingBaseList.isEmpty()) {
505                        return false;
506                }
507                myFhirContext
508                                .getResourceDefinition(theExistingResource)
509                                .getChildByName("base")
510                                .getMutator()
511                                .setValue(theExistingResource, null);
512
513                for (String baseResourceName : remainingBaseList) {
514                        myFhirContext.newTerser().addElement(theExistingResource, "base", baseResourceName);
515                }
516                ourLog.info(
517                                "Existing SearchParameter {} will be updated with base {}",
518                                theExistingResource.getIdElement().getIdPart(),
519                                remainingBaseList);
520                updateResource(theDao, theExistingResource);
521                return true;
522        }
523
524        private DaoMethodOutcome updateResource(IFhirResourceDao theDao, IBaseResource theResource) {
525                DaoMethodOutcome outcome = null;
526
527                IIdType id = theResource.getIdElement();
528                RequestDetails requestDetails = createRequestDetails();
529
530                try {
531                        outcome = theDao.update(theResource, requestDetails);
532                } catch (ResourceVersionConflictException exception) {
533                        final Optional<IBaseResource> optResource = readResourceById(theDao, id);
534
535                        final String existingResourceUrlOrNull = optResource
536                                        .filter(MetadataResource.class::isInstance)
537                                        .map(MetadataResource.class::cast)
538                                        .map(MetadataResource::getUrl)
539                                        .orElse(null);
540                        final String newResourceUrlOrNull =
541                                        (theResource instanceof MetadataResource) ? ((MetadataResource) theResource).getUrl() : null;
542
543                        ourLog.error(
544                                        "Version conflict error: This is possibly due to a collision between ValueSets from different IGs that are coincidentally using the same resource ID: [{}] and new resource URL: [{}], with the exisitng resource having URL: [{}].  Ignoring this update and continuing:  The first IG wins.  ",
545                                        id.getIdPart(),
546                                        newResourceUrlOrNull,
547                                        existingResourceUrlOrNull,
548                                        exception);
549                }
550                return outcome;
551        }
552
553        private RequestDetails createRequestDetails() {
554                SystemRequestDetails requestDetails = new SystemRequestDetails();
555                if (myPartitionSettings.isPartitioningEnabled()) {
556                        requestDetails.setRequestPartitionId(RequestPartitionId.defaultPartition());
557                }
558                return requestDetails;
559        }
560
561        boolean validForUpload(IBaseResource theResource) {
562                String resourceType = myFhirContext.getResourceType(theResource);
563                if ("SearchParameter".equals(resourceType) && !isValidSearchParameter(theResource)) {
564                        // this is an invalid search parameter
565                        return false;
566                }
567
568                if (!isValidResourceStatusForPackageUpload(theResource)) {
569                        ourLog.warn(
570                                        "Failed to validate resource of type {} with ID {} - Error: Resource status not accepted value.",
571                                        theResource.fhirType(),
572                                        theResource.getIdElement().getValue());
573                        return false;
574                }
575
576                return true;
577        }
578
579        private boolean isValidSearchParameter(IBaseResource theResource) {
580                try {
581                        org.hl7.fhir.r5.model.SearchParameter searchParameter =
582                                        myVersionCanonicalizer.searchParameterToCanonical(theResource);
583                        mySearchParameterDaoValidator.validate(searchParameter);
584                        return true;
585                } catch (UnprocessableEntityException unprocessableEntityException) {
586                        ourLog.error(
587                                        "The SearchParameter with URL {} is invalid. Validation Error: {}",
588                                        SearchParameterUtil.getURL(myFhirContext, theResource),
589                                        unprocessableEntityException.getMessage());
590                        return false;
591                }
592        }
593
594        /**
595         * For resources like {@link org.hl7.fhir.r4.model.Subscription}, {@link org.hl7.fhir.r4.model.DocumentReference},
596         * and {@link org.hl7.fhir.r4.model.Communication}, the status field doesn't necessarily need to be set to 'active'
597         * for that resource to be eligible for upload via packages. For example, all {@link org.hl7.fhir.r4.model.Subscription}
598         * have a status of {@link org.hl7.fhir.r4.model.Subscription.SubscriptionStatus#REQUESTED} when they are originally
599         * inserted into the database, so we accept that value for {@link org.hl7.fhir.r4.model.Subscription} instead.
600         * Furthermore, {@link org.hl7.fhir.r4.model.DocumentReference} and {@link org.hl7.fhir.r4.model.Communication} can
601         * exist with a wide variety of values for status that include ones such as
602         * {@link org.hl7.fhir.r4.model.Communication.CommunicationStatus#ENTEREDINERROR},
603         * {@link org.hl7.fhir.r4.model.Communication.CommunicationStatus#UNKNOWN},
604         * {@link org.hl7.fhir.r4.model.DocumentReference.ReferredDocumentStatus#ENTEREDINERROR},
605         * {@link org.hl7.fhir.r4.model.DocumentReference.ReferredDocumentStatus#PRELIMINARY}, and others, which while not considered
606         * 'final' values, should still be uploaded for reference.
607         *
608         * @return {@link Boolean#TRUE} if the status value of this resource is acceptable for package upload.
609         */
610        private boolean isValidResourceStatusForPackageUpload(IBaseResource theResource) {
611                if (!myStorageSettings.isValidateResourceStatusForPackageUpload()) {
612                        return true;
613                }
614                List<IPrimitiveType> statusTypes =
615                                myFhirContext.newFhirPath().evaluate(theResource, "status", IPrimitiveType.class);
616                // Resource does not have a status field
617                if (statusTypes.isEmpty()) {
618                        return true;
619                }
620                // Resource has no status field, or an explicitly null one
621                if (!statusTypes.get(0).hasValue() || statusTypes.get(0).getValue() == null) {
622                        return false;
623                }
624                // Resource has a status, and we need to check based on type
625                switch (theResource.fhirType()) {
626                        case "Subscription":
627                                return (statusTypes.get(0).getValueAsString().equals("requested"));
628                        case "DocumentReference":
629                        case "Communication":
630                                return (statusTypes.get(0).isEmpty()
631                                                || !statusTypes.get(0).getValueAsString().equals("?"));
632                        default:
633                                return (statusTypes.get(0).getValueAsString().equals("active"));
634                }
635        }
636
637        private boolean isStructureDefinitionWithoutSnapshot(IBaseResource r) {
638                boolean retVal = false;
639                FhirTerser terser = myFhirContext.newTerser();
640                if (r.getClass().getSimpleName().equals("StructureDefinition")) {
641                        Optional<String> kind = terser.getSinglePrimitiveValue(r, "kind");
642                        if (kind.isPresent() && !(kind.get().equals("logical"))) {
643                                retVal = terser.getSingleValueOrNull(r, "snapshot") == null;
644                        }
645                }
646                return retVal;
647        }
648
649        private IBaseResource generateSnapshot(IBaseResource sd) {
650                try {
651                        return validationSupport.generateSnapshot(
652                                        new ValidationSupportContext(validationSupport), sd, null, null, null);
653                } catch (Exception e) {
654                        throw new ImplementationGuideInstallationException(
655                                        Msg.code(1290)
656                                                        + String.format(
657                                                                        "Failure when generating snapshot of StructureDefinition: %s", sd.getIdElement()),
658                                        e);
659                }
660        }
661
662        private SearchParameterMap createSearchParameterMapFor(IBaseResource theResource) {
663                String resourceType = theResource.getClass().getSimpleName();
664                if ("NamingSystem".equals(resourceType)) {
665                        String uniqueId = extractUniqeIdFromNamingSystem(theResource);
666                        return SearchParameterMap.newSynchronous().add("value", new StringParam(uniqueId).setExact(true));
667                } else if ("Subscription".equals(resourceType)) {
668                        String id = extractSimpleValue(theResource, "id");
669                        return SearchParameterMap.newSynchronous().add("_id", new TokenParam(id));
670                } else if ("SearchParameter".equals(resourceType)) {
671                        return buildSearchParameterMapForSearchParameter(theResource);
672                } else if (resourceHasUrlElement(theResource)) {
673                        SearchParameterMap retVal = SearchParameterMap.newSynchronous();
674                        retVal.add("url", new UriParam(extractSimpleValueIfPresent(theResource, "url")));
675                        String version = extractSimpleValueIfPresent(theResource, "version");
676                        if (!version.isEmpty()) {
677                                retVal.add("version", new TokenParam(version));
678                        }
679                        return retVal;
680                } else {
681                        TokenParam identifierToken = extractIdentifierFromOtherResourceTypes(theResource);
682                        return SearchParameterMap.newSynchronous().add("identifier", identifierToken);
683                }
684        }
685
686        /**
687         * Strategy is to build a SearchParameterMap same way the SearchParamValidatingInterceptor does, to make sure that
688         * the loader search detects existing resources and routes process to 'update' path, to avoid treating it as a new
689         * upload which validator later rejects as duplicated.
690         * To achieve this, we try canonicalizing the SearchParameter first (as the validator does) and if that is not possible
691         * we cascade to building the map from 'url' or 'identifier'.
692         */
693        private SearchParameterMap buildSearchParameterMapForSearchParameter(IBaseResource theResource) {
694                Optional<SearchParameterMap> spmFromCanonicalized =
695                                mySearchParameterHelper.buildSearchParameterMapFromCanonical(theResource);
696                if (spmFromCanonicalized.isPresent()) {
697                        return spmFromCanonicalized.get();
698                }
699
700                if (resourceHasUrlElement(theResource)) {
701                        String url = extractSimpleValue(theResource, "url");
702                        return SearchParameterMap.newSynchronous().add("url", new UriParam(url));
703                } else {
704                        TokenParam identifierToken = extractIdentifierFromOtherResourceTypes(theResource);
705                        return SearchParameterMap.newSynchronous().add("identifier", identifierToken);
706                }
707        }
708
709        private String extractUniqeIdFromNamingSystem(IBaseResource theResource) {
710                IBase uniqueIdComponent = (IBase) extractValue(theResource, "uniqueId");
711                if (uniqueIdComponent == null) {
712                        throw new ImplementationGuideInstallationException(
713                                        Msg.code(1291) + "NamingSystem does not have uniqueId component.");
714                }
715                return extractSimpleValue(uniqueIdComponent, "value");
716        }
717
718        private TokenParam extractIdentifierFromOtherResourceTypes(IBaseResource theResource) {
719                Identifier identifier = (Identifier) extractValue(theResource, "identifier");
720                if (identifier != null) {
721                        return new TokenParam(identifier.getSystem(), identifier.getValue());
722                } else {
723                        throw new UnsupportedOperationException(Msg.code(1292)
724                                        + "Resources in a package must have a url or identifier to be loaded by the package installer.");
725                }
726        }
727
728        private Object extractValue(IBase theResource, String thePath) {
729                return myFhirContext.newTerser().getSingleValueOrNull(theResource, thePath);
730        }
731
732        private String extractSimpleValue(IBase theResource, String thePath) {
733                IPrimitiveType<?> asPrimitiveType = (IPrimitiveType<?>) extractValue(theResource, thePath);
734                if (asPrimitiveType == null) {
735                        return "";
736                }
737                return (String) asPrimitiveType.getValue();
738        }
739
740        private String extractSimpleValueIfPresent(IBaseResource theResource, String theElementName) {
741                return resourceHasElementNamed(theResource, theElementName)
742                                ? extractSimpleValue(theResource, theElementName)
743                                : "";
744        }
745
746        private boolean resourceHasUrlElement(IBaseResource theResource) {
747                return resourceHasElementNamed(theResource, "url");
748        }
749
750        private boolean resourceHasElementNamed(IBaseResource theResource, String theElementName) {
751                BaseRuntimeElementDefinition<?> def = myFhirContext.getElementDefinition(theResource.getClass());
752                if (!(def instanceof BaseRuntimeElementCompositeDefinition)) {
753                        throw new IllegalArgumentException(Msg.code(1293) + "Resource is not a composite type: "
754                                        + theResource.getClass().getName());
755                }
756                BaseRuntimeElementCompositeDefinition<?> currentDef = (BaseRuntimeElementCompositeDefinition<?>) def;
757                BaseRuntimeChildDefinition nextDef = currentDef.getChildByName(theElementName);
758                return nextDef != null;
759        }
760
761        @VisibleForTesting
762        void setFhirContextForUnitTest(FhirContext theCtx) {
763                myFhirContext = theCtx;
764        }
765}