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}