Reputation: 3301
I need to document my SpringBoot APIs and their possible exceptions with OpenAPI, and I am using SpringDoc-OpenAPI https://springdoc.org/.
To handle the NotFound cases, I created this exception class:
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class NotFoundException extends ResponseStatusException {
public NotFoundException() {
super(HttpStatus.NOT_FOUND);
}
}
and this @RestControllerAdvice
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalControllerExceptionHandler {
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<String> handleNotFoundException(RuntimeException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND);
}
}
The problem I am facing is that the generated OpenAPI yaml file has
responses:
"404":
description: Not Found
content:
'*/*':
schema:
type: string
for all @RestController
endpoints, instead of only for the methods with throws NotFoundException
.
How can I limit the @ControllerAdvice (or the OpenAPI), to generate the 404 Response documentation only for methods with the throwing signature?
Do I need to use something else other than the @RestControllerAdvice? I would like to avoid having to annotate every single method.
Upvotes: 4
Views: 4555
Reputation: 3301
A possible solution is to:
@RestControllerAdvice
@Hidden
OperationCustomizer
@Bean
import io.swagger.v3.oas.annotations.Hidden;
import it.eng.cysec.ot.risk.assessment.api.exceptions.NotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Hidden
@RestControllerAdvice
public class GlobalControllerExceptionHandler {
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<String> handleNotFoundException(NotFoundException exception) {
return new ResponseEntity<>(exception.getMessage(), HttpStatus.NOT_FOUND);
}
}
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.StringSchema;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.responses.ApiResponses;
import it.eng.cysec.ot.risk.assessment.api.exceptions.NotFoundException;
import org.springdoc.core.customizers.OperationCustomizer;
import org.springframework.web.method.HandlerMethod;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
public class OperationResponseCustomizer implements OperationCustomizer {
public static final ApiResponse NOT_FOUND_API_RESPONSE;
static {
MediaType mediaType = new MediaType();
mediaType.setSchema(new StringSchema());
Content content = new Content();
content.addMediaType("*/*", mediaType);
NOT_FOUND_API_RESPONSE = new ApiResponse()
.description("Not Found")
.content(content);
}
/**
* Customize operation.
*
* @param operation input operation
* @param handlerMethod original handler method
* @return customized operation
*/
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
Method method = handlerMethod.getMethod();
List<Class<?>> exceptions = Arrays.asList(method.getExceptionTypes());
if(exceptions.contains(NotFoundException.class)){
ApiResponses apiResponses = operation.getResponses();
apiResponses.addApiResponse("404", NOT_FOUND_API_RESPONSE);
}
return operation;
}
}
Upvotes: 3
Reputation: 905
Based on 1Z10's answer, I built a solution for Spring 6 that does what you are asking for.
I added the @Hidden
annotation to my RestControllerAdvice
class, and then implemented the following OperationCustomizer
which automatically scans the method declarations in my controllers for throw clauses and adds corresponding ApiResponses for the exceptions that inherit from ErrorResponseException
(responses with a ProblemDetail body) and are annotated with ResponseStatus
:
@Component
public class OperationResponseCustomizer implements OperationCustomizer {
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
Method method = handlerMethod.getMethod();
List<Class<?>> exceptions = Arrays.asList(method.getExceptionTypes());
ApiResponses apiResponses = operation.getResponses();
Map<String, List<ResponseStatus>> responseCodeToAnnotations = new TreeMap<>();
for (Class<?> exception : exceptions) {
if (!ErrorResponseException.class.isAssignableFrom(exception)) {
continue;
}
for (Annotation annotation : exception.getAnnotations()) {
if (annotation.annotationType().equals(ResponseStatus.class)) {
ResponseStatus responseStatus = exception.getAnnotation(ResponseStatus.class);
String responseCode = responseStatus.value().value() + "";
responseCodeToAnnotations.putIfAbsent(responseCode,
new ArrayList<>());
responseCodeToAnnotations.get(responseCode).add(responseStatus);
break;
}
}
}
responseCodeToAnnotations.forEach((responseCode, responseStatuses) -> {
if (responseStatuses.isEmpty())
return;
// generate a joined reason per responseCode
String joinedReason = null;
if (responseStatuses.size() > 1) {
joinedReason = responseStatuses.stream()
.map(s -> String.format("- %s", s.reason()))
.collect(Collectors.joining("\n"));
} else {
joinedReason = responseStatuses.get(0).reason();
}
MediaType mediaType = new MediaType();
ResolvedSchema resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ProblemDetail.class));
if (resolvedSchema != null) {
Schema schema = resolvedSchema.schema;
schema.setTitle("ProblemDetail");
mediaType.setSchema(schema);
}
Content content = new Content();
content.addMediaType("*/*", mediaType);
ApiResponse apiResponse = new ApiResponse()
.description(joinedReason)
.content(content);
apiResponses.addApiResponse(responseCode,
apiResponse);
});
return operation;
}
}
An example exception that is picked up:
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = StartTimeInPastException.REASON)
public class StartTimeInPastException extends ResponseStatusException {
static final String REASON = "startTimeEpoch cannot lie in the past";
public StartTimeInPastException() {
super(HttpStatus.BAD_REQUEST, REASON);
}
}
The filtering of ErrorResponseExceptions could be removed, but I suppose that then the schema of the ApiResponses has to be handled in some other way.
Upvotes: 0