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}