Doing More With Springdoc OpenAPI
Learn how to render fully qualified names in the generated Swagger documentation and global exception handling using Controller Advice.
Join the DZone community and get the full member experience.
Join For FreeJava adoption has shifted from version 1.8 to at least Java 17. Concurrently, Spring Boot has advanced from version 2.x to 3.2.2. The springdoc project has transitioned from the older library 'springdoc-openapi-ui' to 'springdoc-openapi-starter-webmvc-ui' for its functionality. These updates mean that readers relying on older articles may find themselves years behind in these technologies. The author has updated this article so that readers are using the latest versions and don't struggle with outdated information during migration.
In my previous article, we tried out a Spring Boot Open API 3-enabled REST project and explored some of its capabilities, namely:
- Automatic JSR-303-related Swagger documentation
- How Maven builds properties could be shown as project information in the Swagger documentation
In this continuation, we will explore two additional objectives, namely:
- Rendering fully qualified names in the generated Swagger documentation
- Global exception handling using Controller Advice and its related Swagger documentation
We are going to refer to Building a RESTful Web Service and springdoc-openapi v2.5.0 like last time.
Prerequisites
- Java 17.x
- Maven 3.x
Steps
The code is loosely based on the last article, but there are some differences/additions. We will start by listing the new code. We will then run the application. As we walk through this tutorial listing the various artifacts, we will discuss which code helped in achieving the objectives of this tutorial along with some other details.
The code is a typical Java Maven project and is laid out as shown below:
Listing below the different artifacts one by one. Try following the layout above.
Showing pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
<relativePath ></relativePath> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>sample</artifactId>
<version>0.0.1</version>
<name>sample</name>
<description>Demo project for Spring Boot with openapi 3 documentation</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
SampleApplication.java follows:
package sample;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
@SpringBootApplication()
public class SampleApplication {
public static void main(String[] args) {
SpringApplication.run(SampleApplication.class, args);
}
@Bean
public OpenAPI customOpenAPI(@Value("${application-description}") String appDesciption,
@Value("${application-version}") String appVersion) {
return new OpenAPI()
.info(new Info()
.title("sample application API")
.version(appVersion)
.description(appDesciption)
.termsOfService("http://swagger.io/terms/")
.license(new License().name("Apache 2.0").url("http://springdoc.org")));
}
}
Let's now move on to the main model class, Person.java:
package sample;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.hibernate.validator.constraints.CreditCardNumber;
public class Person {
private long id;
private String firstName;
@NotNull
@NotBlank
@Size(max = 10)
private String lastName;
@Pattern(regexp = ".+@.+\\..+", message = "Please provide a valid email address")
private String email;
@Email()
private String email1;
@Min(18)
@Max(30)
private int age;
@CreditCardNumber
private String creditCardNumber;
public String getCreditCardNumber() {
return creditCardNumber;
}
public void setCreditCardNumber(String creditCardNumber) {
this.creditCardNumber = creditCardNumber;
}
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getEmail1() {
return email1;
}
public void setEmail1(String email1) {
this.email1 = email1;
}
@Size(min = 2)
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
-
PersonController.java follows:
package sample;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
@RestController
@Validated
public class PersonController {
private Random ran = new Random();
@RequestMapping(path = "/person", method = RequestMethod.POST)
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, content = @Content(examples = {
@ExampleObject(value = INVALID_REQUEST, name = "invalidRequest", description = "Invalid Request"),
@ExampleObject(value = VALID_REQUEST, name = "validRequest", description = "Valid Request") }))
public Person person(@Valid @RequestBody Person person) {
int nxt = ran.nextInt(10);
if(nxt>=5)
{
throw new RuntimeException("Breaking logic");
}
return person;
}
@RequestMapping(path = "/personByLastName", method = RequestMethod.GET)
public List<Person> findByLastName(@RequestParam(name = "lastName", required = true)@NotNull
@NotBlank
@Size(max = 10)String lastName){
List<Person> hardCoded= new ArrayList<>();
Person person= new Person();
person.setAge(20);
person.setCreditCardNumber("4111111111111111");
person.setEmail("abc@abc.com");
person.setEmail1("abc1@abc.com");
person.setFirstName("Somefirstname");
person.setLastName(lastName);
person.setId(1);
hardCoded.add(person);
return hardCoded;
}
private static final String VALID_REQUEST = """
{
"id": 0,
"firstName": "string",
"lastName": "string",
"email": "abc@abc.com",
"email1": "abc@abc.com",
"age": 20,
"creditCardNumber": "4111111111111111"
}""";
private static final String INVALID_REQUEST = """
{
"id": 0,
"firstName": "string",
"lastName": "string",
"email": "abcabc.com",
"email1": "abcabc.com",
"age": 17,
"creditCardNumber": "411111111111111"
}""";
}
The main changes are:
- The addition of a
GET
method - Use of some additional annotations like
@Validated
,@RequestParam
,@NotNull
,@NotBlank
, and@Size
Note: Use of @Validated
causes validations to be applied even for the GET
method parameter. After the application is complete and running play around with the above annotations. Feel free to remove them and see their effect. The GET
URL can always be directly invoked using http://localhost:8080/personByLastName
and http://localhost:8080/personByLastName?lastName=12345678901
. For now, it's a little early. Let's continue adding the remaining artifacts.
Let's discuss global exception handling using @ControllerAdvice
and how we can achieve corresponding Swagger documentation.
When we come to exception handling in Spring controllers, there are so many exceptions possible. There may be various validation-related exceptions invoked even before the actual controller code is invoked.
The controller itself may have some logic that leads to a business exception or some exception because of bad coding. Rather than handling these exceptions in each controller, we are choosing to duck the exceptions. We are choosing to centralize the exception handling be it during the controller invocation or before.
Below, produce a very rudimentary ControllerAdvice
— GlobalControllerAdvice.java
, which you can later expand upon and improve as needed.
package sample;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@ControllerAdvice @RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public class GlobalControllerAdvice //extends ResponseEntityExceptionHandler
{
/**
* Note use base class if you wish to leverage its handling.
* Some code will need changing.
*/
private static final Logger logger = LoggerFactory.getLogger(GlobalControllerAdvice.class);
@ExceptionHandler(Throwable.class)
@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity < Problem > problem(final Throwable e) {
String message =e.getMessage();
//might actually prefer to use a geeric mesasge
message="Problem occured";
UUID uuid = UUID.randomUUID();
String logRef=uuid.toString();
logger.error("logRef="+logRef, message, e);
return new ResponseEntity <Problem> (new Problem(logRef, message), HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public ResponseEntity<ErrorMessage> handleMethodArgumentNotValid(MethodArgumentNotValidException ex
) {
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
List<ObjectError> globalErrors = ex.getBindingResult().getGlobalErrors();
List<String> errors = new ArrayList<>(fieldErrors.size() + globalErrors.size());
String error;
for (FieldError fieldError : fieldErrors) {
error = fieldError.getField() + ", " + fieldError.getDefaultMessage();
errors.add(error);
}
for (ObjectError objectError : globalErrors) {
error = objectError.getObjectName() + ", " + objectError.getDefaultMessage();
errors.add(error);
}
ErrorMessage errorMessage = new ErrorMessage(errors);
//Object result=ex.getBindingResult();//instead of above can allso pass the more detailed bindingResult
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public ResponseEntity<ErrorMessage> handleConstraintViolatedException(ConstraintViolationException ex
) {
Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
List<String> errors = new ArrayList<>(constraintViolations.size() );
String error;
for (ConstraintViolation constraintViolation : constraintViolations) {
error = constraintViolation.getMessage();
errors.add(error);
}
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public ResponseEntity<ErrorMessage> handleMissingServletRequestParameterException(MissingServletRequestParameterException ex
) {
List<String> errors = new ArrayList<>( );
String error=ex.getParameterName()+", "+ex.getMessage();
errors.add(error);
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
@ResponseStatus(code = HttpStatus.UNSUPPORTED_MEDIA_TYPE)
public ResponseEntity<ErrorMessage> handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex
) {
String unsupported = "Unsupported content type: " + ex.getContentType();
String supported = "Supported content types: " + MediaType.toString(ex.getSupportedMediaTypes());
ErrorMessage errorMessage = new ErrorMessage(unsupported, supported);
return new ResponseEntity(errorMessage, HttpStatus.UNSUPPORTED_MEDIA_TYPE);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public ResponseEntity<ErrorMessage> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) {
Throwable mostSpecificCause = ex.getMostSpecificCause();
ErrorMessage errorMessage;
if (mostSpecificCause != null) {
String exceptionName = mostSpecificCause.getClass().getName();
String message = mostSpecificCause.getMessage();
errorMessage = new ErrorMessage(exceptionName, message);
} else {
errorMessage = new ErrorMessage(ex.getMessage());
}
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
}
The key thing to remember is to decorate the methods in this class with @org.springframework.web.bind.annotation.ResponseStatus
which is the only hint that Springdoc OpenAPI needs for including the appropriate models in its controller method Swagger documentation.
For completeness, below, we will list two classes referred to in this GlobalControllerAdvice.java
before we attempt to start the application.
Problem.java follows:
package sample;
public class Problem {
private String logRef;
private String message;
public Problem(String logRef, String message) {
super();
this.logRef = logRef;
this.message = message;
}
public Problem() {
super();
}
public String getLogRef() {
return logRef;
}
public void setLogRef(String logRef) {
this.logRef = logRef;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
ErrorMessage.java also follows:
package sample;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class ErrorMessage {
private List<String> errors;
public ErrorMessage() {
}
public ErrorMessage(List<String> errors) {
this.errors = errors;
}
public ErrorMessage(String error) {
this(Collections.singletonList(error));
}
public ErrorMessage(String ... errors) {
this(Arrays.asList(errors));
}
public List<String> getErrors() {
return errors;
}
public void setErrors(List<String> errors) {
this.errors = errors;
}
}
Let's Not Forget application.properties
xxxxxxxxxx
application-description=@project.description@
application-version=@project.version@
springdoc.use-fqn=true
Starting with v1.4.4 of springdoc-openapi, "springdoc.use-fqn=true
" enables fully qualified names support. Earlier, to achieve the same, we had to write a custom converter.
You might refer to this GitHub comment regarding this.
Also note: Now that we are using ControllerAdvice
we no longer need server.error.include-binding-error
in application.properties. Refer to previous article if needing more information.
That completes all our code for this tutorial. Let's try it out.
Let's Try It Out
Execute the mvn clean package
from the command prompt or terminal. Then, execute java -jar target\sample-0.0.1.jar
.
You can also launch the application by running the SampleApplication.java
class from your IDE.
Now, let's visit the Swagger UI — http://localhost:8080/swagger-ui.html. We can also visit the JSON-based API-docs - http://localhost:8080/v3/api-docs. For YAML-based API-docs, use http://localhost:8080/v3/api-docs.yaml. These URLs are the defaults and can be changed if needed.
The general usage of Swagger UI is explained in the last article. In case of issues please see the "Troubleshooting" section at the bottom of this tutorial.
We will focus this tutorial on specific areas.
Now that the application should be running let's visit it.
The swagger UI landing screen — http://localhost:8080/swagger-ui.html.
Note the fully qualified class names.
Now let's see how Controller Advice has contributed to the Swagger documentation.
If we were to click the schema links and expand the properties sections it would look like this.
So by adding @ResponseStatus
to our ControllerAdvice
methods, we can see the related schemas associated with the HTTPStatus
for each of the REST controller methods documentation. How neat is that?
Note: @ControllerAdvice
also has some attributes (e.g. "assignableTypes
"), which allow the @ControllerAdvice
to be applied more specifically on controllers than globally. Currently, that behavior is not there yet, but please expect it to be there when the springdoc-openapi folks release their next version.
With the above, we have completed the two goals we had set out to achieve.
A little manual testing follows.
The POST Method
xxxxxxxxxx
path = "/person", method = RequestMethod.POST) (
public Person person( Person person) {
int nxt = ran.nextInt(10);
if(nxt>=5)
{
throw new RuntimeException("Breaking logic");
}
return person;
}
This method is designed to have automatic validations. That was tested in the last article.
This method is also designed to cause exceptions randomly just to see how the controller's advice is invoked when things go wrong.
Let's test.
Click the green POST button. Then click the Try it out button. Then click the blue Execute button.
Note that the validation errors are crisper compared to the last article.
{
"errors": [
"age, must be greater than or equal to 18",
"creditCardNumber, invalid credit card number",
"email1, must be a well-formed email address",
"email, Please provide a valid email address"
]
}
The validation errors are now crisper because of the logic in the controller advice.
Let's now feed it valid input. Showing here valid input for ready reference that can be copy-pasted.
xxxxxxxxxx
{
"firstName": "string",
"lastName": "string",
"email": "string@email1.com",
"email1": "strin@email2.comg",
"age": 18,
"creditCardNumber": "4111111111111111"
}
You an also select from the Examples dropdown as shown below and press the blue Execute button.
This will cause either of the two below responses depending on whether the random exception occurs or not:
|
|
Without the exception |
When the exception happened |
Note again the response for the HTTP Status code of 500 is formatted as per logic in the ControllerAdvice
.
The GET Method
Let's now examine the GET
method in our controller.
path = "/personByLastName", method = RequestMethod.GET) (
public List<Person> findByLastName( (name = "lastName", required = true)
(max = 10)String lastName){
List<Person> hardCoded= new ArrayList<>();
//skipping the details
return hardCoded;
}
Also, note that the controller class is decorated with a @Validated
annotation.
There can be a few minor limitations. I will explore one of them without spending too much time on it. As we saw in our last article, as of now it does not fully cover all the JSR 303 annotations. There are other issues also some of which are not entirely due to Springdoc OpenAPI, but rather more likely due to the Swagger UI project itself.
Let's click the blue GET button followed by the Try it out button.
Let's feed in 12345678901
for the lastName
input field and press the blue Execute button.
There can be some improvement in how the contract expected from the validations is displayed.
As discussed earlier, the GET URL can always be directly invoked using http://localhost:8080/personByLastName
and http://localhost:8080/personByLastName?lastName=12345678901
. Give it a try to see what the responses are. Play with the annotations @Validated
, @RequestParam
, @NotNull
, @NotBlank
, and @Size
, and see how the handling or contract changes. A Spring developer who also is a user of the generated documentation will expect the documentation to correspond to his usage of these and other annotations.
It's possible there are some minor areas where this may not be perfect but the bigger strong vital pieces are all there. It covers OpenAPI 3, even Webflux or OAuth 2. It's only a question of time when through contributions and feedback this can keep only getting better.
Conclusion
All said and done, for a fairly recent API, Springdoc OpenAPI has achieved quite a lot very elegantly. Impressive. And it does cover most of the important scenarios thoroughly.
We achieved our objective of realizing FQNs or fully qualified names for Java classes and also saw how to integrate the documentation with global exception handling leveraging Controller Advice.
Troubleshooting Tips
- Ensure prerequisites.
- If using the Eclipse IDE, we might need to do a Maven update on the project after creating all the files.
- In the Swagger UI, if you are unable to access the “Schema” definitions link, it might be because you need to come out of the “try it out “ mode. Click on one or two Cancel buttons that might be visible.
- Ensure you use http://localhost:8080/swagger-ui.html for this tutorial.
- Source Code
- Git Clone URL, Branch: springdoc-openapi-doingmore-updated1.
This article was interesting for me. Firstly there were some useful suggestions from readers. Which fortunately were rapidly incorporated in Springdoc. Also, I had been pushing for simplification of realizing FQNs. That has also been achieved which lead me to update this article.
Please also read my next article on Springdoc and Swagger where we extend Swagger behavior using Swagger schema extensions and communicate via its automated documentation, information regarding other annotations, and custom validation constraint annotations - information that is not captured out of the box by Swagger and Springdoc.
Editor's Note: It's so great to see when authors and readers engage with one another like this. Our purpose at DZone ultimately is to bring developers together to make better software. We're so happy this happened here and hope it continues to do so.
Opinions expressed by DZone contributors are their own.
Comments