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.parser.DataFormatException;
027import ca.uhn.fhir.rest.annotation.IdParam;
028import ca.uhn.fhir.rest.annotation.Operation;
029import ca.uhn.fhir.rest.annotation.OperationParam;
030import ca.uhn.fhir.rest.annotation.OptionalParam;
031import ca.uhn.fhir.rest.annotation.RequiredParam;
032import ca.uhn.fhir.rest.api.RequestTypeEnum;
033import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
034import ca.uhn.fhir.rest.api.server.IBundleProvider;
035import ca.uhn.fhir.rest.api.server.IRestfulServer;
036import ca.uhn.fhir.rest.api.server.RequestDetails;
037import ca.uhn.fhir.rest.param.ParameterUtil;
038import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
039import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
040import ca.uhn.fhir.util.ParametersUtil;
041import jakarta.annotation.Nonnull;
042import org.apache.commons.lang3.builder.ToStringBuilder;
043import org.apache.commons.lang3.builder.ToStringStyle;
044import org.hl7.fhir.instance.model.api.IBase;
045import org.hl7.fhir.instance.model.api.IBaseResource;
046
047import java.io.IOException;
048import java.lang.annotation.Annotation;
049import java.lang.reflect.Method;
050import java.lang.reflect.Modifier;
051import java.util.ArrayList;
052import java.util.Collections;
053import java.util.List;
054import java.util.stream.Collectors;
055
056import static org.apache.commons.lang3.StringUtils.isBlank;
057import static org.apache.commons.lang3.StringUtils.isNotBlank;
058
059public class OperationMethodBinding extends BaseResourceReturningMethodBinding {
060
061        public static final String WILDCARD_NAME = "$" + Operation.NAME_MATCH_ALL;
062        private final boolean myIdempotent;
063        private final boolean myDeleteEnabled;
064        private final Integer myIdParamIndex;
065        private final String myName;
066        private final RestOperationTypeEnum myOtherOperationType;
067        private final ReturnTypeEnum myReturnType;
068        private final String myShortDescription;
069        private boolean myGlobal;
070        private BundleTypeEnum myBundleType;
071        private boolean myCanOperateAtInstanceLevel;
072        private boolean myCanOperateAtServerLevel;
073        private boolean myCanOperateAtTypeLevel;
074        private String myDescription;
075        private List<ReturnType> myReturnParams;
076        private boolean myManualRequestMode;
077        private boolean myManualResponseMode;
078        private String myCanonicalUrl;
079
080        /**
081         * Constructor - This is the constructor that is called when binding a
082         * standard @Operation method.
083         */
084        public OperationMethodBinding(
085                        Class<?> theReturnResourceType,
086                        Class<? extends IBaseResource> theReturnTypeFromRp,
087                        Method theMethod,
088                        FhirContext theContext,
089                        Object theProvider,
090                        Operation theAnnotation) {
091                this(
092                                theReturnResourceType,
093                                theReturnTypeFromRp,
094                                theMethod,
095                                theContext,
096                                theProvider,
097                                theAnnotation.idempotent(),
098                                theAnnotation.deleteEnabled(),
099                                theAnnotation.name(),
100                                theAnnotation.type(),
101                                theAnnotation.typeName(),
102                                theAnnotation.returnParameters(),
103                                theAnnotation.bundleType(),
104                                theAnnotation.global());
105                myCanonicalUrl = theAnnotation.canonicalUrl();
106                myManualRequestMode = theAnnotation.manualRequest();
107                myManualResponseMode = theAnnotation.manualResponse();
108        }
109
110        protected OperationMethodBinding(
111                        Class<?> theReturnResourceType,
112                        Class<? extends IBaseResource> theReturnTypeFromRp,
113                        Method theMethod,
114                        FhirContext theContext,
115                        Object theProvider,
116                        boolean theIdempotent,
117                        boolean theDeleteEnabled,
118                        String theOperationName,
119                        Class<? extends IBaseResource> theOperationType,
120                        String theOperationTypeName,
121                        OperationParam[] theReturnParams,
122                        BundleTypeEnum theBundleType,
123                        boolean theGlobal) {
124                super(theReturnResourceType, theMethod, theContext, theProvider);
125
126                myBundleType = theBundleType;
127                myIdempotent = theIdempotent;
128                myDeleteEnabled = theDeleteEnabled;
129                myDescription = ParametersUtil.extractDescription(theMethod);
130                myShortDescription = ParametersUtil.extractShortDefinition(theMethod);
131                myGlobal = theGlobal;
132
133                for (Annotation[] nextParamAnnotations : theMethod.getParameterAnnotations()) {
134                        for (Annotation nextParam : nextParamAnnotations) {
135                                if (nextParam instanceof OptionalParam || nextParam instanceof RequiredParam) {
136                                        throw new ConfigurationException(Msg.code(421) + "Illegal method parameter annotation @"
137                                                        + nextParam.annotationType().getSimpleName() + " on method: " + theMethod.toString());
138                                }
139                        }
140                }
141
142                if (isBlank(theOperationName)) {
143                        throw new ConfigurationException(Msg.code(422) + "Method '" + theMethod.getName() + "' on type "
144                                        + theMethod.getDeclaringClass().getName() + " is annotated with @" + Operation.class.getSimpleName()
145                                        + " but this annotation has no name defined");
146                }
147                if (theOperationName.startsWith("$") == false) {
148                        theOperationName = "$" + theOperationName;
149                }
150                myName = theOperationName;
151
152                try {
153                        if (theReturnTypeFromRp != null) {
154                                setResourceName(theContext.getResourceType(theReturnTypeFromRp));
155                        } else if (theOperationType != null && Modifier.isAbstract(theOperationType.getModifiers()) == false) {
156                                setResourceName(theContext.getResourceType(theOperationType));
157                        } else if (isNotBlank(theOperationTypeName)) {
158                                setResourceName(theContext.getResourceType(theOperationTypeName));
159                        } else {
160                                setResourceName(null);
161                        }
162                } catch (DataFormatException e) {
163                        throw new ConfigurationException(
164                                        Msg.code(423) + "Failed to bind method " + theMethod + " - " + e.getMessage(), e);
165                }
166
167                if (theMethod.getReturnType().equals(IBundleProvider.class)) {
168                        myReturnType = ReturnTypeEnum.BUNDLE;
169                } else {
170                        myReturnType = ReturnTypeEnum.RESOURCE;
171                }
172
173                myIdParamIndex = ParameterUtil.findIdParameterIndex(theMethod, getContext());
174                if (getResourceName() == null) {
175                        myOtherOperationType = RestOperationTypeEnum.EXTENDED_OPERATION_SERVER;
176                        if (myIdParamIndex != null) {
177                                myCanOperateAtInstanceLevel = true;
178                        } else {
179                                myCanOperateAtServerLevel = true;
180                        }
181                } else if (myIdParamIndex == null) {
182                        myOtherOperationType = RestOperationTypeEnum.EXTENDED_OPERATION_TYPE;
183                        myCanOperateAtTypeLevel = true;
184                } else {
185                        myOtherOperationType = RestOperationTypeEnum.EXTENDED_OPERATION_INSTANCE;
186                        myCanOperateAtInstanceLevel = true;
187                        for (Annotation next : theMethod.getParameterAnnotations()[myIdParamIndex]) {
188                                if (next instanceof IdParam) {
189                                        myCanOperateAtTypeLevel = ((IdParam) next).optional() == true;
190                                }
191                        }
192                }
193
194                myReturnParams = new ArrayList<>();
195                if (theReturnParams != null) {
196                        for (OperationParam next : theReturnParams) {
197                                ReturnType type = new ReturnType();
198                                type.setName(next.name());
199                                type.setMin(next.min());
200                                type.setMax(next.max());
201                                if (type.getMax() == OperationParam.MAX_DEFAULT) {
202                                        type.setMax(1);
203                                }
204                                Class<? extends IBase> returnType = next.type();
205                                if (!returnType.equals(IBase.class)) {
206                                        if (returnType.isInterface() || Modifier.isAbstract(returnType.getModifiers())) {
207                                                throw new ConfigurationException(
208                                                                Msg.code(424) + "Invalid value for @OperationParam.type(): " + returnType.getName());
209                                        }
210                                        OperationParameter.validateTypeIsAppropriateVersionForContext(
211                                                        theMethod, returnType, theContext, "return");
212                                        type.setType(theContext.getElementDefinition(returnType).getName());
213                                }
214                                myReturnParams.add(type);
215                        }
216                }
217
218                // Parameter Validation
219                if (myCanOperateAtInstanceLevel && !isGlobalMethod() && getResourceName() == null) {
220                        throw new ConfigurationException(Msg.code(425) + "@" + Operation.class.getSimpleName()
221                                        + " method is an instance level method (it has an @" + IdParam.class.getSimpleName()
222                                        + " parameter) but is not marked as global() and is not declared in a resource provider: "
223                                        + theMethod.getName());
224                }
225        }
226
227        public String getShortDescription() {
228                return myShortDescription;
229        }
230
231        @Override
232        public boolean isGlobalMethod() {
233                return myGlobal;
234        }
235
236        public String getDescription() {
237                return myDescription;
238        }
239
240        public void setDescription(String theDescription) {
241                myDescription = theDescription;
242        }
243
244        /**
245         * Returns the name of the operation, starting with "$"
246         */
247        public String getName() {
248                return myName;
249        }
250
251        @Override
252        protected BundleTypeEnum getResponseBundleType() {
253                return myBundleType;
254        }
255
256        @Nonnull
257        @Override
258        public RestOperationTypeEnum getRestOperationType() {
259                assert myOtherOperationType != null;
260                return myOtherOperationType;
261        }
262
263        public List<ReturnType> getReturnParams() {
264                return Collections.unmodifiableList(myReturnParams);
265        }
266
267        @Override
268        public ReturnTypeEnum getReturnType() {
269                return myReturnType;
270        }
271
272        @Override
273        public MethodMatchEnum incomingServerRequestMatchesMethod(RequestDetails theRequest) {
274                if (isBlank(theRequest.getOperation())) {
275                        return MethodMatchEnum.NONE;
276                }
277
278                if (!myName.equals(theRequest.getOperation())) {
279                        if (!myName.equals(WILDCARD_NAME)) {
280                                return MethodMatchEnum.NONE;
281                        }
282                }
283
284                if (getResourceName() == null) {
285                        if (isNotBlank(theRequest.getResourceName())) {
286                                if (!isGlobalMethod()) {
287                                        return MethodMatchEnum.NONE;
288                                }
289                        }
290                }
291
292                if (getResourceName() != null && !getResourceName().equals(theRequest.getResourceName())) {
293                        return MethodMatchEnum.NONE;
294                }
295
296                RequestTypeEnum requestType = theRequest.getRequestType();
297                if (requestType != RequestTypeEnum.GET
298                                && requestType != RequestTypeEnum.POST
299                                && requestType != RequestTypeEnum.DELETE) {
300                        // Operations can only be invoked with GET, POST and DELETE
301                        return MethodMatchEnum.NONE;
302                }
303
304                boolean requestHasId = theRequest.getId() != null;
305                if (requestHasId) {
306                        return myCanOperateAtInstanceLevel ? MethodMatchEnum.EXACT : MethodMatchEnum.NONE;
307                }
308                if (isNotBlank(theRequest.getResourceName())) {
309                        return myCanOperateAtTypeLevel ? MethodMatchEnum.EXACT : MethodMatchEnum.NONE;
310                }
311                return myCanOperateAtServerLevel ? MethodMatchEnum.EXACT : MethodMatchEnum.NONE;
312        }
313
314        @Override
315        public RestOperationTypeEnum getRestOperationType(RequestDetails theRequestDetails) {
316                RestOperationTypeEnum retVal = super.getRestOperationType(theRequestDetails);
317
318                if (retVal == RestOperationTypeEnum.EXTENDED_OPERATION_INSTANCE) {
319                        if (theRequestDetails.getId() == null) {
320                                retVal = RestOperationTypeEnum.EXTENDED_OPERATION_TYPE;
321                        }
322                }
323
324                if (myGlobal
325                                && theRequestDetails.getId() != null
326                                && theRequestDetails.getId().hasIdPart()) {
327                        retVal = RestOperationTypeEnum.EXTENDED_OPERATION_INSTANCE;
328                } else if (myGlobal && isNotBlank(theRequestDetails.getResourceName())) {
329                        retVal = RestOperationTypeEnum.EXTENDED_OPERATION_TYPE;
330                }
331
332                return retVal;
333        }
334
335        @Override
336        public String toString() {
337                return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
338                                .append("name", myName)
339                                .append(
340                                                "methodName",
341                                                getMethod().getDeclaringClass().getSimpleName() + "."
342                                                                + getMethod().getName())
343                                .append("serverLevel", myCanOperateAtServerLevel)
344                                .append("typeLevel", myCanOperateAtTypeLevel)
345                                .append("instanceLevel", myCanOperateAtInstanceLevel)
346                                .toString();
347        }
348
349        @Override
350        public Object invokeServer(IRestfulServer<?> theServer, RequestDetails theRequest)
351                        throws BaseServerResponseException, IOException {
352                if (theRequest.getRequestType() == RequestTypeEnum.POST && !myManualRequestMode) {
353                        IBaseResource requestContents = ResourceParameter.loadResourceFromRequest(theRequest, this, null);
354                        theRequest.getUserData().put(OperationParameter.REQUEST_CONTENTS_USERDATA_KEY, requestContents);
355                }
356                return super.invokeServer(theServer, theRequest);
357        }
358
359        @Override
360        public Object invokeServer(IRestfulServer<?> theServer, RequestDetails theRequest, Object[] theMethodParams)
361                        throws BaseServerResponseException {
362                List<RequestTypeEnum> allowedRequestTypes = new ArrayList<>(List.of(RequestTypeEnum.POST));
363                if (myIdempotent) {
364                        allowedRequestTypes.add(RequestTypeEnum.GET);
365                }
366                if (myDeleteEnabled) {
367                        allowedRequestTypes.add(RequestTypeEnum.DELETE);
368                }
369                String messageParameter =
370                                allowedRequestTypes.stream().map(RequestTypeEnum::name).collect(Collectors.joining(", "));
371                String message = getContext()
372                                .getLocalizer()
373                                .getMessage(
374                                                OperationMethodBinding.class,
375                                                "methodNotSupported",
376                                                theRequest.getRequestType(),
377                                                messageParameter);
378                if (theRequest.getRequestType() == RequestTypeEnum.POST) {
379                        // all good
380                } else if (theRequest.getRequestType() == RequestTypeEnum.GET) {
381                        if (!myIdempotent) {
382                                throw new MethodNotAllowedException(
383                                                Msg.code(426) + message, allowedRequestTypes.toArray(RequestTypeEnum[]::new));
384                        }
385                } else if (theRequest.getRequestType() == RequestTypeEnum.DELETE) {
386                        if (!myDeleteEnabled) {
387                                throw new MethodNotAllowedException(
388                                                Msg.code(427) + message, allowedRequestTypes.toArray(RequestTypeEnum[]::new));
389                        }
390                } else {
391                        throw new MethodNotAllowedException(
392                                        Msg.code(428) + message, allowedRequestTypes.toArray(RequestTypeEnum[]::new));
393                }
394
395                if (myIdParamIndex != null) {
396                        theMethodParams[myIdParamIndex] = theRequest.getId();
397                }
398
399                Object response = invokeServerMethod(theRequest, theMethodParams);
400                if (myManualResponseMode) {
401                        return null;
402                }
403
404                IBundleProvider retVal = toResourceList(response);
405                return retVal;
406        }
407
408        public boolean isCanOperateAtInstanceLevel() {
409                return this.myCanOperateAtInstanceLevel;
410        }
411
412        public boolean isCanOperateAtServerLevel() {
413                return this.myCanOperateAtServerLevel;
414        }
415
416        public boolean isCanOperateAtTypeLevel() {
417                return myCanOperateAtTypeLevel;
418        }
419
420        public boolean isIdempotent() {
421                return myIdempotent;
422        }
423
424        public boolean isDeleteEnabled() {
425                return myDeleteEnabled;
426        }
427
428        @Override
429        protected void populateRequestDetailsForInterceptor(RequestDetails theRequestDetails, Object[] theMethodParams) {
430                super.populateRequestDetailsForInterceptor(theRequestDetails, theMethodParams);
431                IBaseResource resource =
432                                (IBaseResource) theRequestDetails.getUserData().get(OperationParameter.REQUEST_CONTENTS_USERDATA_KEY);
433                theRequestDetails.setResource(resource);
434        }
435
436        public boolean isManualRequestMode() {
437                return myManualRequestMode;
438        }
439
440        public String getCanonicalUrl() {
441                return myCanonicalUrl;
442        }
443
444        public static class ReturnType {
445                private int myMax;
446                private int myMin;
447                private String myName;
448                /**
449                 * http://hl7-fhir.github.io/valueset-operation-parameter-type.html
450                 */
451                private String myType;
452
453                public int getMax() {
454                        return myMax;
455                }
456
457                public void setMax(int theMax) {
458                        myMax = theMax;
459                }
460
461                public int getMin() {
462                        return myMin;
463                }
464
465                public void setMin(int theMin) {
466                        myMin = theMin;
467                }
468
469                public String getName() {
470                        return myName;
471                }
472
473                public void setName(String theName) {
474                        myName = theName;
475                }
476
477                public String getType() {
478                        return myType;
479                }
480
481                public void setType(String theType) {
482                        myType = theType;
483                }
484        }
485}