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(typeDef.getName());
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                        RequestPartitionId requestPartitionId =
324                                        myPartitionHelperSvc.determineReadPartitionForRequestForSearchType(requestDetails, theType, params);
325                        response = mySearchCoordinatorSvc.registerSearch(
326                                        getDao(theType), params, theType, cacheControlDirective, requestDetails, requestPartitionId);
327
328                        searchOffset = 0;
329                        searchId = myPagingProvider.storeResultList(requestDetails, response);
330                }
331
332                // response.size() may return {@literal null}, in that case use pageSize
333                String serverBase = requestDetails.getFhirServerBase();
334                Optional<Integer> numTotalResults = Optional.ofNullable(response.size());
335                int numToReturn = numTotalResults
336                                .map(integer -> Math.min(pageSize, integer - searchOffset))
337                                .orElse(pageSize);
338
339                BundleLinks links = new BundleLinks(
340                                requestDetails.getServerBaseForRequest(),
341                                null,
342                                RestfulServerUtils.prettyPrintResponse(requestDetails.getServer(), requestDetails),
343                                BundleTypeEnum.SEARCHSET);
344
345                // RestfulServerUtils.createLinkSelf not suitable here
346                String linkFormat = "%s/%s?_format=application/json&search-id=%s&search-offset=%d&_count=%d";
347
348                String linkSelf = String.format(linkFormat, serverBase, theType, searchId, searchOffset, pageSize);
349                links.setSelf(linkSelf);
350
351                boolean hasNext = numTotalResults
352                                .map(total -> (searchOffset + numToReturn) < total)
353                                .orElse(true);
354
355                if (hasNext) {
356                        String linkNext =
357                                        String.format(linkFormat, serverBase, theType, searchId, searchOffset + numToReturn, pageSize);
358                        links.setNext(linkNext);
359                }
360
361                if (searchOffset > 0) {
362                        String linkPrev = String.format(
363                                        linkFormat, serverBase, theType, searchId, Math.max(0, searchOffset - pageSize), pageSize);
364                        links.setPrev(linkPrev);
365                }
366
367                List<IBaseResource> resourceList = response.getResources(searchOffset, numToReturn + searchOffset);
368
369                IVersionSpecificBundleFactory bundleFactory = myContext.newBundleFactory();
370                bundleFactory.addRootPropertiesToBundle(response.getUuid(), links, response.size(), response.getPublished());
371                bundleFactory.addResourcesToBundle(resourceList, BundleTypeEnum.SEARCHSET, serverBase, null, null);
372
373                IBaseResource result = bundleFactory.getResourceBundle();
374                return (IBaseBundle) result;
375        }
376}