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}