001/*- 002 * #%L 003 * HAPI FHIR - Server Framework 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.rest.server.method; 021 022import ca.uhn.fhir.rest.api.BundleLinks; 023import ca.uhn.fhir.rest.api.server.IBundleProvider; 024import ca.uhn.fhir.rest.server.RestfulServerUtils; 025import org.apache.commons.lang3.ObjectUtils; 026import org.apache.commons.lang3.StringUtils; 027import org.hl7.fhir.instance.model.api.IBaseResource; 028import org.slf4j.Logger; 029import org.slf4j.LoggerFactory; 030 031import java.util.List; 032 033/** 034 * This is an intermediate record object that holds all the fields required to make the final bundle that will be returned to the client. 035 */ 036public class ResponsePage { 037 private static final Logger ourLog = LoggerFactory.getLogger(ResponsePage.class); 038 039 /** 040 * The id of the search used to page through search results 041 */ 042 private final String mySearchId; 043 /** 044 * The list of resources that will be used to create the bundle 045 */ 046 private final List<IBaseResource> myResourceList; 047 /** 048 * The total number of results that matched the search 049 */ 050 private final Integer myNumTotalResults; 051 /** 052 * The number of resources that should be returned in each page 053 */ 054 private final int myPageSize; 055 /** 056 * The number of resources that should be returned in the bundle. 057 * Can be smaller than pageSize when the bundleProvider 058 * has fewer results than the page size. 059 */ 060 private final int myNumToReturn; 061 062 /** 063 * The count of resources included from the _include filter. 064 * These _include resources are otherwise included in the resourceList. 065 */ 066 private final int myIncludedResourceCount; 067 /** 068 * This is the count of resources that have been omitted from results 069 * (typically because of consent interceptors). 070 * We track these because they shouldn't change paging results, 071 * even though it will change number of resources returned. 072 */ 073 private final int myOmittedResourceCount; 074 /** 075 * This is the total count of requested resources 076 * (ie, non-omitted, non-_include'd resource count). 077 * We typically fetch (for offset queries) 1 more than 078 * we need so we know if there is an additional page 079 * to fetch. 080 * But this is determined by the implementers of 081 * IBundleProvider. 082 */ 083 private final int myTotalRequestedResourcesFetched; 084 085 /** 086 * The bundle provider. 087 */ 088 private final IBundleProvider myBundleProvider; 089 090 // Properties below here are set for calculation of pages; 091 // not part of the response pages in and of themselves 092 093 /** 094 * The response bundle request object 095 */ 096 private ResponseBundleRequest myResponseBundleRequest; 097 098 /** 099 * Whether or not this page uses (non-cached) offset paging 100 */ 101 private boolean myIsUsingOffsetPages = false; 102 103 /** 104 * The requested page object (should not be null for proper calculations) 105 */ 106 private RequestedPage myRequestedPage; 107 108 /** 109 * The paging style being used. 110 * This is determined by a number of conditions, 111 * including what the bundleprovider provides. 112 */ 113 private PagingStyle myPagingStyle; 114 115 ResponsePage( 116 String theSearchId, 117 List<IBaseResource> theResourceList, 118 int thePageSize, 119 int theNumToReturn, 120 int theIncludedResourceCount, 121 int theOmittedResourceCount, 122 int theTotalRequestedResourcesFetched, 123 IBundleProvider theBundleProvider) { 124 mySearchId = theSearchId; 125 myResourceList = theResourceList; 126 myPageSize = thePageSize; 127 myNumToReturn = theNumToReturn; 128 myIncludedResourceCount = theIncludedResourceCount; 129 myOmittedResourceCount = theOmittedResourceCount; 130 myTotalRequestedResourcesFetched = theTotalRequestedResourcesFetched; 131 myBundleProvider = theBundleProvider; 132 133 myNumTotalResults = myBundleProvider.size(); 134 } 135 136 public int size() { 137 return myResourceList.size(); 138 } 139 140 public List<IBaseResource> getResourceList() { 141 return myResourceList; 142 } 143 144 private boolean isBundleProviderOffsetPaging() { 145 if (myBundleProvider != null) { 146 if (myBundleProvider.getCurrentPageOffset() != null) { 147 // it's not enough that currentpageoffset is not null 148 // (sometimes it's 0, even if it's not a currentpageoffset search) 149 // so we have to make sure either next or prev links are not null 150 return (StringUtils.isNotBlank(myBundleProvider.getNextPageId()) 151 || StringUtils.isNotBlank(myBundleProvider.getPreviousPageId())); 152 } 153 } 154 155 return false; 156 } 157 158 private void determinePagingStyle() { 159 if (myPagingStyle != null) { 160 // already assigned 161 return; 162 } 163 164 if (isBundleProviderOffsetPaging()) { 165 myPagingStyle = PagingStyle.BUNDLE_PROVIDER_OFFSETS; 166 } else if (myIsUsingOffsetPages) { 167 myPagingStyle = PagingStyle.NONCACHED_OFFSET; 168 } else if (myBundleProvider != null && StringUtils.isNotBlank(myBundleProvider.getCurrentPageId())) { 169 myPagingStyle = PagingStyle.BUNDLE_PROVIDER_PAGE_IDS; 170 } else if (StringUtils.isNotBlank(mySearchId)) { 171 myPagingStyle = PagingStyle.SAVED_SEARCH; 172 } else { 173 myPagingStyle = PagingStyle.NONE; 174 // only end up here if no paging is desired 175 ourLog.debug( 176 "No accurate paging will be generated." 177 + " If accurate paging is desired, ResponsePageBuilder must be provided with additioanl information."); 178 } 179 } 180 181 public void setRequestedPage(RequestedPage theRequestedPage) { 182 myRequestedPage = theRequestedPage; 183 } 184 185 public IBundleProvider getBundleProvider() { 186 return myBundleProvider; 187 } 188 189 public void setUseOffsetPaging(boolean theIsUsingOffsetPaging) { 190 myIsUsingOffsetPages = theIsUsingOffsetPaging; 191 } 192 193 public void setResponseBundleRequest(ResponseBundleRequest theRequest) { 194 myResponseBundleRequest = theRequest; 195 } 196 197 private boolean hasNextPage() { 198 determinePagingStyle(); 199 switch (myPagingStyle) { 200 case BUNDLE_PROVIDER_OFFSETS: 201 case BUNDLE_PROVIDER_PAGE_IDS: 202 return StringUtils.isNotBlank(myBundleProvider.getNextPageId()); 203 case NONCACHED_OFFSET: 204 if (myNumTotalResults == null) { 205 if (hasNextPageWithoutKnowingTotal()) { 206 return true; 207 } 208 } else if (myNumTotalResults > myNumToReturn + ObjectUtils.defaultIfNull(myRequestedPage.offset, 0)) { 209 return true; 210 } 211 break; 212 case SAVED_SEARCH: 213 if (myNumTotalResults == null) { 214 if (hasNextPageWithoutKnowingTotal()) { 215 return true; 216 } 217 } else if (myResponseBundleRequest.offset + myNumToReturn < myNumTotalResults) { 218 return true; 219 } 220 break; 221 } 222 223 // fallthrough 224 return false; 225 } 226 227 /** 228 * If myNumTotalResults is null, it typically means we don't 229 * have an accurate total. 230 * 231 * Ie, we're in the middle of a set of pages (of non-named page results), 232 * and _total=accurate was not passed. 233 * 234 * This typically always means that a 235 * 'next' link definitely exists. 236 * 237 * But there are cases where this might not be true: 238 * * the last page of a search that also has an _include 239 * query parameter where the total of resources + _include'd 240 * resources is > the page size expected to be returned. 241 * * the last page of a search that returns the exact number 242 * of resources requested 243 * 244 * In these case, we must check to see if the returned 245 * number of *requested* resources. 246 * If our bundleprovider has fetched > requested, 247 * we'll know that there are more resources already. 248 * But if it hasn't, we'll have to check pagesize compared to 249 * _include'd count, omitted count, and resource count. 250 */ 251 private boolean hasNextPageWithoutKnowingTotal() { 252 // if we have totalRequestedResource count, and it's not equal to pagesize, 253 // then we can use this, alone, to determine if there are more pages 254 if (myTotalRequestedResourcesFetched >= 0) { 255 if (myPageSize < myTotalRequestedResourcesFetched) { 256 return true; 257 } 258 } else { 259 // otherwise we'll try and determine if there are next links based on the following 260 // calculation: 261 // resourceList.size - included resources + omitted resources == pagesize 262 // -> we (most likely) have more resources 263 if (myPageSize == myResourceList.size() - myIncludedResourceCount + myOmittedResourceCount) { 264 ourLog.warn( 265 "Returning a next page based on calculated resource count." 266 + " This could be inaccurate if the exact number of resources were fetched is equal to the pagesize requested. " 267 + " Consider setting ResponseBundleBuilder.setTotalResourcesFetchedRequest after fetching resources."); 268 return true; 269 } 270 } 271 return false; 272 } 273 274 public void setNextPageIfNecessary(BundleLinks theLinks) { 275 if (hasNextPage()) { 276 String next; 277 switch (myPagingStyle) { 278 case BUNDLE_PROVIDER_OFFSETS: 279 next = RestfulServerUtils.createOffsetPagingLink( 280 theLinks, 281 myResponseBundleRequest.requestDetails.getRequestPath(), 282 myResponseBundleRequest.requestDetails.getTenantId(), 283 myRequestedPage.offset + myRequestedPage.limit, 284 myRequestedPage.limit, 285 myResponseBundleRequest.getRequestParameters()); 286 break; 287 case NONCACHED_OFFSET: 288 next = RestfulServerUtils.createOffsetPagingLink( 289 theLinks, 290 myResponseBundleRequest.requestDetails.getRequestPath(), 291 myResponseBundleRequest.requestDetails.getTenantId(), 292 ObjectUtils.defaultIfNull(myRequestedPage.offset, 0) + myNumToReturn, 293 myNumToReturn, 294 myResponseBundleRequest.getRequestParameters()); 295 break; 296 case BUNDLE_PROVIDER_PAGE_IDS: 297 next = RestfulServerUtils.createPagingLink( 298 theLinks, 299 myResponseBundleRequest.requestDetails, 300 myBundleProvider.getUuid(), 301 myBundleProvider.getNextPageId(), 302 myResponseBundleRequest.getRequestParameters()); 303 break; 304 case SAVED_SEARCH: 305 next = RestfulServerUtils.createPagingLink( 306 theLinks, 307 myResponseBundleRequest.requestDetails, 308 mySearchId, 309 myResponseBundleRequest.offset + myNumToReturn, 310 myNumToReturn, 311 myResponseBundleRequest.getRequestParameters()); 312 break; 313 default: 314 next = null; 315 break; 316 } 317 318 if (StringUtils.isNotBlank(next)) { 319 theLinks.setNext(next); 320 } 321 } 322 } 323 324 private boolean hasPreviousPage() { 325 determinePagingStyle(); 326 switch (myPagingStyle) { 327 case BUNDLE_PROVIDER_OFFSETS: 328 case BUNDLE_PROVIDER_PAGE_IDS: 329 return StringUtils.isNotBlank(myBundleProvider.getPreviousPageId()); 330 case NONCACHED_OFFSET: 331 if (myRequestedPage != null && myRequestedPage.offset != null && myRequestedPage.offset > 0) { 332 return true; 333 } 334 break; 335 case SAVED_SEARCH: 336 return myResponseBundleRequest.offset > 0; 337 } 338 339 // fallthrough 340 return false; 341 } 342 343 public void setPreviousPageIfNecessary(BundleLinks theLinks) { 344 if (hasPreviousPage()) { 345 String prev; 346 switch (myPagingStyle) { 347 case BUNDLE_PROVIDER_OFFSETS: 348 prev = RestfulServerUtils.createOffsetPagingLink( 349 theLinks, 350 myResponseBundleRequest.requestDetails.getRequestPath(), 351 myResponseBundleRequest.requestDetails.getTenantId(), 352 Math.max(ObjectUtils.defaultIfNull(myRequestedPage.offset, 0) - myRequestedPage.limit, 0), 353 myRequestedPage.limit, 354 myResponseBundleRequest.getRequestParameters()); 355 break; 356 case NONCACHED_OFFSET: 357 { 358 int start = Math.max(0, ObjectUtils.defaultIfNull(myRequestedPage.offset, 0) - myPageSize); 359 prev = RestfulServerUtils.createOffsetPagingLink( 360 theLinks, 361 myResponseBundleRequest.requestDetails.getRequestPath(), 362 myResponseBundleRequest.requestDetails.getTenantId(), 363 start, 364 myPageSize, 365 myResponseBundleRequest.getRequestParameters()); 366 } 367 break; 368 case BUNDLE_PROVIDER_PAGE_IDS: 369 prev = RestfulServerUtils.createPagingLink( 370 theLinks, 371 myResponseBundleRequest.requestDetails, 372 myBundleProvider.getUuid(), 373 myBundleProvider.getPreviousPageId(), 374 myResponseBundleRequest.getRequestParameters()); 375 break; 376 case SAVED_SEARCH: 377 { 378 int start = Math.max(0, myResponseBundleRequest.offset - myPageSize); 379 prev = RestfulServerUtils.createPagingLink( 380 theLinks, 381 myResponseBundleRequest.requestDetails, 382 mySearchId, 383 start, 384 myPageSize, 385 myResponseBundleRequest.getRequestParameters()); 386 } 387 break; 388 default: 389 prev = null; 390 } 391 392 if (StringUtils.isNotBlank(prev)) { 393 theLinks.setPrev(prev); 394 } 395 } 396 } 397 398 /** 399 * A builder for constructing ResponsePage objects. 400 */ 401 public static class ResponsePageBuilder { 402 403 private String mySearchId; 404 private List<IBaseResource> myResources; 405 private int myPageSize; 406 private int myNumToReturn; 407 private int myIncludedResourceCount; 408 private int myOmittedResourceCount; 409 private IBundleProvider myBundleProvider; 410 private int myTotalRequestedResourcesFetched = -1; 411 412 public ResponsePageBuilder setOmittedResourceCount(int theOmittedResourceCount) { 413 myOmittedResourceCount = theOmittedResourceCount; 414 return this; 415 } 416 417 public ResponsePageBuilder setIncludedResourceCount(int theIncludedResourceCount) { 418 myIncludedResourceCount = theIncludedResourceCount; 419 return this; 420 } 421 422 public ResponsePageBuilder setNumToReturn(int theNumToReturn) { 423 myNumToReturn = theNumToReturn; 424 return this; 425 } 426 427 public ResponsePageBuilder setPageSize(int thePageSize) { 428 myPageSize = thePageSize; 429 return this; 430 } 431 432 public ResponsePageBuilder setBundleProvider(IBundleProvider theBundleProvider) { 433 myBundleProvider = theBundleProvider; 434 return this; 435 } 436 437 public ResponsePageBuilder setResources(List<IBaseResource> theResources) { 438 myResources = theResources; 439 return this; 440 } 441 442 public ResponsePageBuilder setSearchId(String theSearchId) { 443 mySearchId = theSearchId; 444 return this; 445 } 446 447 public ResponsePageBuilder setTotalRequestedResourcesFetched(int theTotalRequestedResourcesFetched) { 448 myTotalRequestedResourcesFetched = theTotalRequestedResourcesFetched; 449 return this; 450 } 451 452 /** 453 * Combine this builder with a second buider. 454 * Useful if a second page is requested, but you do not wish to 455 * overwrite the current values. 456 * 457 * Will not replace searchId, nor IBundleProvider (which should be 458 * the exact same for any subsequent searches anyways). 459 * 460 * Will also not copy pageSize nor numToReturn, as these should be 461 * the same for any single search result set. 462 * 463 * @param theSecondBuilder - a second builder (cannot be this one) 464 */ 465 public void combineWith(ResponsePageBuilder theSecondBuilder) { 466 assert theSecondBuilder != this; // don't want to combine with itself 467 468 if (myTotalRequestedResourcesFetched != -1 && theSecondBuilder.myTotalRequestedResourcesFetched != -1) { 469 myTotalRequestedResourcesFetched += theSecondBuilder.myTotalRequestedResourcesFetched; 470 } 471 472 // primitives can always be added 473 myIncludedResourceCount += theSecondBuilder.myIncludedResourceCount; 474 myOmittedResourceCount += theSecondBuilder.myOmittedResourceCount; 475 } 476 477 public ResponsePage build() { 478 return new ResponsePage( 479 mySearchId, // search id 480 myResources, // resource list 481 myPageSize, // page size 482 myNumToReturn, // num to return 483 myIncludedResourceCount, // included count 484 myOmittedResourceCount, // omitted resources 485 myTotalRequestedResourcesFetched, // total count of requested resources 486 myBundleProvider // the bundle provider 487 ); 488 } 489 } 490 491 /** 492 * First we determine what kind of paging we use: 493 * * Bundle Provider Offsets - the bundle provider has offset counts that it uses 494 * to determine the page. For legacy reasons, it's not enough 495 * that the bundle provider has a currentOffsetPage. Sometimes 496 * this value is provided (often as a 0), but no nextPageId nor previousPageId 497 * is available. Typically this is the case in UnitTests. 498 * * non-cached offsets - if the server is not storing the search results (and it's not 499 * an everything operator) OR the Requested Page has an initial offset 500 * OR it is explicitly set to use non-cached offset 501 * (ResponseBundleBuilder.myIsOffsetModeHistory) 502 * * Bundle Provider Page Ids - the bundle provider knows the page ids and will 503 * provide them. bundle provider will have a currentPageId 504 * * Saved Search - the server has a saved search object with an id that it 505 * uses to page through results. 506 */ 507 private enum PagingStyle { 508 /** 509 * Paging is done by offsets; pages are not cached 510 */ 511 NONCACHED_OFFSET, 512 /** 513 * Paging is done by offsets, but 514 * the bundle provider provides the offsets 515 */ 516 BUNDLE_PROVIDER_OFFSETS, 517 /** 518 * Paging is done by page ids, 519 * but bundle provider provides the page ids 520 */ 521 BUNDLE_PROVIDER_PAGE_IDS, 522 /** 523 * The server has a saved search object with an id 524 * that is used to page through results. 525 */ 526 SAVED_SEARCH, 527 /** 528 * No paging is done at all. 529 * No previous nor next links will be available, even if previous or next 530 * links exist. 531 * If paging is required, a different paging method must be specified. 532 */ 533 NONE; 534 } 535}