1Z10
1Z10

Reputation: 3301

Spring-Boot OpenAPI - @RestControllerAdvice not limited to methods throwing the Exception

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

Answers (2)

1Z10
1Z10

Reputation: 3301

A possible solution is to:

  1. Make the @RestControllerAdvice @Hidden
  2. Provide an 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

derwiwie
derwiwie

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

Related Questions