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