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}