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