'Java SpringBoot OpenApi @ApiResponse shows wrong return object
I'm using OpenApi 3 in my SpringBoot project in order to generate a Swagger html page.
The dependency in POM.xml :
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.5.12</version> </dependency>
In the Controller class I've defined the following Annotations above the method.
@Operation(
summary = "Get a list of letters for a specific user",
description = "Get a list of letters for a specific user",
tags = {"letters"}
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "success", content = {@Content(
mediaType = "application/json",
array = @ArraySchema(schema = @Schema(implementation = LetterDTO.class)))}),
@ApiResponse(responseCode = "400", description = "BAD REQUEST"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "Forbidden"),
@ApiResponse(responseCode = "404", description = "NOT_FOUND: Entity could not be found")}
)
@GetMapping(value = "letters/user/{userId}", produces = {"application/json"})
public List<LetterDTO> getLettersForUser(
...
)
The output of Swagger UI shows the correct response for code 200, which is a list of LetterDTO objects.
But the response for code 401 also show a list of LetterDTO objects. Al tough I didn't define any response object for code 401. I was expecting Swagger to generate the same response object like for code 400, which is a default return object containing the error code and a error message.
Why does Swagger take the same return object like the one defined for code 200 ? I was expecting that Swagger would generate the default return object. Is this a Bug in Swagger ?
Solution 1:[1]
I normally configure API responses like this:
@ApiResponse(responseCode = "200", description = "OK")
@ApiResponse(responseCode = "400", description = "Invalid request", content = @Content)
If no content
is specified, the return type of the respective Controller method is used. content = @Content
tells Swagger that there is no content in the response.
For @ApiGetOne
this is what Swagger would display (the screenshot is from a different DTO class):
For simplicity and reusability, I typically wrap these in reusable helper annotations, this way my endpoints don't have as many annotations and I don't need a ResponseEntity
in the controller, e.g.:
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@RequestMapping(method = RequestMethod.GET,
produces = MediaType.APPLICATION_JSON_VALUE)
@ApiResponse(responseCode = "200", description = "OK")
@ApiResponse(responseCode = "400", description = "Invalid request", content = @Content)
@ApiResponse(responseCode = "500", description = "Internal error", content = @Content)
public @interface ApiGet {
@AliasFor(annotation = RequestMapping.class)
String[] value() default {};
@AliasFor(annotation = RequestMapping.class)
String[] path() default {};
}
You can also extend these annotations with more API responses, e.g., to add a 404 for some endpoints, create another annotation that also has @ApiGet
on it:
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ApiGet
@ApiResponse(responseCode = "404", description = "Not found", content = @Content)
public @interface ApiGetOne {
@AliasFor(annotation = ApiGet.class)
String[] value() default {};
@AliasFor(annotation = ApiGet.class)
String[] path() default {};
}
and finally, use them on any endpoint (using Java 17):
public record HelloWorldDto(String recipientName) {
public String getMessage() {
return "Hello, %s".formatted(recipientName);
}
}
public record ErrorDto(String message) {
}
@RestController
@RequestMapping("api/test")
@Tag(name = "Demo", description = "Endpoints for testing")
public class DemoController {
...
@ApiGet("/hello")
public HelloWorldDto sayHello() {
return new HelloWorldDto("stranger");
}
@ApiGetOne("/hello/{id}")
public HelloWorldDto sayHelloWithParam(@PathVariable int id) {
final var person = myPersonRepo.getById(id); // might throw a NotFoundException which is mapped to 404 status code
return new HelloWorldDto(person.name());
}
}
Mapping exceptions to custom error responses:
@ControllerAdvice
public class ErrorHandler {
private static final Logger log = LoggerFactory.getLogger(ErrorHandler.class);
@ExceptionHandler
public ResponseEntity<ErrorDto> handle(Exception exception) {
log.error("Internal server error occurred", exception);
return response(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown error occurred.");
}
@ExceptionHandler
public ResponseEntity<ErrorDto> handle(NotFoundException exception) {
return response(HttpStatus.NOT_FOUND, exception.getMessage());
}
private ResponseEntity<ErrorDto> response(HttpStatus status, String message) {
return ResponseEntity
.status(status)
.body(new ErrorDto(message));
}
}
I like this setup a lot because
- I end up with a handful of reusable annotations that are sufficient for typical CRUD endpoints
- I don't need to build
ResponseEntity
in controller methods - the
@ControllerAdvice
serves as a central point of reusable error handling - all of which keeps my controllers/endpoints clean and simple
- and that, in turn, keeps testing simple
Update 2022/04/20
Just had to fix a bug where we have an endpoint that returns images instead of JSON. In this case, to prevent HttpMessageNotWritableException: No converter for [class ErrorDto] with preset Content-Type 'image/jpeg'
, you need to check the Accept
header of the request like so (using a header as fallback):
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorDto> handle(final Exception exception, final WebRequest webRequest) {
return createResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Some error", webRequest);
}
protected ResponseEntity<ErrorDto> createResponse(final HttpStatus httpStatus,
final String message,
final WebRequest webRequest) {
final var accepts = webRequest.getHeader(HttpHeaders.ACCEPT);
if (!MediaType.APPLICATION_JSON_VALUE.equals(accepts)) {
return ResponseEntity.status(httpStatus)
.header("my-error", message)
.build();
}
return ResponseEntity
.status(status)
.body(new ErrorDto(message));
}
Solution 2:[2]
This is a problem that keeps popping up since a Java method can have only return type. Your method has a response type of List<LetterDTO>
, so whatever the HTTP status you return, the response will be of that structure.
To resolve this, most people resort to one of the following:
- A return type of
Object
with corresponding response objects actually retuurned. In this case, the user must depend upon your API documentation to handle the responses correctly. - Another method is to have your own wrapper in the response. This also needs documentation support.
- A third way is to have your
@RequestMapping()
selector use fine-tuned selections and not just by endpoint path. Eg, using request methods also. However, this has the disadvantage of defining new REST endpoints.
But overall, there is no easy way out of this.
Edit: Adding examples
Let us assume these simple objects as your data constructs.
@NoArgsConstructor @AllArgsConstructor
@Getter @Setter
private static class LetterDTO{
String from;
}
@NoArgsConstructor @AllArgsConstructor
@Getter @Setter
private static class ErrorResp{
String message;
}
Then, for the first case using Object
as return value, you can do something like this:
@GetMapping(value = "letters/user/{userId}", produces = {"application/json"})
public Object getLettersForUser( @PathVariable( "userId") String input, HttpServletResponse resp ) {
if( input.equalsIgnoreCase( "a" ) ) {
resp.setStatus( 200 );
return Arrays.asList( new LetterDTO( "A Friend" ) );
}
else{
resp.setStatus( 415 ); //Invalid user
return new ErrorResp( "Invalid user" );
}
}
Or even better in SpringMVC way, ie, returning ResponseEntity
:
@GetMapping(value = "letters2/user/{userId}", produces = {"application/json"})
public ResponseEntity<Object> getLettersForUser2( @PathVariable( "userId") String input, HttpServletResponse resp ) {
if( input.equalsIgnoreCase( "a" ) ) {
return ResponseEntity.ok().body( Arrays.asList( new LetterDTO( "A Friend" ) ) );
}
else{
return ResponseEntity.status( 415 ).body( Arrays.asList( new ErrorResp( "Invalid user" ) ) );
}
}
Using your class as a return value must now be clear. Instead of Object
you return some structure of yours as wrapper say:
@NoArgsConstructor @AllArgsConstructor
@Getter @Setter
private static class APIResponse{
String message;
Object data;
}
As must be evident from these examples, the documentation of your API becomes very important since it is difficult to gather from the signature what the content of the response will be in each case - 200, 415, etc.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 |