
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.repository; 021 022import ca.uhn.fhir.i18n.Msg; 023import ca.uhn.fhir.model.api.Include; 024import ca.uhn.fhir.model.valueset.BundleTypeEnum; 025import ca.uhn.fhir.rest.api.BundleLinks; 026import ca.uhn.fhir.rest.api.Constants; 027import ca.uhn.fhir.rest.api.IVersionSpecificBundleFactory; 028import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 029import ca.uhn.fhir.rest.api.server.IBundleProvider; 030import ca.uhn.fhir.rest.api.server.IRestfulServer; 031import ca.uhn.fhir.rest.api.server.RequestDetails; 032import ca.uhn.fhir.rest.server.IPagingProvider; 033import ca.uhn.fhir.rest.server.RestfulServerUtils; 034import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 035import org.apache.commons.lang3.Validate; 036import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 037import org.hl7.fhir.instance.model.api.IBaseResource; 038 039import java.util.ArrayList; 040import java.util.Collections; 041import java.util.List; 042import java.util.Objects; 043import java.util.Set; 044import javax.annotation.Nonnull; 045import javax.annotation.Nullable; 046 047import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; 048import static org.apache.commons.lang3.StringUtils.isBlank; 049import static org.apache.commons.lang3.StringUtils.isNotBlank; 050 051/** 052 * This class pulls existing methods from the BaseResourceReturningMethodBinding class used for taking 053 * the results of a BundleProvider and turning it into a Bundle. It is intended to be used only by the 054 * HapiFhirRepository. 055 */ 056@SuppressWarnings("java:S107") 057public class BundleProviderUtil { 058 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BundleProviderUtil.class); 059 060 private BundleProviderUtil() {} 061 062 private record OffsetLimitInfo(Integer offset, Integer limit) { 063 int addOffsetAndLimit() { 064 return offsetOrZero() + limitOrZero(); 065 } 066 067 int maxOfDifference() { 068 return Math.max(offsetOrZero() - limitOrZero(), 0); 069 } 070 071 private int offsetOrZero() { 072 return defaultZeroIfNull(offset); 073 } 074 075 private int limitOrZero() { 076 return defaultZeroIfNull(offset); 077 } 078 079 private int defaultZeroIfNull(Integer value) { 080 return defaultIfNull(value, 0); 081 } 082 } 083 084 private record InitialPagingResults( 085 int pageSize, 086 List<IBaseResource> resourceList, 087 int numToReturn, 088 String searchId, 089 Integer numTotalResults) {} 090 091 public static IBaseResource createBundleFromBundleProvider( 092 IRestfulServer<?> theServer, 093 RequestDetails theRequest, 094 Integer theLimit, 095 String theLinkSelf, 096 Set<Include> theIncludes, 097 IBundleProvider theResult, 098 int theOffset, 099 BundleTypeEnum theBundleType, 100 String theSearchId) { 101 102 final OffsetLimitInfo offsetLimitInfo = extractOffsetPageInfo(theResult, theRequest, theLimit); 103 104 final InitialPagingResults initialPagingResults = 105 extractInitialPagingResults(theServer, theRequest, theResult, theOffset, theSearchId, offsetLimitInfo); 106 107 removeNullIfNeeded(initialPagingResults.resourceList); 108 validateAllResourcesHaveId(initialPagingResults.resourceList); 109 110 final BundleLinks links = buildLinks( 111 theServer, 112 theRequest, 113 theLinkSelf, 114 theIncludes, 115 theResult, 116 theOffset, 117 theBundleType, 118 offsetLimitInfo, 119 initialPagingResults); 120 121 return buildBundle(theServer, theIncludes, theResult, theBundleType, links, initialPagingResults.resourceList); 122 } 123 124 @Nonnull 125 private static BundleLinks buildLinks( 126 IRestfulServer<?> theServer, 127 RequestDetails theRequest, 128 String theLinkSelf, 129 Set<Include> theIncludes, 130 IBundleProvider theResult, 131 int theOffset, 132 BundleTypeEnum theBundleType, 133 OffsetLimitInfo theOffsetLimitInfo, 134 InitialPagingResults theInitialPagingResults) { 135 136 BundleLinks links = new BundleLinks( 137 theRequest.getFhirServerBase(), 138 theIncludes, 139 RestfulServerUtils.prettyPrintResponse(theServer, theRequest), 140 theBundleType); 141 links.setSelf(theLinkSelf); 142 143 if (theResult.getCurrentPageOffset() != null) { 144 145 if (isNotBlank(theResult.getNextPageId())) { 146 links.setNext(RestfulServerUtils.createOffsetPagingLink( 147 links, 148 theRequest.getRequestPath(), 149 theRequest.getTenantId(), 150 theOffsetLimitInfo.addOffsetAndLimit(), 151 theOffsetLimitInfo.limit, 152 theRequest.getParameters())); 153 } 154 if (isNotBlank(theResult.getPreviousPageId())) { 155 links.setNext(RestfulServerUtils.createOffsetPagingLink( 156 links, 157 theRequest.getRequestPath(), 158 theRequest.getTenantId(), 159 theOffsetLimitInfo.maxOfDifference(), 160 theOffsetLimitInfo.limit, 161 theRequest.getParameters())); 162 } 163 } 164 165 if (theOffsetLimitInfo.offset != null 166 || (!theServer.canStoreSearchResults() && !isEverythingOperation(theRequest))) { 167 handleOffsetPage(theServer, theRequest, theOffset, theOffsetLimitInfo, theInitialPagingResults, links); 168 } else if (isNotBlank(theResult.getCurrentPageId())) { 169 handleCurrentPage(theRequest, theResult, theInitialPagingResults, links); 170 } else if (theInitialPagingResults.searchId != null && !theInitialPagingResults.resourceList.isEmpty()) { 171 handleSearchId(theRequest, theOffset, theInitialPagingResults, links); 172 } 173 return links; 174 } 175 176 private static void handleSearchId( 177 RequestDetails theRequest, 178 int theOffset, 179 InitialPagingResults theInitialPagingResults, 180 BundleLinks theLinks) { 181 if (theInitialPagingResults.numTotalResults == null 182 || theOffset + theInitialPagingResults.numToReturn < theInitialPagingResults.numTotalResults) { 183 theLinks.setNext((RestfulServerUtils.createPagingLink( 184 theLinks, 185 theRequest, 186 theInitialPagingResults.searchId, 187 theOffset + theInitialPagingResults.numToReturn, 188 theInitialPagingResults.numToReturn, 189 theRequest.getParameters()))); 190 } 191 if (theOffset > 0) { 192 int start = Math.max(0, theOffset - theInitialPagingResults.pageSize); 193 theLinks.setPrev(RestfulServerUtils.createPagingLink( 194 theLinks, 195 theRequest, 196 theInitialPagingResults.searchId, 197 start, 198 theInitialPagingResults.pageSize, 199 theRequest.getParameters())); 200 } 201 } 202 203 private static void handleCurrentPage( 204 RequestDetails theRequest, 205 IBundleProvider theResult, 206 InitialPagingResults theInitialPagingResults, 207 BundleLinks theLinks) { 208 String searchIdToUse; 209 // We're doing named pages 210 searchIdToUse = theResult.getUuid(); 211 if (isNotBlank(theResult.getNextPageId())) { 212 theLinks.setNext(RestfulServerUtils.createPagingLink( 213 theLinks, theRequest, searchIdToUse, theResult.getNextPageId(), theRequest.getParameters())); 214 } 215 if (isNotBlank(theResult.getPreviousPageId())) { 216 theLinks.setPrev(RestfulServerUtils.createPagingLink( 217 theLinks, 218 theRequest, 219 theInitialPagingResults.searchId, 220 theResult.getPreviousPageId(), 221 theRequest.getParameters())); 222 } 223 } 224 225 private static void handleOffsetPage( 226 IRestfulServer<?> theServer, 227 RequestDetails theRequest, 228 int theOffset, 229 OffsetLimitInfo theOffsetLimitInfo, 230 InitialPagingResults theInitialPagingResults, 231 BundleLinks theLinks) { 232 // Paging without caching 233 // We're doing offset pages 234 int requestedToReturn = theInitialPagingResults.numToReturn; 235 if (theServer.getPagingProvider() == null && theOffsetLimitInfo.offset != null) { 236 // There is no paging provider at all, so assume we're querying up to all the results we 237 // need every time 238 requestedToReturn += theOffsetLimitInfo.offset; 239 } 240 if ((theInitialPagingResults.numTotalResults == null 241 || requestedToReturn < theInitialPagingResults.numTotalResults) 242 && !theInitialPagingResults.resourceList.isEmpty()) { 243 theLinks.setNext(RestfulServerUtils.createOffsetPagingLink( 244 theLinks, 245 theRequest.getRequestPath(), 246 theRequest.getTenantId(), 247 defaultIfNull(theOffsetLimitInfo.offset, 0) + theInitialPagingResults.numToReturn, 248 theInitialPagingResults.numToReturn, 249 theRequest.getParameters())); 250 } 251 252 if (theOffsetLimitInfo.offset != null && theOffsetLimitInfo.offset > 0) { 253 int start = Math.max(0, theOffset - theInitialPagingResults.pageSize); 254 theLinks.setPrev(RestfulServerUtils.createOffsetPagingLink( 255 theLinks, 256 theRequest.getRequestPath(), 257 theRequest.getTenantId(), 258 start, 259 theInitialPagingResults.pageSize, 260 theRequest.getParameters())); 261 } 262 } 263 264 private static OffsetLimitInfo extractOffsetPageInfo( 265 IBundleProvider theResult, RequestDetails theRequest, Integer theLimit) { 266 Integer offsetToUse; 267 Integer limitToUse = theLimit; 268 if (theResult.getCurrentPageOffset() != null) { 269 offsetToUse = theResult.getCurrentPageOffset(); 270 limitToUse = theResult.getCurrentPageSize(); 271 Validate.notNull( 272 limitToUse, "IBundleProvider returned a non-null offset, but did not return a non-null page size"); 273 } else { 274 offsetToUse = RestfulServerUtils.tryToExtractNamedParameter(theRequest, Constants.PARAM_OFFSET); 275 } 276 return new OffsetLimitInfo(offsetToUse, limitToUse); 277 } 278 279 private static InitialPagingResults extractInitialPagingResults( 280 IRestfulServer<?> theServer, 281 RequestDetails theRequest, 282 IBundleProvider theResult, 283 int theOffset, 284 String theSearchId, 285 OffsetLimitInfo theOffsetLimitInfo) { 286 287 if (theOffsetLimitInfo.offset != null || !theServer.canStoreSearchResults()) { 288 return handleOffset(theServer, theResult, theOffsetLimitInfo); 289 } 290 291 return handleNonOffset(theServer, theRequest, theResult, theOffset, theSearchId, theOffsetLimitInfo); 292 } 293 294 @Nonnull 295 private static InitialPagingResults handleNonOffset( 296 IRestfulServer<?> theServer, 297 RequestDetails theRequest, 298 IBundleProvider theResult, 299 int theOffset, 300 String theSearchId, 301 OffsetLimitInfo theOffsetLimitInfo) { 302 303 Integer numTotalResults = theResult.size(); 304 List<IBaseResource> resourceList; 305 int numToReturn; 306 final int pageSize; 307 IPagingProvider pagingProvider = theServer.getPagingProvider(); 308 309 if (theOffsetLimitInfo.limit == null || theOffsetLimitInfo.limit.equals(0)) { 310 pageSize = pagingProvider.getDefaultPageSize(); 311 } else { 312 pageSize = Math.min(pagingProvider.getMaximumPageSize(), theOffsetLimitInfo.limit); 313 } 314 numToReturn = pageSize; 315 316 if (numTotalResults != null) { 317 numToReturn = Math.min(numToReturn, numTotalResults - theOffset); 318 } 319 320 if (numToReturn > 0 || theResult.getCurrentPageId() != null) { 321 resourceList = theResult.getResources(theOffset, numToReturn + theOffset); 322 } else { 323 resourceList = Collections.emptyList(); 324 } 325 RestfulServerUtils.validateResourceListNotNull(resourceList); 326 327 if (numTotalResults == null) { 328 numTotalResults = theResult.size(); 329 } 330 331 final String searchIdToUse = 332 computeSearchId(theRequest, theResult, theSearchId, numTotalResults, numToReturn, pagingProvider); 333 334 return new InitialPagingResults(pageSize, resourceList, numToReturn, searchIdToUse, numTotalResults); 335 } 336 337 @Nullable 338 private static String computeSearchId( 339 RequestDetails theRequest, 340 IBundleProvider theResult, 341 String theSearchId, 342 Integer theNumTotalResults, 343 int theNumToReturn, 344 IPagingProvider thePagingProvider) { 345 String searchIdToUse = null; 346 if (theSearchId != null) { 347 searchIdToUse = theSearchId; 348 } else { 349 if (theNumTotalResults == null || theNumTotalResults > theNumToReturn) { 350 searchIdToUse = thePagingProvider.storeResultList(theRequest, theResult); 351 if (isBlank(searchIdToUse)) { 352 ourLog.info( 353 "Found {} results but paging provider did not provide an ID to use for paging", 354 theNumTotalResults); 355 searchIdToUse = null; 356 } 357 } 358 } 359 return searchIdToUse; 360 } 361 362 @Nonnull 363 private static InitialPagingResults handleOffset( 364 IRestfulServer<?> theServer, IBundleProvider theResult, OffsetLimitInfo theOffsetLimitInfo) { 365 String searchIdToUse = null; 366 final int pageSize; 367 int numToReturn; 368 Integer numTotalResults = theResult.size(); 369 370 List<IBaseResource> resourceList; 371 if (theOffsetLimitInfo.limit != null) { 372 pageSize = theOffsetLimitInfo.limit; 373 } else { 374 if (theServer.getDefaultPageSize() != null) { 375 pageSize = theServer.getDefaultPageSize(); 376 } else { 377 pageSize = numTotalResults != null ? numTotalResults : Integer.MAX_VALUE; 378 } 379 } 380 numToReturn = pageSize; 381 382 if (theOffsetLimitInfo.offset != null || theResult.getCurrentPageOffset() != null) { 383 // When offset query is done result already contains correct amount (+ ir includes 384 // etc.) so return everything 385 resourceList = theResult.getResources(0, Integer.MAX_VALUE); 386 } else if (numToReturn > 0) { 387 resourceList = theResult.getResources(0, numToReturn); 388 } else { 389 resourceList = Collections.emptyList(); 390 } 391 RestfulServerUtils.validateResourceListNotNull(resourceList); 392 393 return new InitialPagingResults(pageSize, resourceList, numToReturn, searchIdToUse, numTotalResults); 394 } 395 396 private static IBaseResource buildBundle( 397 IRestfulServer<?> theServer, 398 Set<Include> theIncludes, 399 IBundleProvider theResult, 400 BundleTypeEnum theBundleType, 401 BundleLinks theLinks, 402 List<IBaseResource> theResourceList) { 403 IVersionSpecificBundleFactory bundleFactory = theServer.getFhirContext().newBundleFactory(); 404 405 bundleFactory.addRootPropertiesToBundle( 406 theResult.getUuid(), theLinks, theResult.size(), theResult.getPublished()); 407 bundleFactory.addResourcesToBundle( 408 new ArrayList<>(theResourceList), 409 theBundleType, 410 theLinks.serverBase, 411 theServer.getBundleInclusionRule(), 412 theIncludes); 413 414 return bundleFactory.getResourceBundle(); 415 } 416 417 private static void removeNullIfNeeded(List<IBaseResource> theResourceList) { 418 /* 419 * Remove any null entries in the list - This generally shouldn't happen but can if data has 420 * been manually purged from the JPA database 421 */ 422 boolean hasNull = false; 423 for (IBaseResource next : theResourceList) { 424 if (next == null) { 425 hasNull = true; 426 break; 427 } 428 } 429 if (hasNull) { 430 theResourceList.removeIf(Objects::isNull); 431 } 432 } 433 434 private static void validateAllResourcesHaveId(List<IBaseResource> theResourceList) { 435 /* 436 * Make sure all returned resources have an ID (if not, this is a bug in the user server code) 437 */ 438 for (IBaseResource next : theResourceList) { 439 if ((next.getIdElement() == null || next.getIdElement().isEmpty()) 440 && !(next instanceof IBaseOperationOutcome)) { 441 throw new InternalErrorException(Msg.code(2637) 442 + String.format( 443 "Server method returned resource of type[%s] with no ID specified (IResource#setId(IdDt) must be called)", 444 next.getIdElement())); 445 } 446 } 447 } 448 449 private static boolean isEverythingOperation(RequestDetails theRequest) { 450 return (theRequest.getRestOperationType() == RestOperationTypeEnum.EXTENDED_OPERATION_TYPE 451 || theRequest.getRestOperationType() == RestOperationTypeEnum.EXTENDED_OPERATION_INSTANCE) 452 && theRequest.getOperation() != null 453 && theRequest.getOperation().equals("$everything"); 454 } 455}