Accion Labs members share technology ideas to foster digital transformation.

Extending Swagger and Spring Doc Open API — Accion Labs

Written by Raghuraman Ramaswamy | Jan 5, 2023 5:00:00 AM

See how to extend swagger behavior and communicate via its automated documentation the information regarding additional annotations and more.

In my last recent articles — https://dzone.com/articles/openapi-3-documentation-with-spring-boot and https://dzone.com/articles/doing-more-with-springdoc-openapi — 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
  • Rendering Fully Qualified names in the generated swagger documentation
  • Global Exception Handling Using Controller Advice and its related swagger documentation

We also discussed how in future releases of springdoc-openapi

  • Achieving FQNs can be lot easier (since achieved)
  • springdoc-openapi will handle @ControlerAdvice related documentation in even better manner with more flexibility (since achieved) .

Previously, amongst other details, we saw how we were able to leverage some of the JSR 303 annotations. We did notice some annotations were being ignored eg - javax.validation.constraints.Email and say org.hibernate.validator.constraints.CreditCardNumber

New Objective

Wouldn't it be nice if we could extend swagger behavior and communicate via its automated documentation the information regarding these additional annotations and also custom validation annotations?

Let's explore that. Let's keep the code as simple as possible. We will start from scratch and write enough code to achieve our objective.

Won't be repeating the exception handling and Controller advice concepts already detailed last time (just to keep this article code as simple as possible).

As before we are going to refer to https://spring.io/guides/gs/rest-service/ and https://springdoc.org/.

Prerequisites:

  • Java 8.x
  • Maven 3.x
  • Lombok installed in the IDE if using an IDE

Steps

Start by creating a Maven JAR project. Below, you will see the pom.xml to use:

XML

<?xml version="1.0" encoding="UTF-8"?>

    <modelVersion>4.0.0</modelVersion>

        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.4</version>
        <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>


        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>1.5.10</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
        </dependency>
    </dependencies>
    <build>


                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Note the "springdoc-openapi-ui" dependency and "springdoc-openapi-maven-plugin" plugin.

If using the Eclipse IDE, we might need to do a Maven update on the project (right click on project - Maven > Update Project) after creating all the pom.xml with the above content.

Now, let's create a small Java bean class similar to previous articles.

Java

package sample.model;

import javax.validation.constraints.Email;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;

import lombok.Data;
import org.hibernate.validator.constraints.CreditCardNumber;
import sample.customvalidations.DateTimeType;
import sample.customvalidations.LocalDateTimeFormat;

@Data
@XmlRootElement(name = "person")
@XmlAccessorType(XmlAccessType.FIELD)
public class Person {

    private long id;

    @Size(min = 2)
    private String firstName;

    @NotNull
    @NotBlank
    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;

    @LocalDateTimeFormat(pattern = "yyyyMMdd", dateTimeType = DateTimeType.Date, 
                         message = "Invalid dateTimeField Format. It Should be in yyyyMMdd format")
    private String registrationDate;

}

Person.java might complain about sample.customvalidations.DateTimeType and sample.customvalidations.LocalDateTimeFormat. We will add those classes later as we proceed with the steps.

This is an example of a Java bean. This java bean also now contains an additional "registrationDate" field just to demonstrate custom validators.

Now, let's create a controller.

Java

package sample.controller;

import javax.validation.Valid;

import sample.model.Person;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PersonController {

    @PostMapping(path = "/person", consumes = { MediaType.APPLICATION_JSON_VALUE, 
            MediaType.APPLICATION_XML_VALUE })
    public Person person(@Valid @RequestBody Person person) {
        return person;
    }
}

Let's make some entries in src\main\resources\application.properties. Please create the file accordingly.

Properties files

application-description=@project.description@
application-version=@project.version@
springdoc.swagger-ui.show-extensions=true
springdoc.swagger-ui.show-common-extensions=true
server.error.include-message=always
server.error.include-binding-errors=always
springdoc.use-fqn=true

The application description, application-version entries will pass on Maven build-related information to the OpenAPI documentation.

Java

package sample.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class OpenApiConfig {

    @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 write the spring boot application class

Java

package sample;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SampleApplication.class, args);
    }

}

Let's add some more code to help demonstrate custom validators.

Java

package sample.customvalidations;

public enum DateTimeType {
    DateTime,
    Date,
    Time
}

Java

package sample.customvalidations;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER,
        ElementType.ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = LocalDateTimeValidator.class)
@Documented
public @interface LocalDateTimeFormat {
    String message() default "{message.key}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String pattern();
    DateTimeType dateTimeType() default DateTimeType.DateTime;
}

Java

package sample.customvalidations;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class LocalDateTimeValidator implements ConstraintValidator<LocalDateTimeFormat, String> {

    private String pattern;

    private DateTimeType dateTimeType;

    @Override
    public void initialize(LocalDateTimeFormat constraintAnnotation) {
        this.pattern = constraintAnnotation.pattern();
        this.dateTimeType = constraintAnnotation.dateTimeType();
    }

    @Override
    public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
        if (object == null || "".equals(object)) {
            return true;
        }

        try {
            DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(pattern);
            if (DateTimeType.Time.equals(dateTimeType)) {
                LocalTime.parse(object, dateFormatter);
            }
            else if (DateTimeType.Date.equals(dateTimeType)) {
                LocalDate.parse(object, dateFormatter);
            }
            else {
                LocalDateTime.parse(object, dateFormatter);
            }
            return true;
        }
        catch (Exception e) {
            // e.printStackTrace();
            return false;
        }
    }
}

At this stage this is what the project looks like in eclipse:

Above are the project contents. Next, 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:

Expand the > symbol on the right of Person under Schemas.

The nice thing is how the contract is automatically detailed leveraging JSR-303 annotations on the model. It out-of-the-box covers many of the important annotations and documents them. However, I do not see it support out of the box @javax.validation.constraints.Email and @org.hibernate.validator.constraints.CreditCardNumber at this point in time.

Nor does it also support our custom annotation which we applied on registrationDate field in Person java bean.

Java

@LocalDateTimeFormat(pattern = "yyyyMMdd",   dateTimeType=DateTimeType.Date, 
                     message = "Invalid dateTimeField Format. It Should be in yyyyMMdd format")
private String registrationDate;

However, these constraints are being applied by the back end.

For completeness, let's post a request. Press the Post button. Then Press the Try it out button that will appear. That will lead you to the below screen.

Press the blue execute button.

JSON

{
  "timestamp": "2021-08-23T16:27:39.950+00:00",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed for object='person'. Error count: 4",
  "errors": [
    {
      "codes": [
        "Pattern.person.email",
        "Pattern.email",
        "Pattern.java.lang.String",
        "Pattern"
      ],
      "arguments": [
        {
          "codes": [
            "person.email",
            "email"
          ],
          "arguments": null,
          "defaultMessage": "email",
          "code": "email"
        },
        [],
        {
          "defaultMessage": ".+@.+\\..+",
          "codes": [
            ".+@.+\\..+"
          ],
          "arguments": null
        }
      ],
      "defaultMessage": "Please provide a valid email address",
      "objectName": "person",
      "field": "email",
      "rejectedValue": "string",
      "bindingFailure": false,
      "code": "Pattern"
    },
    {
      "codes": [
        "LocalDateTimeFormat.person.registrationDate",
        "LocalDateTimeFormat.registrationDate",
        "LocalDateTimeFormat.java.lang.String",
        "LocalDateTimeFormat"
      ],
      "arguments": [
        {
          "codes": [
            "person.registrationDate",
            "registrationDate"
          ],
          "arguments": null,
          "defaultMessage": "registrationDate",
          "code": "registrationDate"
        },
        "Date",
        {
          "defaultMessage": "yyyyMMdd",
          "codes": [
            "yyyyMMdd"
          ],
          "arguments": null
        }
      ],
      "defaultMessage": "Invalid dateTimeField Format. It Should be in yyyyMMdd format",
      "objectName": "person",
      "field": "registrationDate",
      "rejectedValue": "string",
      "bindingFailure": false,
      "code": "LocalDateTimeFormat"
    },
    {
      "codes": [
        "Email.person.email1",
        "Email.email1",
        "Email.java.lang.String",
        "Email"
      ],
      "arguments": [
        {
          "codes": [
            "person.email1",
            "email1"
          ],
          "arguments": null,
          "defaultMessage": "email1",
          "code": "email1"
        },
        [],
        {
          "defaultMessage": ".*",
          "codes": [
            ".*"
          ],
          "arguments": null
        }
      ],
      "defaultMessage": "must be a well-formed email address",
      "objectName": "person",
      "field": "email1",
      "rejectedValue": "string",
      "bindingFailure": false,
      "code": "Email"
    },
    {
      "codes": [
        "CreditCardNumber.person.creditCardNumber",
        "CreditCardNumber.creditCardNumber",
        "CreditCardNumber.java.lang.String",
        "CreditCardNumber"
      ],
      "arguments": [
        {
          "codes": [
            "person.creditCardNumber",
            "creditCardNumber"
          ],
          "arguments": null,
          "defaultMessage": "creditCardNumber",
          "code": "creditCardNumber"
        },
        false
      ],
      "defaultMessage": "invalid credit card number",
      "objectName": "person",
      "field": "creditCardNumber",
      "rejectedValue": "string",
      "bindingFailure": false,
      "code": "CreditCardNumber"
    }
  ],
  "path": "/person"
}

It's obvious from the above json that these annotations did get applied by the back end:

@javax.validation.constraints.Email,

@org.hibernate.validator.constraints.CreditCardNumber

and @sample.customvalidationsLocalDateTimeFormat

Let's feed in a valid input:

JSON

{
  "id": 0,
  "firstName": "string",
  "lastName": "string",
  "email": "abc@abc.com",
  "email1": "abc@abc.com",
  "age": 20,
  "creditCardNumber": "4111111111111111",
  "registrationDate": "20211231"
}

Let's feed that valid input into the Request Body Section

On pressing the blue Execute button we see the below:

Code so far can be found in https://github.com/teq-niq/sample branch: custom-validators

Let's revisit our Objective:

New Objective: Wouldn't it be nice if we could extend swagger behavior and communicate via its automated documentation the information regarding these additional annotations and also custom validation annotations?

Let's work on exactly this now.

Let's add two new classes.

Java

package sample.config;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
class DateTimeFormatData {

    private String pattern;

    private String dateTimeType;

}

Java

package sample.config;

import java.lang.annotation.Annotation;
import java.util.Map;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.oas.models.media.Schema;
import sample.customvalidations.LocalDateTimeFormat;

import org.springframework.context.annotation.Configuration;

@Configuration
class CustomOpenApiValidator extends ModelResolver {

    private final Class[] handledValidations = { javax.validation.constraints.NotNull.class,
            javax.validation.constraints.NotBlank.class,
            javax.validation.constraints.NotEmpty.class,
            javax.validation.constraints.Min.class,
            javax.validation.constraints.Max.class,
            javax.validation.constraints.DecimalMin.class,
            javax.validation.constraints.DecimalMax.class,
            javax.validation.constraints.Pattern.class,
            javax.validation.constraints.Size.class };

    private final Package[] allowedPackages = { handledValidations[0].getPackage(),
            org.hibernate.validator.constraints.CreditCardNumber.class.getPackage(),
            LocalDateTimeFormat.class.getPackage() };

    public CustomOpenApiValidator(ObjectMapper mapper) {
        super(mapper);
    }

    @Override
    protected void applyBeanValidatorAnnotations(Schema property, Annotation[] annotations, Schema parent) {
        super.applyBeanValidatorAnnotations(property, annotations, parent);
        if (annotations != null) {
            for (Annotation annotation : annotations) {
                Class<? extends Annotation> annotationType = annotation.annotationType();
                boolean handled = false;
                for (Class check : handledValidations) {
                    if (annotationType == check) {
                        handled = true;
                        break;
                    }
                }
                if (!handled) {
                    Package annotationPackage = annotationType.getPackage();
                    boolean allowed = false;
                    for (Package allowedPackage : allowedPackages) {
                        if (allowedPackage == annotationPackage) {
                            allowed = true;
                            break;
                        }
                    }
                    if (allowed) {
                        Map extensions = property.getExtensions();
                        String extensionKey = "x-" + annotationType.getSimpleName();
                        if (!(extensions != null && extensions.containsKey(extensionKey))) {
                            Object value = describeAnnotation(annotation, annotationType);
                            property.addExtension(extensionKey, value);

                        }
                    }
                }
            }
        }

    }

    private Object describeAnnotation(Annotation annotation, Class<? extends Annotation> annotationType) {
        Object ret = true;
        if (annotationType == LocalDateTimeFormat.class) {
            LocalDateTimeFormat format = (LocalDateTimeFormat) annotation;
            ret = new DateTimeFormatData(format.pattern(), format.dateTimeType().name());

        }
        return ret;
    }
}

Please stop the application, then build and restart the application. Revisit the swagger ui at http://localhost:8080/swagger-ui.html. If you check, you will find that the schemas now show the highlighted extensions to convey additional constraints.

Also as shown below:

The code so far can be found in https://github.com/teq-niq/sample branch: documented-custom-validators.

Conclusion: We have shown how to meet our new objective. Demonstrated how to use swagger schema extensions and document the additional constraints which would have otherwise remained undocumented. And this includes custom validators. In the next part will try something on these same lines but stretch the limits a little more.

Troubleshooting Tips

  • Ensure prerequisites.
  • If using the Eclipse IDE, we might need to do a Maven update on the project (right click on project - Maven > Update 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.
  • Also see https://projectlombok.org/setup/overview for setting up lombok in IDE