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}