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.context.ConfigurationException;
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.model.valueset.BundleTypeEnum;
026import ca.uhn.fhir.rest.annotation.Search;
027import ca.uhn.fhir.rest.api.Constants;
028import ca.uhn.fhir.rest.api.RequestTypeEnum;
029import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
030import ca.uhn.fhir.rest.api.server.IBundleProvider;
031import ca.uhn.fhir.rest.api.server.IRestfulServer;
032import ca.uhn.fhir.rest.api.server.RequestDetails;
033import ca.uhn.fhir.rest.param.ParameterUtil;
034import ca.uhn.fhir.rest.param.QualifierDetails;
035import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
036import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
037import ca.uhn.fhir.util.ParametersUtil;
038import jakarta.annotation.Nonnull;
039import org.apache.commons.lang3.StringUtils;
040import org.hl7.fhir.instance.model.api.IAnyResource;
041import org.hl7.fhir.instance.model.api.IBaseResource;
042
043import java.lang.reflect.Method;
044import java.util.Collections;
045import java.util.HashSet;
046import java.util.List;
047import java.util.Set;
048import java.util.stream.Collectors;
049
050import static org.apache.commons.lang3.StringUtils.isBlank;
051import static org.apache.commons.lang3.StringUtils.isNotBlank;
052
053public class SearchMethodBinding extends BaseResourceReturningMethodBinding {
054        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(SearchMethodBinding.class);
055
056        private static final Set<String> SPECIAL_SEARCH_PARAMS;
057
058        static {
059                HashSet<String> specialSearchParams = new HashSet<>();
060                specialSearchParams.add(IAnyResource.SP_RES_ID);
061                specialSearchParams.add(Constants.PARAM_INCLUDE);
062                specialSearchParams.add(Constants.PARAM_REVINCLUDE);
063                SPECIAL_SEARCH_PARAMS = Collections.unmodifiableSet(specialSearchParams);
064        }
065
066        private final String myResourceProviderResourceName;
067        private final List<String> myRequiredParamNames;
068        private final List<String> myOptionalParamNames;
069        private final String myCompartmentName;
070        private String myDescription;
071        private final Integer myIdParamIndex;
072        private final String myQueryName;
073        private final boolean myAllowUnknownParams;
074
075        public SearchMethodBinding(
076                        Class<? extends IBaseResource> theReturnResourceType,
077                        Class<? extends IBaseResource> theResourceProviderResourceType,
078                        Method theMethod,
079                        FhirContext theContext,
080                        Object theProvider) {
081                super(theReturnResourceType, theMethod, theContext, theProvider);
082                Search search = theMethod.getAnnotation(Search.class);
083                this.myQueryName = StringUtils.defaultIfBlank(search.queryName(), null);
084                this.myCompartmentName = StringUtils.defaultIfBlank(search.compartmentName(), null);
085                this.myIdParamIndex = ParameterUtil.findIdParameterIndex(theMethod, getContext());
086                this.myAllowUnknownParams = search.allowUnknownParams();
087                this.myDescription = ParametersUtil.extractDescription(theMethod);
088
089                /*
090                 * Only compartment searching methods may have an ID parameter
091                 */
092                if (isBlank(myCompartmentName) && myIdParamIndex != null) {
093                        String msg = theContext
094                                        .getLocalizer()
095                                        .getMessage(
096                                                        getClass().getName() + ".idWithoutCompartment",
097                                                        theMethod.getName(),
098                                                        theMethod.getDeclaringClass());
099                        throw new ConfigurationException(Msg.code(412) + msg);
100                }
101
102                if (theResourceProviderResourceType != null) {
103                        this.myResourceProviderResourceName = theContext.getResourceType(theResourceProviderResourceType);
104                } else {
105                        this.myResourceProviderResourceName = null;
106                }
107
108                myRequiredParamNames = getQueryParameters().stream()
109                                .filter(t -> t.isRequired())
110                                .map(t -> t.getName())
111                                .collect(Collectors.toList());
112                myOptionalParamNames = getQueryParameters().stream()
113                                .filter(t -> !t.isRequired())
114                                .map(t -> t.getName())
115                                .collect(Collectors.toList());
116        }
117
118        public String getDescription() {
119                return myDescription;
120        }
121
122        public String getQueryName() {
123                return myQueryName;
124        }
125
126        public String getResourceProviderResourceName() {
127                return myResourceProviderResourceName;
128        }
129
130        @Nonnull
131        @Override
132        public RestOperationTypeEnum getRestOperationType() {
133                return RestOperationTypeEnum.SEARCH_TYPE;
134        }
135
136        @Override
137        protected BundleTypeEnum getResponseBundleType() {
138                return BundleTypeEnum.SEARCHSET;
139        }
140
141        @Override
142        public ReturnTypeEnum getReturnType() {
143                return ReturnTypeEnum.BUNDLE;
144        }
145
146        @Override
147        public MethodMatchEnum incomingServerRequestMatchesMethod(RequestDetails theRequest) {
148
149                if (!mightBeSearchRequest(theRequest)) {
150                        return MethodMatchEnum.NONE;
151                }
152
153                if (theRequest.getId() != null && myIdParamIndex == null) {
154                        ourLog.trace("Method {} doesn't match because ID is not null: {}", getMethod(), theRequest.getId());
155                        return MethodMatchEnum.NONE;
156                }
157                if (!StringUtils.equals(myCompartmentName, theRequest.getCompartmentName())) {
158                        ourLog.trace(
159                                        "Method {} doesn't match because it is for compartment {} but request is compartment {}",
160                                        getMethod(),
161                                        myCompartmentName,
162                                        theRequest.getCompartmentName());
163                        return MethodMatchEnum.NONE;
164                }
165
166                if (myQueryName != null) {
167                        String[] queryNameValues = theRequest.getParameters().get(Constants.PARAM_QUERY);
168                        if (queryNameValues != null && StringUtils.isNotBlank(queryNameValues[0])) {
169                                String queryName = queryNameValues[0];
170                                if (!myQueryName.equals(queryName)) {
171                                        ourLog.trace("Query name does not match {}", myQueryName);
172                                        return MethodMatchEnum.NONE;
173                                }
174                        } else {
175                                ourLog.trace("Query name does not match {}", myQueryName);
176                                return MethodMatchEnum.NONE;
177                        }
178                } else {
179                        String[] queryNameValues = theRequest.getParameters().get(Constants.PARAM_QUERY);
180                        if (queryNameValues != null && StringUtils.isNotBlank(queryNameValues[0])) {
181                                ourLog.trace("Query has name");
182                                return MethodMatchEnum.NONE;
183                        }
184                }
185
186                Set<String> unqualifiedNames =
187                                theRequest.getUnqualifiedToQualifiedNames().keySet();
188                Set<String> qualifiedParamNames = theRequest.getParameters().keySet();
189
190                MethodMatchEnum retVal = MethodMatchEnum.EXACT;
191                for (String nextRequestParam : theRequest.getParameters().keySet()) {
192                        String nextUnqualifiedRequestParam = ParameterUtil.stripModifierPart(nextRequestParam);
193                        if (nextRequestParam.startsWith("_") && !SPECIAL_SEARCH_PARAMS.contains(nextUnqualifiedRequestParam)) {
194                                continue;
195                        }
196
197                        boolean parameterMatches = false;
198                        boolean approx = false;
199                        for (BaseQueryParameter nextMethodParam : getQueryParameters()) {
200
201                                if (nextRequestParam.equals(nextMethodParam.getName())) {
202                                        QualifierDetails qualifiers = QualifierDetails.extractQualifiersFromParameterName(nextRequestParam);
203                                        if (qualifiers.passes(
204                                                        nextMethodParam.getQualifierWhitelist(), nextMethodParam.getQualifierBlacklist())) {
205                                                parameterMatches = true;
206                                        }
207                                } else if (nextUnqualifiedRequestParam.equals(nextMethodParam.getName())) {
208                                        List<String> qualifiedNames =
209                                                        theRequest.getUnqualifiedToQualifiedNames().get(nextUnqualifiedRequestParam);
210                                        if (passesWhitelistAndBlacklist(
211                                                        qualifiedNames,
212                                                        nextMethodParam.getQualifierWhitelist(),
213                                                        nextMethodParam.getQualifierBlacklist())) {
214                                                parameterMatches = true;
215                                        }
216                                }
217
218                                // Repetitions supplied by URL but not supported by this parameter
219                                if (theRequest.getParameters().get(nextRequestParam).length > 1
220                                                != nextMethodParam.supportsRepetition()) {
221                                        approx = true;
222                                }
223                        }
224
225                        if (parameterMatches) {
226
227                                if (approx) {
228                                        retVal = retVal.weakerOf(MethodMatchEnum.APPROXIMATE);
229                                }
230
231                        } else {
232
233                                if (myAllowUnknownParams) {
234                                        retVal = retVal.weakerOf(MethodMatchEnum.APPROXIMATE);
235                                } else {
236                                        retVal = retVal.weakerOf(MethodMatchEnum.NONE);
237                                }
238                        }
239
240                        if (retVal == MethodMatchEnum.NONE) {
241                                break;
242                        }
243                }
244
245                if (retVal != MethodMatchEnum.NONE) {
246                        for (String nextRequiredParamName : myRequiredParamNames) {
247                                if (!qualifiedParamNames.contains(nextRequiredParamName)) {
248                                        if (!unqualifiedNames.contains(nextRequiredParamName)) {
249                                                retVal = MethodMatchEnum.NONE;
250                                                break;
251                                        }
252                                }
253                        }
254                }
255                if (retVal != MethodMatchEnum.NONE) {
256                        for (String nextRequiredParamName : myOptionalParamNames) {
257                                if (!qualifiedParamNames.contains(nextRequiredParamName)) {
258                                        if (!unqualifiedNames.contains(nextRequiredParamName)) {
259                                                retVal = retVal.weakerOf(MethodMatchEnum.APPROXIMATE);
260                                        }
261                                }
262                        }
263                }
264
265                return retVal;
266        }
267
268        /**
269         * Is this request a request for a normal search - Ie. not a named search, nor a compartment
270         * search, just a plain old search.
271         *
272         * @since 5.4.0
273         */
274        public static boolean isPlainSearchRequest(RequestDetails theRequest) {
275                if (theRequest.getId() != null) {
276                        return false;
277                }
278                if (isNotBlank(theRequest.getCompartmentName())) {
279                        return false;
280                }
281                return mightBeSearchRequest(theRequest);
282        }
283
284        private static boolean mightBeSearchRequest(RequestDetails theRequest) {
285                if (theRequest.getRequestType() == RequestTypeEnum.GET
286                                && theRequest.getOperation() != null
287                                && !Constants.PARAM_SEARCH.equals(theRequest.getOperation())) {
288                        return false;
289                }
290                if (theRequest.getRequestType() == RequestTypeEnum.POST
291                                && !Constants.PARAM_SEARCH.equals(theRequest.getOperation())) {
292                        return false;
293                }
294                if (theRequest.getRequestType() != RequestTypeEnum.GET && theRequest.getRequestType() != RequestTypeEnum.POST) {
295                        return false;
296                }
297                if (theRequest.getParameters().get(Constants.PARAM_PAGINGACTION) != null) {
298                        return false;
299                }
300                return true;
301        }
302
303        @Override
304        public IBundleProvider invokeServer(
305                        IRestfulServer<?> theServer, RequestDetails theRequest, Object[] theMethodParams)
306                        throws InvalidRequestException, InternalErrorException {
307                if (myIdParamIndex != null) {
308                        theMethodParams[myIdParamIndex] = theRequest.getId();
309                }
310
311                Object response = invokeServerMethod(theRequest, theMethodParams);
312
313                return toResourceList(response);
314        }
315
316        @Override
317        protected boolean isAddContentLocationHeader() {
318                return false;
319        }
320
321        private boolean passesWhitelistAndBlacklist(
322                        List<String> theQualifiedNames, Set<String> theQualifierWhitelist, Set<String> theQualifierBlacklist) {
323                if (theQualifierWhitelist == null && theQualifierBlacklist == null) {
324                        return true;
325                }
326                for (String next : theQualifiedNames) {
327                        QualifierDetails qualifiers = QualifierDetails.extractQualifiersFromParameterName(next);
328                        if (!qualifiers.passes(theQualifierWhitelist, theQualifierBlacklist)) {
329                                return false;
330                        }
331                }
332                return true;
333        }
334
335        @Override
336        public String toString() {
337                return getMethod().toString();
338        }
339}