001/*-
002 * #%L
003 * HAPI FHIR Storage api
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.graphql;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
027import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
028import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc;
029import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
030import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
031import ca.uhn.fhir.model.api.IQueryParameterOr;
032import ca.uhn.fhir.model.valueset.BundleTypeEnum;
033import ca.uhn.fhir.rest.api.BundleLinks;
034import ca.uhn.fhir.rest.api.CacheControlDirective;
035import ca.uhn.fhir.rest.api.Constants;
036import ca.uhn.fhir.rest.api.IVersionSpecificBundleFactory;
037import ca.uhn.fhir.rest.api.server.IBundleProvider;
038import ca.uhn.fhir.rest.api.server.RequestDetails;
039import ca.uhn.fhir.rest.param.DateOrListParam;
040import ca.uhn.fhir.rest.param.DateParam;
041import ca.uhn.fhir.rest.param.NumberOrListParam;
042import ca.uhn.fhir.rest.param.NumberParam;
043import ca.uhn.fhir.rest.param.QuantityOrListParam;
044import ca.uhn.fhir.rest.param.QuantityParam;
045import ca.uhn.fhir.rest.param.ReferenceOrListParam;
046import ca.uhn.fhir.rest.param.ReferenceParam;
047import ca.uhn.fhir.rest.param.SpecialOrListParam;
048import ca.uhn.fhir.rest.param.SpecialParam;
049import ca.uhn.fhir.rest.param.StringOrListParam;
050import ca.uhn.fhir.rest.param.StringParam;
051import ca.uhn.fhir.rest.param.TokenOrListParam;
052import ca.uhn.fhir.rest.param.TokenParam;
053import ca.uhn.fhir.rest.server.IPagingProvider;
054import ca.uhn.fhir.rest.server.RestfulServerUtils;
055import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
056import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
057import ca.uhn.fhir.rest.server.util.ResourceSearchParams;
058import org.apache.commons.lang3.Validate;
059import org.hl7.fhir.exceptions.FHIRException;
060import org.hl7.fhir.instance.model.api.IBaseBundle;
061import org.hl7.fhir.instance.model.api.IBaseReference;
062import org.hl7.fhir.instance.model.api.IBaseResource;
063import org.hl7.fhir.instance.model.api.IIdType;
064import org.hl7.fhir.utilities.graphql.Argument;
065import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices;
066import org.hl7.fhir.utilities.graphql.Value;
067import org.springframework.beans.factory.annotation.Autowired;
068import org.springframework.transaction.annotation.Propagation;
069import org.springframework.transaction.annotation.Transactional;
070
071import java.util.List;
072import java.util.Optional;
073import java.util.Set;
074import java.util.TreeSet;
075import java.util.stream.Collectors;
076
077import static ca.uhn.fhir.rest.api.Constants.PARAM_COUNT;
078import static ca.uhn.fhir.rest.api.Constants.PARAM_FILTER;
079
080public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageServices {
081
082        // the constant hasn't already been defined in org.hl7.fhir.core so we define it here
083        static final String SEARCH_ID_PARAM = "search-id";
084        static final String SEARCH_OFFSET_PARAM = "search-offset";
085
086        private static final int MAX_SEARCH_SIZE = 500;
087
088        @Autowired
089        private FhirContext myContext;
090
091        @Autowired
092        private DaoRegistry myDaoRegistry;
093
094        @Autowired
095        private ISearchParamRegistry mySearchParamRegistry;
096
097        @Autowired
098        protected ISearchCoordinatorSvc mySearchCoordinatorSvc;
099
100        @Autowired
101        private IRequestPartitionHelperSvc myPartitionHelperSvc;
102
103        @Autowired
104        private IPagingProvider myPagingProvider;
105
106        private IFhirResourceDao<? extends IBaseResource> getDao(String theResourceType) {
107                RuntimeResourceDefinition typeDef = myContext.getResourceDefinition(theResourceType);
108                return myDaoRegistry.getResourceDaoOrNull(typeDef.getImplementingClass());
109        }
110
111        private String graphqlArgumentToSearchParam(String name) {
112                if (name.startsWith("_")) {
113                        return name;
114                } else {
115                        return name.replaceAll("_", "-");
116                }
117        }
118
119        private String searchParamToGraphqlArgument(String name) {
120                return name.replaceAll("-", "_");
121        }
122
123        private SearchParameterMap buildSearchParams(String theType, List<Argument> theSearchParams) {
124                List<Argument> resourceSearchParam = theSearchParams.stream()
125                                .filter(it -> !PARAM_COUNT.equals(it.getName()))
126                                .collect(Collectors.toList());
127
128                FhirContext fhirContext = myContext;
129                RuntimeResourceDefinition typeDef = fhirContext.getResourceDefinition(theType);
130
131                SearchParameterMap params = new SearchParameterMap();
132                ResourceSearchParams searchParams = mySearchParamRegistry.getRuntimeSearchParams(
133                                typeDef.getName(), ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
134
135                for (Argument nextArgument : resourceSearchParam) {
136
137                        if (nextArgument.getName().equals(PARAM_FILTER)) {
138                                String value = nextArgument.getValues().get(0).getValue();
139                                params.add(PARAM_FILTER, new StringParam(value));
140                                continue;
141                        }
142
143                        String searchParamName = graphqlArgumentToSearchParam(nextArgument.getName());
144                        RuntimeSearchParam searchParam = searchParams.get(searchParamName);
145                        if (searchParam == null) {
146                                Set<String> graphqlArguments = searchParams.getSearchParamNames().stream()
147                                                .map(this::searchParamToGraphqlArgument)
148                                                .collect(Collectors.toSet());
149                                String msg = myContext
150                                                .getLocalizer()
151                                                .getMessageSanitized(
152                                                                DaoRegistryGraphQLStorageServices.class,
153                                                                "invalidGraphqlArgument",
154                                                                nextArgument.getName(),
155                                                                new TreeSet<>(graphqlArguments));
156                                throw new InvalidRequestException(Msg.code(1275) + msg);
157                        }
158
159                        IQueryParameterOr<?> queryParam;
160
161                        switch (searchParam.getParamType()) {
162                                case NUMBER:
163                                        NumberOrListParam numberOrListParam = new NumberOrListParam();
164                                        for (Value value : nextArgument.getValues()) {
165                                                numberOrListParam.addOr(new NumberParam(value.getValue()));
166                                        }
167                                        queryParam = numberOrListParam;
168                                        break;
169                                case DATE:
170                                        DateOrListParam dateOrListParam = new DateOrListParam();
171                                        for (Value value : nextArgument.getValues()) {
172                                                dateOrListParam.addOr(new DateParam(value.getValue()));
173                                        }
174                                        queryParam = dateOrListParam;
175                                        break;
176                                case STRING:
177                                        StringOrListParam stringOrListParam = new StringOrListParam();
178                                        for (Value value : nextArgument.getValues()) {
179                                                stringOrListParam.addOr(new StringParam(value.getValue()));
180                                        }
181                                        queryParam = stringOrListParam;
182                                        break;
183                                case TOKEN:
184                                        TokenOrListParam tokenOrListParam = new TokenOrListParam();
185                                        for (Value value : nextArgument.getValues()) {
186                                                TokenParam tokenParam = new TokenParam();
187                                                tokenParam.setValueAsQueryToken(fhirContext, searchParamName, null, value.getValue());
188                                                tokenOrListParam.addOr(tokenParam);
189                                        }
190                                        queryParam = tokenOrListParam;
191                                        break;
192                                case REFERENCE:
193                                        ReferenceOrListParam referenceOrListParam = new ReferenceOrListParam();
194                                        for (Value value : nextArgument.getValues()) {
195                                                referenceOrListParam.addOr(new ReferenceParam(value.getValue()));
196                                        }
197                                        queryParam = referenceOrListParam;
198                                        break;
199                                case QUANTITY:
200                                        QuantityOrListParam quantityOrListParam = new QuantityOrListParam();
201                                        for (Value value : nextArgument.getValues()) {
202                                                quantityOrListParam.addOr(new QuantityParam(value.getValue()));
203                                        }
204                                        queryParam = quantityOrListParam;
205                                        break;
206                                case SPECIAL:
207                                        SpecialOrListParam specialOrListParam = new SpecialOrListParam();
208                                        for (Value value : nextArgument.getValues()) {
209                                                specialOrListParam.addOr(new SpecialParam().setValue(value.getValue()));
210                                        }
211                                        queryParam = specialOrListParam;
212                                        break;
213                                case COMPOSITE:
214                                case URI:
215                                case HAS:
216                                default:
217                                        throw new InvalidRequestException(Msg.code(1276)
218                                                        + String.format(
219                                                                        "%s parameters are not yet supported in GraphQL", searchParam.getParamType()));
220                        }
221
222                        params.add(searchParamName, queryParam);
223                }
224
225                return params;
226        }
227
228        @Transactional(propagation = Propagation.NEVER)
229        @Override
230        public void listResources(
231                        Object theAppInfo, String theType, List<Argument> theSearchParams, List<IBaseResource> theMatches)
232                        throws FHIRException {
233                SearchParameterMap params = buildSearchParams(theType, theSearchParams);
234                params.setLoadSynchronousUpTo(MAX_SEARCH_SIZE);
235
236                RequestDetails requestDetails = (RequestDetails) theAppInfo;
237                IBundleProvider response = getDao(theType).search(params, requestDetails);
238                Integer size = response.size();
239                // We set size to null in SearchCoordinatorSvcImpl.executeQuery() if matching results exceeds count
240                // so don't throw here
241                if ((response.preferredPageSize() != null && size != null && response.preferredPageSize() < size)
242                                || size == null) {
243                        size = response.preferredPageSize();
244                }
245
246                Validate.notNull(size, "size is null");
247                theMatches.addAll(response.getResources(0, size));
248        }
249
250        @Transactional(propagation = Propagation.REQUIRED)
251        @Override
252        public IBaseResource lookup(Object theAppInfo, String theType, String theId) throws FHIRException {
253                IIdType refId = myContext.getVersion().newIdType();
254                refId.setValue(theType + "/" + theId);
255                return lookup(theAppInfo, refId);
256        }
257
258        private IBaseResource lookup(Object theAppInfo, IIdType theRefId) {
259                IFhirResourceDao<? extends IBaseResource> dao = getDao(theRefId.getResourceType());
260                RequestDetails requestDetails = (RequestDetails) theAppInfo;
261                return dao.read(theRefId, requestDetails, false);
262        }
263
264        @Transactional(propagation = Propagation.REQUIRED)
265        @Override
266        public ReferenceResolution lookup(Object theAppInfo, IBaseResource theContext, IBaseReference theReference)
267                        throws FHIRException {
268                IBaseResource outcome = lookup(theAppInfo, theReference.getReferenceElement());
269                if (outcome == null) {
270                        return null;
271                }
272                return new ReferenceResolution(theContext, outcome);
273        }
274
275        private Optional<String> getArgument(List<Argument> params, String name) {
276                return params.stream()
277                                .filter(it -> name.equals(it.getName()))
278                                .map(it -> it.getValues().get(0).getValue())
279                                .findAny();
280        }
281
282        @Transactional(propagation = Propagation.NEVER)
283        @Override
284        public IBaseBundle search(Object theAppInfo, String theType, List<Argument> theSearchParams) throws FHIRException {
285                RequestDetails requestDetails = (RequestDetails) theAppInfo;
286
287                Optional<String> searchIdArgument = getArgument(theSearchParams, SEARCH_ID_PARAM);
288                Optional<String> searchOffsetArgument = getArgument(theSearchParams, SEARCH_OFFSET_PARAM);
289
290                String searchId;
291                int searchOffset;
292                int pageSize;
293                IBundleProvider response;
294
295                if (searchIdArgument.isPresent() && searchOffsetArgument.isPresent()) {
296                        searchId = searchIdArgument.get();
297                        searchOffset = Integer.parseInt(searchOffsetArgument.get());
298
299                        response = Optional.ofNullable(myPagingProvider.retrieveResultList(requestDetails, searchId))
300                                        .orElseThrow(() -> {
301                                                String msg = myContext
302                                                                .getLocalizer()
303                                                                .getMessageSanitized(
304                                                                                DaoRegistryGraphQLStorageServices.class,
305                                                                                "invalidGraphqlCursorArgument",
306                                                                                searchId);
307                                                return new InvalidRequestException(Msg.code(2076) + msg);
308                                        });
309
310                        pageSize =
311                                        Optional.ofNullable(response.preferredPageSize()).orElseGet(myPagingProvider::getDefaultPageSize);
312                } else {
313                        pageSize = getArgument(theSearchParams, "_count")
314                                        .map(Integer::parseInt)
315                                        .orElseGet(myPagingProvider::getDefaultPageSize);
316
317                        SearchParameterMap params = buildSearchParams(theType, theSearchParams);
318                        params.setCount(pageSize);
319
320                        CacheControlDirective cacheControlDirective = new CacheControlDirective();
321                        cacheControlDirective.parse(requestDetails.getHeaders(Constants.HEADER_CACHE_CONTROL));
322
323                        response = mySearchCoordinatorSvc.registerSearch(
324                                        getDao(theType), params, theType, cacheControlDirective, requestDetails);
325
326                        searchOffset = 0;
327                        searchId = myPagingProvider.storeResultList(requestDetails, response);
328                }
329
330                // response.size() may return {@literal null}, in that case use pageSize
331                String serverBase = requestDetails.getFhirServerBase();
332                Optional<Integer> numTotalResults = Optional.ofNullable(response.size());
333                int numToReturn = numTotalResults
334                                .map(integer -> Math.min(pageSize, integer - searchOffset))
335                                .orElse(pageSize);
336
337                BundleLinks links = new BundleLinks(
338                                requestDetails.getServerBaseForRequest(),
339                                null,
340                                RestfulServerUtils.prettyPrintResponse(requestDetails.getServer(), requestDetails),
341                                BundleTypeEnum.SEARCHSET);
342
343                // RestfulServerUtils.createLinkSelf not suitable here
344                String linkFormat = "%s/%s?_format=application/json&search-id=%s&search-offset=%d&_count=%d";
345
346                String linkSelf = String.format(linkFormat, serverBase, theType, searchId, searchOffset, pageSize);
347                links.setSelf(linkSelf);
348
349                boolean hasNext = numTotalResults
350                                .map(total -> (searchOffset + numToReturn) < total)
351                                .orElse(true);
352
353                if (hasNext) {
354                        String linkNext =
355                                        String.format(linkFormat, serverBase, theType, searchId, searchOffset + numToReturn, pageSize);
356                        links.setNext(linkNext);
357                }
358
359                if (searchOffset > 0) {
360                        String linkPrev = String.format(
361                                        linkFormat, serverBase, theType, searchId, Math.max(0, searchOffset - pageSize), pageSize);
362                        links.setPrev(linkPrev);
363                }
364
365                List<IBaseResource> resourceList = response.getResources(searchOffset, numToReturn + searchOffset);
366
367                IVersionSpecificBundleFactory bundleFactory = myContext.newBundleFactory();
368                bundleFactory.addRootPropertiesToBundle(response.getUuid(), links, response.size(), response.getPublished());
369                bundleFactory.addResourcesToBundle(resourceList, BundleTypeEnum.SEARCHSET, serverBase, null, null);
370
371                IBaseResource result = bundleFactory.getResourceBundle();
372                return (IBaseBundle) result;
373        }
374}