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.dao;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.FhirVersionEnum;
024import ca.uhn.fhir.context.support.IValidationSupport;
025import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
026import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
027import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
028import ca.uhn.fhir.jpa.term.TermReadSvcUtil;
029import ca.uhn.fhir.rest.api.SortOrderEnum;
030import ca.uhn.fhir.rest.api.SortSpec;
031import ca.uhn.fhir.rest.api.server.IBundleProvider;
032import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
033import ca.uhn.fhir.rest.param.StringParam;
034import ca.uhn.fhir.rest.param.TokenParam;
035import ca.uhn.fhir.rest.param.UriParam;
036import jakarta.annotation.Nonnull;
037import jakarta.annotation.Nullable;
038import jakarta.annotation.PostConstruct;
039import org.apache.commons.lang3.Validate;
040import org.hl7.fhir.instance.model.api.IAnyResource;
041import org.hl7.fhir.instance.model.api.IBaseResource;
042import org.hl7.fhir.instance.model.api.IIdType;
043import org.hl7.fhir.r4.model.IdType;
044import org.hl7.fhir.r4.model.StructureDefinition;
045import org.hl7.fhir.r4.model.ValueSet;
046import org.slf4j.Logger;
047import org.slf4j.LoggerFactory;
048
049import java.util.List;
050import java.util.Objects;
051import java.util.Optional;
052import java.util.function.Supplier;
053
054import static org.apache.commons.lang3.StringUtils.isBlank;
055import static org.hl7.fhir.common.hapi.validation.support.ValidationConstants.LOINC_LOW;
056import static org.hl7.fhir.instance.model.api.IAnyResource.SP_RES_LAST_UPDATED;
057
058/**
059 * This class is a {@link IValidationSupport Validation support} module that loads
060 * validation resources (StructureDefinition, ValueSet, CodeSystem, etc.) from the resources
061 * persisted in the JPA server.
062 *
063 * Note that this class is aware of the resource business version (not to be confused with the FHIR version or
064 * meta.versionId) for CodeSystem, ValueSet, and StructureDefinition resources.
065 * For example, a request for <code>http://example.com/StructureDefinition/ABC|1.2.3</code> will
066 * return the resource that matches the URL http://example.com/StructureDefinition/ABC and version 1.2.3.
067 * Unversioned URLs will match the most recently updated resource by using the meta.lastUpdated field.
068 *
069 */
070public class JpaPersistedResourceValidationSupport implements IValidationSupport {
071
072        private static final Logger ourLog = LoggerFactory.getLogger(JpaPersistedResourceValidationSupport.class);
073
074        private final FhirContext myFhirContext;
075        private final DaoRegistry myDaoRegistry;
076
077        private Class<? extends IBaseResource> myCodeSystemType;
078        private Class<? extends IBaseResource> myStructureDefinitionType;
079        private Class<? extends IBaseResource> myValueSetType;
080
081        /**
082         * Constructor
083         */
084        public JpaPersistedResourceValidationSupport(FhirContext theFhirContext, DaoRegistry theDaoRegistry) {
085                super();
086                Validate.notNull(theFhirContext, "theFhirContext must not be null");
087                Validate.notNull(theDaoRegistry, "theDaoRegistry must not be null");
088                myFhirContext = theFhirContext;
089                myDaoRegistry = theDaoRegistry;
090        }
091
092        @Override
093        public String getName() {
094                return myFhirContext.getVersion().getVersion() + " JPA Validation Support";
095        }
096
097        @Override
098        public IBaseResource fetchCodeSystem(String theSystem) {
099                if (TermReadSvcUtil.isLoincUnversionedCodeSystem(theSystem)) {
100                        IIdType id = myFhirContext.getVersion().newIdType("CodeSystem", LOINC_LOW);
101                        return findResourceByIdWithNoException(id, myCodeSystemType);
102                }
103
104                return fetchResource(myCodeSystemType, theSystem);
105        }
106
107        @Override
108        public IBaseResource fetchValueSet(String theSystem) {
109                if (TermReadSvcUtil.isLoincUnversionedValueSet(theSystem)) {
110                        Optional<String> vsIdOpt = TermReadSvcUtil.getValueSetId(theSystem);
111                        if (vsIdOpt.isEmpty()) {
112                                return null;
113                        }
114                        IIdType id = myFhirContext.getVersion().newIdType("ValueSet", vsIdOpt.get());
115                        return findResourceByIdWithNoException(id, myValueSetType);
116                }
117
118                return fetchResource(myValueSetType, theSystem);
119        }
120
121        /**
122         * Performs a lookup by ID, with no exception thrown (since that can mark the active
123         * transaction as rollback).
124         */
125        @Nullable
126        private IBaseResource findResourceByIdWithNoException(IIdType id, Class<? extends IBaseResource> type) {
127                SearchParameterMap map = SearchParameterMap.newSynchronous()
128                                .setLoadSynchronousUpTo(1)
129                                .add(IAnyResource.SP_RES_ID, new TokenParam(id.getValue()));
130                IFhirResourceDao<? extends IBaseResource> dao = myDaoRegistry.getResourceDao(type);
131                IBundleProvider outcome = dao.search(map, new SystemRequestDetails());
132                List<IBaseResource> resources = outcome.getResources(0, 1);
133                if (resources.isEmpty()) {
134                        return null;
135                } else {
136                        return resources.get(0);
137                }
138        }
139
140        @Override
141        public IBaseResource fetchStructureDefinition(String theUrl) {
142                assert myStructureDefinitionType != null;
143                return fetchResource(myStructureDefinitionType, theUrl);
144        }
145
146        @SuppressWarnings("unchecked")
147        @Nullable
148        @Override
149        public <T extends IBaseResource> List<T> fetchAllStructureDefinitions() {
150                if (!myDaoRegistry.isResourceTypeSupported("StructureDefinition")) {
151                        return null;
152                }
153                IBundleProvider search = myDaoRegistry
154                                .getResourceDao("StructureDefinition")
155                                .search(new SearchParameterMap().setLoadSynchronousUpTo(1000), new SystemRequestDetails());
156                return (List<T>) search.getResources(0, 1000);
157        }
158
159        @Override
160        @SuppressWarnings({"unchecked", "unused"})
161        public <T extends IBaseResource> T fetchResource(@Nullable Class<T> theClass, String theUri) {
162                if (isBlank(theUri)) {
163                        return null;
164                }
165
166                return (T) doFetchResource(theClass, theUri);
167        }
168
169        private <T extends IBaseResource> IBaseResource doFetchResource(@Nullable Class<T> theClass, String theUri) {
170                if (theClass == null) {
171                        List<Supplier<IBaseResource>> fetchers = List.of(
172                                        () -> doFetchResource(myValueSetType, theUri),
173                                        () -> doFetchResource(myCodeSystemType, theUri),
174                                        () -> doFetchResource(myStructureDefinitionType, theUri));
175                        return fetchers.stream()
176                                        .map(Supplier::get)
177                                        .filter(Objects::nonNull)
178                                        .findFirst()
179                                        .orElse(null);
180                }
181
182                IdType id = new IdType(theUri);
183                boolean localReference = id.hasBaseUrl() == false && id.hasIdPart() == true;
184
185                /*
186                 * This method is used to fetch conformance resources that are needed by
187                 * the validator by canonical URL. The general pattern here is to allow
188                 * both unversioned URLs (http://foo) and versioned URLs (http://foo|1.2.3).
189                 * For historical reasons and to reflect patterns that are still common
190                 * in real-world use, we also allow local and fetch references
191                 * (e.g. Questionnaire/foo) for some types.
192                 */
193
194                String resourceName = myFhirContext.getResourceType(theClass);
195                IBundleProvider search;
196                switch (resourceName) {
197                        case "ValueSet" -> {
198                                if (localReference) {
199                                        SearchParameterMap params = new SearchParameterMap();
200                                        params.setLoadSynchronousUpTo(1);
201                                        params.add(IAnyResource.SP_RES_ID, new StringParam(theUri));
202                                        search = myDaoRegistry.getResourceDao(resourceName).search(params, new SystemRequestDetails());
203                                        if (search.isEmpty()) {
204                                                params = new SearchParameterMap();
205                                                params.setLoadSynchronousUpTo(1);
206                                                params.add(ValueSet.SP_URL, new UriParam(theUri));
207                                                search = myDaoRegistry.getResourceDao(resourceName).search(params, new SystemRequestDetails());
208                                        }
209                                } else {
210                                        SearchParameterMap params = createSearchParameterMapForCanonicalUrl(theUri);
211                                        search = myDaoRegistry.getResourceDao(resourceName).search(params, new SystemRequestDetails());
212
213                                        /*
214                                         * The ValueSet#url parameter was called "system" in older FHIR
215                                         */
216                                        if (search.isEmpty()
217                                                        && myFhirContext.getVersion().getVersion().isOlderThan(FhirVersionEnum.DSTU3)) {
218                                                params = new SearchParameterMap();
219                                                params.setLoadSynchronousUpTo(1);
220                                                int versionSeparator = theUri.lastIndexOf('|');
221                                                if (versionSeparator != -1) {
222                                                        params.add(ValueSet.SP_VERSION, new TokenParam(theUri.substring(versionSeparator + 1)));
223                                                        params.add(
224                                                                        ca.uhn.fhir.model.dstu2.resource.ValueSet.SP_SYSTEM,
225                                                                        new UriParam(theUri.substring(0, versionSeparator)));
226                                                } else {
227                                                        params.add(ca.uhn.fhir.model.dstu2.resource.ValueSet.SP_SYSTEM, new UriParam(theUri));
228                                                }
229                                                params.setSort(new SortSpec(SP_RES_LAST_UPDATED).setOrder(SortOrderEnum.DESC));
230                                                search = myDaoRegistry.getResourceDao(resourceName).search(params, new SystemRequestDetails());
231                                        }
232                                }
233                        }
234                        case "StructureDefinition" -> {
235                                // Don't allow the core FHIR definitions to be overwritten
236                                if (theUri.startsWith(URL_PREFIX_STRUCTURE_DEFINITION)) {
237                                        String typeName = theUri.substring(URL_PREFIX_STRUCTURE_DEFINITION.length());
238                                        if (myFhirContext.getElementDefinition(typeName) != null) {
239                                                return null;
240                                        }
241                                }
242                                SearchParameterMap params = createSearchParameterMapForCanonicalUrl(theUri);
243                                search = myDaoRegistry.getResourceDao("StructureDefinition").search(params, new SystemRequestDetails());
244                        }
245                        case "Questionnaire" -> {
246                                SearchParameterMap params;
247                                if (localReference || myFhirContext.getVersion().getVersion().isEquivalentTo(FhirVersionEnum.DSTU2)) {
248                                        params = new SearchParameterMap();
249                                        params.setLoadSynchronousUpTo(1);
250                                        params.add(IAnyResource.SP_RES_ID, new StringParam("Questionnaire/" + id.getIdPart()));
251                                } else {
252                                        params = createSearchParameterMapForCanonicalUrl(theUri);
253                                }
254                                search = myDaoRegistry.getResourceDao("Questionnaire").search(params, new SystemRequestDetails());
255                        }
256                        default -> {
257                                SearchParameterMap params = createSearchParameterMapForCanonicalUrl(theUri);
258                                search = myDaoRegistry.getResourceDao(resourceName).search(params, new SystemRequestDetails());
259                        }
260                }
261
262                Integer size = search.size();
263                if (size == null || size == 0) {
264                        return null;
265                }
266
267                if (size > 1) {
268                        ourLog.warn("Found multiple {} instances with URL search value of: {}", resourceName, theUri);
269                }
270
271                return search.getResources(0, 1).get(0);
272        }
273
274        @Override
275        public FhirContext getFhirContext() {
276                return myFhirContext;
277        }
278
279        @PostConstruct
280        public void start() {
281                myStructureDefinitionType =
282                                myFhirContext.getResourceDefinition("StructureDefinition").getImplementingClass();
283                myValueSetType = myFhirContext.getResourceDefinition("ValueSet").getImplementingClass();
284
285                if (myFhirContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU2)) {
286                        myCodeSystemType = myFhirContext.getResourceDefinition("CodeSystem").getImplementingClass();
287                } else {
288                        myCodeSystemType = myFhirContext.getResourceDefinition("ValueSet").getImplementingClass();
289                }
290        }
291
292        /**
293         * Creates a {@link SearchParameterMap} for a canonical URL, which can be either
294         * unversioned (<code>http://foo</code>) or versioned (<code>http://foo|1.2.3</code>).
295         */
296        @Nonnull
297        private static SearchParameterMap createSearchParameterMapForCanonicalUrl(String theUri) {
298                SearchParameterMap params = new SearchParameterMap();
299                params.setLoadSynchronousUpTo(1);
300                int versionSeparator = theUri.lastIndexOf('|');
301                if (versionSeparator != -1) {
302                        params.add(StructureDefinition.SP_VERSION, new TokenParam(theUri.substring(versionSeparator + 1)));
303                        params.add(StructureDefinition.SP_URL, new UriParam(theUri.substring(0, versionSeparator)));
304                } else {
305                        params.add(StructureDefinition.SP_URL, new UriParam(theUri));
306                        // When no version is specified, we will take the most recently updated resource as the current
307                        // version
308                        params.setSort(new SortSpec(SP_RES_LAST_UPDATED).setOrder(SortOrderEnum.DESC));
309                }
310                return params;
311        }
312}