
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}