OpenAPI: Extend Functionality of Generator Plugin Using Custom Mustache Templates
This review is focused on the basic concepts of the OpenAPI generator plugin and has some tips on how to use mustache templates.
Join the DZone community and get the full member experience.
Join For FreeOpenAPI Specification is a standard for documentation of HTTP REST services. You can try it using the Swagger editor. Based on specification files, code generators generate all model, server, and client classes required for development. This article is about the OpenAPI generator. It allows the generation of API client libraries (SDK generation), server stubs, documentation, and configuration automatically given an OpenAPI Spec. It’s available for several programming languages. I will use Java 17 and the OpenAPI generator of the 6.0.0 version to give an example of how to extend its functionality by amending mustache templates used by this code generator.
Quick Guide to Mustache
Before going further, let’s take a closer look at the mustache itself. It will help us amend current OpenAPI templates according to our needs.
Mustache calls itself a logic-less template. It is logical-less because it does not support cycles and if-then statements. It uses tags that you can replace with values from the context. By default, all tags use HTML escape when replacing. However, we can bypass this default behavior. You can find the detailed guide here.
So, a mustache might be very helpful when you need to generate some text with your own substitutions where it’s necessary. After that, you can use it as you wish. For example, you can create files with .java
extension and compile them.
Here is a simple mustache template test.mustache
with common cases.
test.mustache
!!!!!Print variable!!!!!
{{#title}} {{! add 'print title %title value%' to the output if title exists in context}}
{{=<% %>=}} <%! change delimiter to <% %>
print title <%title%> <%! here we substitute variable 'title' with its value from context%>
<%={{ }}=%> {{! change delimiter back to default}}
{{/title}}
{{^title}} {{! add 'no title ' to the output if title not exists in context}}
no title
{{/title}}
{{> templates/test-list.mustache }} {{! Include test-list.mustache file}}
{{> templates/show-context.mustache }} {{! Include show-context.mustache file}}
If you execute this template, providing all the required data, you will get something like this.
!!!!!Print variable!!!!!
print title mustache example
!!!!!Print list!!!!!
Let's show how to check variable existence
Let's show how lists are processed
!!!!!Print context!!!!!
{todos=[{message=show how to check variable existence}, {message=show how lists are processed }], title=mustache example}
{todos=[{message=show how to check variable existence}, {message=show how lists are processed }], title=mustache example}
So, the output is some text with substitutions from the context printed in that latest line.
{todos=[{message=show how to check variable existence}, {message=show how lists are processed }], title=mustache example}
Let’s go through some details.
Mustache uses tags.
To distinguish variables that we want to replace from the other text, it uses a tag {{ }}
by default. So, for example, when you write {{title}}
, you mean it’s a variable title
you want to substitute. The default tag can be changed using the tag {{= =}}
. It’s very similar to the first one, except for the equal sign. For example, {{=<% %>=}}
will change the tag for variables to <% %>
, so a title
variable in this case will look like this <%title%>
. To change it back, use the same approach <%={{ }}=%>
.
You can also include other templates in the current template. Use {{> }}
tag. For example, {{> test-list.mustache}}
. It will put the contents of test-list.mustache
file in test.mustache
from the place, it is stated. Take a look at test-list.mustache
test-list.mustache
!!!!!Print list!!!!!
{{#todos}} {{! print the list of values from context variable 'todos'}}
Let's {{message}}
{{/todos}} {{! if no 'todos' variable exists in context or this list is empty}}
{{^todos}}
No todos!
{{/todos}}
It provided the output that you already saw above.
!!!!!Print list!!!!!
Let's show how to check variable existence
Let's show how lists are processed
As you can guess from the name of the template, it substitutes the list of variables in your template. To start writing lists {{# }}
tag is used. To finish writing {{/ }}
is used. The substitution for text Let's {{message}}
will be implemented as many times as the number of elements in the variable todos
. According to context:
{title=mustache example, todos=[{message=show how to check variable existence}, {message=show how lists are processed }]}
There are two elements message=show how to check variable existence
and message=show how lists are processed
.
If todos
variable is null or empty nothing will be added to the output. This situation can also be handled using the tag {{^ }}
. It works like if (todos == null || todos.size() == 0)
, so when todos
variable is null or empty, you can add any text you need. The handler finishes with the tag {{/ }}
. For example:
{{^todos}}
No todos!
{{/todos}}
This template will print No todos!
if there is no todos
variable in context or if this variable is an empty list.
The same tags {{# }}
, {{^ }}
can be used to check if a variable is not null. For example,
{{#title}}
print title ‘{{title}}’
{{/title}}
{{^title}}
no title
{{/title}}
If title
= “variable exists” this template will provide the output “print title ‘variable exists’.”
If title
is null or is not part of the context, the output will be “no title.”
I always say context
in all previous examples. So, what is this? It’s the data that is used for substitutions. Mustache provides a special tag {{.}}
that substitutes all the data that was passed for this template for replacements. Here is the template below that prints the context.
show-context.mustache
!!!!!Print context!!!!!
{{.}} {{! add all context to the output}}
{{{.}}} {{! add all context to the output without HTML codes}}
{{.}}
prints data with HTML codes and {{{.}}}
substitutes data as is. Here is the output of the template.
!!!!!Print context!!!!!
{title=mustache example, todos=[{message=show how to check variable existence}, {message=show how lists are processed }]}
{title=mustache example, todos=[{message=show how to check variable existence}, {message=show how lists are processed }]}
I didn't use a code block to show the difference. In this example, HTML escape is applied for the character ‘=’
. HTML escapes work in the same way as tag {{ }}
. For example, {{title}}
with value key=test
will provide output key=test and {{{title}}}
for the same value will return key=test. = is a hex code and = is decimal HTML code for symbol ‘=’
.
If you want to try mustache by yourself and run the templates above, you can add the following dependency to your project.
<dependency>
<groupId>com.samskivert</groupId>
<artifactId>jmustache</artifactId>
<version>1.14</version>
</dependency>
I used the same library that OpenAPI uses in the 6.0.0 version. To execute a template, you need an instance of com.samskivert.mustache.Template
. Here is a code snippet.
import com.samskivert.mustache.Mustache;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.Map;
public class MustacheCompileService {
private final Mustache.Compiler compiler;
public MustacheCompileService() {
this.compiler = Mustache.compiler();
}
public String compileTemplate(final Map<String, Object> bundle, final String template) {
final var tmpl = compiler
.withLoader(
this::getTemplate
)
.compile(getTemplate(template));
return tmpl.execute(bundle);
}
private Reader getTemplate(final String name) {
return new InputStreamReader(
Thread.currentThread().getContextClassLoader().getResourceAsStream(name)
);
}
}
Put your templates in the classpath. I put them in src\main\resources\templates
. I used java.util.Map
as context. You can also use any POJO.
Here are some examples of the output.
System.out.println(
compiler.compileTemplate(
Map.of(
"title", "mustache example",
"todos",
List.of(
Map.of("message", "show how to check variable existence"),
Map.of("message", "show how lists are processed ")
)
),
"templates/test.mustache"
)
);
!!!!!Print variable!!!!!
print title mustache example
!!!!!Print list!!!!!
Let's show how=to check variable existence
Let's show how lists are processed
!!!!!Print context!!!!!
{todos=[{message=show how=to check variable existence}, {message=show how lists are processed }], title=mustache example}
{todos=[{message=show how=to check variable existence}, {message=show how lists are processed }], title=mustache example}
System.out.println(
compiler.compileTemplate(
Map.of(),
"templates/test.mustache"
)
);
!!!!!Print variable!!!!!
no title
!!!!!Print list!!!!!
No todos!
!!!!!Print context!!!!!
{}
{}
So, in the last example with empty context, you can ensure that all handlers for null and empty variables work correctly.
Usage of Mustache in OpenAPI Generator
I used version 6.0.0 of the OpenAPI generator in my examples. This version uses com.samskivert:jmustache:1.14
. You can find the code here.
The usage of mustache you can find in class org.openapitools.codegen.templating.MustacheEngineAdapter.
public String compileTemplate(TemplatingExecutor executor, Map<String, Object> bundle, String templateFile) throws IOException {
Template tmpl = compiler
.withLoader(name -> findTemplate(executor, name))
.defaultValue("")
.compile(executor.getFullTemplateContents(templateFile));
return tmpl.execute(bundle);
}
This method is very similar to the one I used above. So, all the things described previously work correctly here as well.
Templates are in src\main\resources
. For example, src\main\resources\Java
, src\main\resources\JavaSpring
. As you can guess from the names of directories, there are templates of classes for Java and String frameworks. After reading your .yaml
and .json
specification files with HTTP server methods descriptions OpenAPI generator generates required .java
classes from these templates, substituting required variables with the values from your .yaml
and .json
files.
Further, I’m going to use org.openapitools:openapi-generator-maven-plugin
plugin for Maven that implements OpenAPI code generation.
Custom Validation in Generated HTTP Server Methods
It’s a very common task to validate the input and output parameters of your HTTP methods. Some validations are simple; for example, it’s enough to check that the value is not null. However, you might have requirements to make more complex validations.
Hibernate validator based on Javax (Jakarta) validation API is one of the most suitable tools for such tasks. More details about the hibernate validator are here.
You can switch it on for the 6.0.0 version of the OpenAPI generator with vendorExtensions.x-constraints
. Extensions allow you to provide vendor-specific configurations to your specification document. You can read about vendor extensions here.
So, I’m going to use vendorExtensions.x-constraints
to generate code with Javax(Jakarta) validation annotations and custom annotations for HTTP method parameters. And here, we need to amend the default OpenAPI generator mustache template.
But first of all, let’s create a model to test our changes.
Generate Commons and Model
I am going to generate an HTTP method that should create an account report. The result of this method is a file with a report. All generated code is for Spring Framework.
Finally, OpenAPI will generate a Spring controller with request and response. I assume that there will be more methods in this specification later, so I will devote some common data to it. Besides, I will generate it before the server code is used vendorExtensions.x-constraints
properly. Later I will explain why I did it this way and it will be up to you to choose another way.
So, here are the commons.
common.yaml
openapi: 3.0.1
info:
title: API example documentation.
version: 0.1.0
paths: { }
components:
schemas:
AgreementType:
nullable: true
description: agreement type.
type: string
enum:
- 'loan'
- 'card'
x-enum-varnames:
- LOAN
- CARD
CardType:
description: card type.
type: string
nullable: true
enum:
- 'creditCard'
- 'debitCard'
x-enum-varnames:
- CREDIT
- DEBIT
AccountParameter:
type: object
required:
- name
properties:
name:
$ref: '#/components/schemas/Parameters'
value:
description: parameter value
type: string
Parameters:
type: string
nullable: true
x-constraints: "/*not null parameter name*/@NotNull(message = \"{notnull.account.param-name}\")"
enum:
- 'siebelId'
- 'processingId'
- 'status'
- 'id'
x-enum-varnames:
- SIEBEL_ID
- PROCESSING_ID
- STATUS
- ID
Almost all these classes are enums.
Here is the request response model.
account.yaml
GetReportAccountRequest:
type: object
required:
- payload
properties:
payload:
$ref: '#/AccountReportData'
GetReportAccountResponse:
type: object
properties:
file:
description: отчет.
type: string
AccountReportData:
x-constraints: "/*id, status, siebelId params are mandatory*/@org.example.validation.AccountParametersValidation"
type: object
required:
- agreementNumber
- agreementType
- accountNumber
- date
properties:
agreementNumber:
description: agreement number.
type: string
x-constraints: "/*not empty value*/@NotBlank(message = \"{notnull.account.agreement}\")"
agreementType:
description: agreement type.
type: AgreementType
x-constraints: "/*not null value*/@NotNull(message = \"{notnull.account.agreement.type}\")"
cardType:
description: card type.
type: CardType
accountNumber:
description: account number.
type: string
x-constraints: "/*not empty value*/@NotBlank(message = \"{notnull.account.number}\")"
date:
description: account date.
type: string
format: date
x-constraints: "/*not null value*/@NotNull(message = \"{notnull.account.date}\")"
parameters:
description: >
Parameters:
- id
- GUID
- mandatory, unique
- unique identifier.
- siebelId
- mandatory
- client siebel identifier
- processingId
- unique process identifier
- status
- mandatory
- process status. Possible values:
in progress
completes
interrupted
blocked
type: array
items:
type: AccountParameter
The request contains some fields like accountNumber
and list of parameters. Some parameters from this list are mandatory, and we have to verify it. I created a custom validator for this constraint and annotation @AccountParametersValidation
.
All validations in this model are added using the tag x-constraints
. I simply put the required annotation and comments. OpenAPI will add these annotations to the required fields when generating. That’s all I need. When executing this code, Spring will run the corresponding validator.
The common data type is used here using the tag type
, for example type:
AgreementType
. I will show later the OpenaAPI generator maven config where I put all type mappings. If I use a tag $ref
for agreemnetType
field, I would have to put x-constraints
in definition of AgreementType
, for example:
AgreementType:
x-constraints: "/*not null value*/@NotNull(message = \"{notnull.account.agreement.type}\")"
description: agreement type.
type: string
enum:
- 'loan'
- 'card'
x-enum-varnames:
- LOAN
- CARD
Because $ref
works by replacing itself and everything on its level with the definition it is pointing at. So, all other tags placed together $ref
will be ignored, including x-constraints
. However, I assume there will be methods in my Spring controller where agreemnetType
the field is not mandatory, and I should have the ability to add validation where I need to.
Before going further, I will show the rest of the API documentation files and custom validator implementation for @AccountParametersValidation
annotation.
Here is the HTTP method for account report generation:
account.api.yaml
generate:
post:
tags:
- Report
summary: Generate account report
operationId: generate
requestBody:
description: Request body of account report
required: true
content:
application/json:
schema:
$ref: './components/account/account.yaml#/GetReportAccountRequest'
responses:
200:
description: Ответ
content:
application/json:
schema:
$ref: './components/account/account.yaml#/GetReportAccountResponse'
401:
description: authorisation error
403:
description: access forbidden
404:
description: not found
500:
description: internal server error
And the final API documentation file for the OpenAPI generator.
api.yaml
openapi: 3.0.1
info:
title: api documentation
description: API example documentation
version: 0.1.0
tags:
- name: Report
description: generate report service
paths:
/v1/account:
$ref: './account.api.yaml#/generate'
If you like, you can put the contents of account.api.yaml
, account.yaml
in api.yaml
file.
Custom Validator Implementation
Here is @AccountParametersValidation
annotation.
AccountParametersValidation.class
package org.example.validation;
import org.example.server.api.model.GetReportAccountRequestHttp;
import org.example.validation.validator.AccountParametersValidator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Custom validation of {@link GetReportAccountRequestHttp}.
*/
@Documented
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Constraint(validatedBy = AccountParametersValidator.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface AccountParametersValidation {
/**
* Validation error message.
*
* @return message
*/
String message() default "mandatory fields error";
/**
* Validation groups.
*
* @return groups
*/
Class<?>[] groups() default {};
/**
* Metadata.
*
* @return payload
*/
Class<? extends Payload>[] payload() default {};
}
It requires AccountParametersValidator.class. Here it is.
AccountParametersValidator.class
package org.example.validation.validator;
import org.example.common.model.AccountParameter;
import org.example.common.model.Parameters;
import org.example.server.api.model.AccountReportDataHttp;
import org.example.validation.AccountParametersValidation;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static org.apache.commons.lang3.StringUtils.isBlank;
/**
* Custom validator for {@link AccountParametersValidation}.
*/
public class AccountParametersValidator
implements ConstraintValidator<AccountParametersValidation, AccountReportDataHttp> {
@Override
public boolean isValid(final AccountReportDataHttp payload, final ConstraintValidatorContext context) {
boolean hasNoChanges = true;
final var params = getParams(payload == null ? null : payload.getParameters());
if (isBlank(params.get(Parameters.ID))) {
addConstraintViolation("notnull.account.param.id", context);
hasNoChanges = false;
}
if (isBlank(params.get(Parameters.STATUS))) {
addConstraintViolation("notnull.account.param.status", context);
hasNoChanges = false;
}
if (isBlank(params.get(Parameters.SIEBEL_ID))) {
addConstraintViolation("notnull.account.param.siebel.id", context);
hasNoChanges = false;
}
return hasNoChanges;
}
void addConstraintViolation(String code, ConstraintValidatorContext context) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("{" + code + "}").addConstraintViolation();
}
private Map<Parameters, String> getParams(final List<AccountParameter> parameters) {
return defaultIfNull(parameters, Collections.<AccountParameter>emptyList()).stream()
.filter(param -> param != null && Objects.nonNull(param.getName()))
.collect(
Collectors.toMap(
AccountParameter::getName,
AccountParameter::getValue,
(oldValue, newValue) -> newValue,
HashMap::new
)
);
}
}
This class converts the parameter list to a map where the key is the parameter name, and the value is the parameter value and verifies that the values of mandatory parameters are not null or empty. All errors are put in ConstraintValidatorContext
.
So, now all preparations are finished, and it’s time to amend the default OpenAPI mustache templates to make x-constraints
tag works properly to add annotations from this tag to the generated code.
Customize Default OpenAPI Mustache Templates
I created a templates directory in the root folder of my project. I am going to put all my templates in this directory.
First of all, let’s enable vendorExtensions.x-constraints
. We need to amend beanValidationCore.mustache
template. You can take the actual template for the required version here. I use version 6.0.0 to generate Spring controllers, so the template I need is here.
To enable vendorExtensions.x-constraints
we need to add tag {{{ vendorExtensions.x-constraints }}}
.
beanValidationCore.mustache
{{{ vendorExtensions.x-constraints }}}
{{#pattern}}{{^isByteArray}}@Pattern(regexp = "{{{pattern}}}") {{/isByteArray}}{{/pattern}}{{!
minLength && maxLength set
}}{{#minLength}}{{#maxLength}}@Size(min = {{minLength}}, max = {{maxLength}}) {{/maxLength}}{{/minLength}}{{!
minLength set, maxLength not
}}{{#minLength}}{{^maxLength}}@Size(min = {{minLength}}) {{/maxLength}}{{/minLength}}{{!
minLength not set, maxLength set
}}{{^minLength}}{{#maxLength}}@Size(max = {{.}}) {{/maxLength}}{{/minLength}}{{!
@Size: minItems && maxItems set
}}{{#minItems}}{{#maxItems}}@Size(min = {{minItems}}, max = {{maxItems}}) {{/maxItems}}{{/minItems}}{{!
@Size: minItems set, maxItems not
}}{{#minItems}}{{^maxItems}}@Size(min = {{minItems}}) {{/maxItems}}{{/minItems}}{{!
@Size: minItems not set && maxItems set
}}{{^minItems}}{{#maxItems}}@Size(max = {{.}}) {{/maxItems}}{{/minItems}}{{!
@Email: useBeanValidation set && isEmail set
}}{{#useBeanValidation}}{{#isEmail}}@Email{{/isEmail}}{{/useBeanValidation}}{{!
check for integer or long / all others=decimal type with @Decimal*
isInteger set
}}{{#isInteger}}{{#minimum}}@Min({{.}}) {{/minimum}}{{#maximum}}@Max({{.}}) {{/maximum}}{{/isInteger}}{{!
isLong set
}}{{#isLong}}{{#minimum}}@Min({{.}}L) {{/minimum}}{{#maximum}}@Max({{.}}L) {{/maximum}}{{/isLong}}{{!
Not Integer, not Long => we have a decimal value!
}}{{^isInteger}}{{^isLong}}{{#minimum}}@DecimalMin({{#exclusiveMinimum}}value = {{/exclusiveMinimum}}"{{minimum}}"{{#exclusiveMinimum}}, inclusive = false{{/exclusiveMinimum}}) {{/minimum}}{{#maximum}}@DecimalMax({{#exclusiveMaximum}}value = {{/exclusiveMaximum}}"{{maximum}}"{{#exclusiveMaximum}}, inclusive = false{{/exclusiveMaximum}}) {{/maximum}}{{/isLong}}{{/isInteger}}
So, it’s done. vendorExtensions.x-constraints
is enabled. This is the way vendorExtensions
are enabled.
Maybe you noticed that in account.yaml
I used the canonical name @org.example.validation.AccountParametersValidation
. I did it so as not to add imports in generated model classes. However, we can do it. Model classes are generated based on model.mustache
template, so let’s add the required imports. The default template for my version is here.
model.mustache
package {{package}};
import java.net.URI;
import java.util.Objects;
{{#imports}}import {{import}};
{{/imports}}
{{#openApiNullable}}
import org.openapitools.jackson.nullable.JsonNullable;
{{/openApiNullable}}
{{#serializableModel}}
import java.io.Serializable;
{{/serializableModel}}
import java.time.OffsetDateTime;
{{#useBeanValidation}}
import org.example.validation.AccountParametersValidation;
{{#useJakartaEe}}
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
{{/useJakartaEe}}
{{^useJakartaEe}}
import javax.validation.Valid;
import javax.validation.constraints.*;
{{/useJakartaEe}}
{{/useBeanValidation}}
{{#performBeanValidation}}
import org.hibernate.validator.constraints.*;
{{/performBeanValidation}}
{{#jackson}}
{{#withXml}}
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
{{/withXml}}
{{/jackson}}
{{#swagger2AnnotationLibrary}}
import io.swagger.v3.oas.annotations.media.Schema;
{{/swagger2AnnotationLibrary}}
{{#withXml}}
import javax.xml.bind.annotation.*;
{{/withXml}}
{{^parent}}
{{#hateoas}}
import org.springframework.hateoas.RepresentationModel;
{{/hateoas}}
{{/parent}}
import java.util.*;
{{#useJakartaEe}}
import jakarta.annotation.Generated;
{{/useJakartaEe}}
{{^useJakartaEe}}
import javax.annotation.Generated;
{{/useJakartaEe}}
{{#models}}
{{#model}}
{{#isEnum}}
{{>enumOuterClass}}
{{/isEnum}}
{{^isEnum}}
{{#vendorExtensions.x-is-one-of-interface}}{{>oneof_interface}}{{/vendorExtensions.x-is-one-of-interface}}{{^vendorExtensions.x-is-one-of-interface}}{{>pojo}}{{/vendorExtensions.x-is-one-of-interface}}
{{/isEnum}}
{{/model}}
{{/models}}
If you pay attention, you notice that there is a special variable useBeanValidation
that can be used to enable/disable validation. You can also find imports for javax.validation.Valid
and javax.validation.constraints.*
. If useJakartaEe
variable is enabled, corresponding imports for Jakarta are used.
There is one more thing I’m going to change. It’s about required
property. For example,
Data:
type: object
required:
- name
properties:
name:
type: string
value:
type: string
When you generate code for the Data
object defined above OpenAPI will add @javax.validation.constraints.NotNull
annotation for getName()
method. However, I’m adding my own validation annotations using x-constraints
with my custom error message, like
Data:
type: object
required:
- name
properties:
name:
type: string
x-constraints: "@NotBlank(message = \"{notnull.name}\")"
value:
type: string
It turns out that OpenAPI will generate both @NotNull
, because I set the name as required, and my @NotBlank(message = "{notnull.name}")
. But I don’t need both; I need only @NotBlank(message = "{notnull.name}")
.
Well, you say let’s simply remove required
for name
filed, and it’s done. It works; however, if we make it this way, we miss the required flag in the documentation.
As you can see name
field is marked as required, and some people strongly ask to keep it this way.
And we can do it. I mean both our custom validation and required
flag. All we need is to change beanValidation.mustache
template. The default one for 6.0.0 is here. I changed it respectively.
beanValidation.mustache
{{#required}}
{{^isReadOnly}}
{{^vendorExtensions.x-constraints}}
@NotNull
{{/vendorExtensions.x-constraints}}
{{/isReadOnly}}
{{/required}}
{{#isContainer}}
{{^isPrimitiveType}}
{{^isEnum}}
@Valid
{{/isEnum}}
{{/isPrimitiveType}}
{{/isContainer}}
{{^isContainer}}
{{^isPrimitiveType}}
@Valid
{{/isPrimitiveType}}
{{/isContainer}}
{{>beanValidationCore}}
So, as you remember previously, in the section ‘Quick guide to mustache,’ we already discussed how to check if the specific variable is in the mustache context and how to add handlers for cases when it’s not there. In this case, I verify that if vendorExtensions.x-constraints
is enabled do not add @NotNull
annotation for fields marked as required. Meantime, the documentation will look the same:
Now it’s done.
So, we took some default OpenAPI mustache templates and changed them according to our needs.
The only thing left is to configure the generator plugin. As I mentioned before, I used the Maven project, so I have to configure the OpenAPI generator Maven plugin.
OpenAPI Generator Maven Plugin Configuration
The description and tutorial can be found here. This tutorial is for version 6.0.0, but you can choose any version you need. Some options are described here.
First of all, let’s generate common classes described in common.yaml
. Here is the config.
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>6.0.0</version>
<executions>
<execution>
<id>generate-common</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/common.yaml
</inputSpec>
<generatorName>spring</generatorName>
<modelPackage>org.example.common.model</modelPackage>
<generateApis>false</generateApis>
<generateSupportingFiles>false</generateSupportingFiles>
<configOptions>
<openApiNullable>false</openApiNullable>
<additionalModelTypeAnnotations>
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
</additionalModelTypeAnnotations>
</configOptions>
<templateDirectory>
${project.basedir}/templates
</templateDirectory>
</configuration>
</execution>
</executions>
</plugin>
According to this config, I’m going to generate model classes in the package org.example.common.model
from ${project.basedir}/common.yaml
. As was previously discussed, I decided to generate code for Spring Framework; that’s why generatorName=spring
is used, and it will use templates from src\main\resources\JavaSpring
. To generate commons, I could use java
generator as well; however, it may require additional dependencies in pom.xml
, so I keep spring
generator for commons as well.
As soon as I need to generate only the model generateApis=false
.
generateSupportingFiles=false
to avoid generating unnecessary classes.
openApiNullable=false
to avoid generating code with the usage of jackson-databind-nullable
library. This library will generate JsonNullable
objects for all properties marked as nullable: true
.
In common.yaml
I marked enums with this flag. So, if you discover this template, you will find a code:
@JsonCreator
public static {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} fromValue({{{dataType}}} value) {
for ({{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
if (b.value.equals(value)) {
return b;
}
}
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}throw new IllegalArgumentException("Unexpected value '" + value + "'");{{/isNullable}}
}
nullable
value is isNullable
variable value, so in case if nullable=true
@JsonCreator
will return null
if there is an incorrect enum value. This is exactly what I need because I use my own validation, and if I need the field of such an enum type to be not null, I will add the required annotation using x-constraints
with an appropriate error message.
I also added @lombok.Builder
, @lombok.NoArgsConstructor
, @lombok.AllArgsConstructor
lombok annotations to model classes to generate corresponding constructors and methods. This might be useful in converters or other application code. You can read about lombok here.
And finally templateDirectory
points to the directory with custom mustache templates. It is ${project.basedir}/templates
in my case.
If you run, mvn clean compile
you get generated code in target/generated-sources/openapi/src/main/java/org/example/common/model
. There should be 4 Java classes.
So, everything is ready to generate Spring controllers with requests and responses. My HTTP method generate
is described in api.yaml
. Here is the Maven plugin configuration.
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>6.0.0</version>
<executions>
<execution>
<id>generate-common</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/common.yaml
</inputSpec>
<generatorName>spring</generatorName>
<modelPackage>org.example.common.model</modelPackage>
<generateApis>false</generateApis>
<generateSupportingFiles>false</generateSupportingFiles>
<configOptions>
<openApiNullable>false</openApiNullable>
<additionalModelTypeAnnotations>
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
</additionalModelTypeAnnotations>
</configOptions>
<templateDirectory>
${project.basedir}/templates
</templateDirectory>
</configuration>
</execution>
<execution>
<id>generate-server</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/api.yaml
</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>org.example.server.api</apiPackage>
<modelPackage>org.example.server.api.model</modelPackage>
<generateApis>true</generateApis>
<generateSupportingFiles>false</generateSupportingFiles>
<languageSpecificPrimitives>CardType,AgreementType,AccountParameter</languageSpecificPrimitives>
<typeMappings>
<typeMapping>CardType=org.example.common.model.CardType</typeMapping>
<typeMapping>AgreementType=org.example.common.model.AgreementType</typeMapping>
<typeMapping>AccountParameter=org.example.common.model.AccountParameter</typeMapping>
</typeMappings>
<configOptions>
<openApiNullable>false</openApiNullable>
<useTags>true</useTags>
<interfaceOnly>true</interfaceOnly>
<skipDefaultInterface>true</skipDefaultInterface>
<additionalModelTypeAnnotations>
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
</additionalModelTypeAnnotations>
</configOptions>
<additionalProperties>
<additionalProperty>modelNameSuffix=Http</additionalProperty>
</additionalProperties>
<templateDirectory>
${project.basedir}/templates
</templateDirectory>
</configuration>
</execution>
</executions>
</plugin>
I added a task generate-server
to the already existing openapi-generator-maven-plugin
configuration. All definitions come from ${project.basedir}/api.yaml
file. generatorName
is still spring
. But this time generateApis=true
and String controllers are generated in org.example.server.api
package. Requests and responses will be in org.example.server.api.model
package. Besides, all requests and responses will get postfix Http
, for example AccountReportDataHttp.java
, as it’s configured within the tag additionalProperty
with the property name modelNameSuffix
.
generateSupportingFiles
is still false to avoid generating examples and other classes; however, at this time, we have to add skipDefaultInterface=true
to skip generation of default implementation for generated interfaces that use classes we skipped by setting generateSupportingFiles
to false.
interfaceOnly=true
says to generate only interfaces for Spring controllers. I will add implementation by myself.
useTags=true
will generate @io.swagger.v3.oas.annotations.tags.Tag
for controller interfaces. It helps to group several HTTP methods in OpenAPI documentation. For example,
corresponds to @Tag(name = "Report", description = "generate report service")
Pay attention to typeMappings
tag. We need these mappings to use classes generated by generate-common
task when generating ReportApi
Spring controller. Let’s take a look at account.yaml
, for example
cardType:
description: card type.
type: CardType
How OpenAPI generator plugin understand that CardType
is org.example.common.model.CardType
? It uses typeMappings
. All custom types are mentioned in languageSpecificPrimitives
and their mappings are in typeMappings
.
With all other settings, you are already familiar.
If you run mvn clean compile
you get generated code in target/generated-sources/openapi/src/main/java
. At this time, besides classes in org/example/common/model
, there will be one Java interface ReportApi.java
in org/example/server/api
and three Java classes in org/example/server/api/model
. Everything is as we configured.
Before testing generated code, I think it might be useful to provide all dependencies I used in my pom.xml
.
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<lombok.version>1.18.28</lombok.version>
<findbugs.annotations.version>3.0.1</findbugs.annotations.version>
<jackson-version>2.11.2</jackson-version>
<swagger-annotations.version>1.6.2</swagger-annotations.version>
<javax-validation.version>1.1.0.Final</javax-validation.version>
<spring-version>5.3.18</spring-version>
<jackson-version>2.11.2</jackson-version>
<javax-servlet.version>4.0.1</javax-servlet.version>
<javax-annotation.version>1.3.2</javax-annotation.version>
<javax-validation.version>2.0.1.Final</javax-validation.version>
<hibernate.validator.version>6.2.3.Final</hibernate.validator.version>
<springfox.core.version>3.0.0</springfox.core.version>
<findbugs.version>3.0.1</findbugs.version>
<swagger.annotations.version>2.2.0</swagger.annotations.version>
<commons.lang3.version>3.12.0</commons.lang3.version>
<openapi-generator-plugin.version>6.0.0</openapi-generator-plugin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons.lang3.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson-version}</version>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>${swagger.annotations.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>${javax-servlet.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson-version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson-version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson-version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson-version}</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>${javax-validation.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>${hibernate.validator.version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-core</artifactId>
<version>${springfox.core.version}</version>
</dependency>
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>${javax-annotation.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>annotations</artifactId>
<version>${findbugs.annotations.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>${findbugs.version}</version>
</dependency>
</dependencies>
Tests for Generated Code
To test the generated code, I will create a testing controller that implements the generated interface. It will be quite simple.
TestController.class
package org.example;
import org.example.server.api.ReportApi;
import org.example.server.api.model.GetReportAccountRequestHttp;
import org.example.server.api.model.GetReportAccountResponseHttp;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@RestController
class TestController implements ReportApi {
@Override
public ResponseEntity<GetReportAccountResponseHttp> generate(final @Valid @RequestBody GetReportAccountRequestHttp request) {
final var response = new GetReportAccountResponseHttp();
response.setFile(Base64.getEncoder().encodeToString("test report".getBytes(StandardCharsets.UTF_8)));
return ResponseEntity.ok(
response
);
}
}
I create a response entity with some data to simulate normal controller behavior.
I decided to use spring-test
libraries, but not the spring boot test. I think it’s useful to configure required beans that help to understand how validation works in Spring. So, let’s configure Spring to have validation work correctly.
Spring has its own framework for validation, so we need an adaptor to make Javax (Jarakta) validation work with Spring. And Spring provided such an adaptor it is org.springframework.validation.beanvalidation.LocalValidatorFactoryBean
.
jakarta.validation.Validator
comes to org.springframework.validation.Validator
through SpringValidatorAdapter
.
ConstraintValidator
comes to org.springframework.validation.Validator
using both SpringConstraintValidatorFactory
, which helps to add validators to IoC containers and HibernateValidator
, which implements ValidationProvider
, to create required ValidatorFactory
.
Besides, Spring uses Spring AOP for validation. AOP creates proxies for beans and adds validation interceptors where it is required. When starting the application context, Spring searches for classes marked with org.springframework.validation.annotation.Validated
and creates validators for arguments marked with javax.validation.Valid
. This job is done by org.springframework.validation.beanvalidation.MethodValidationPostProcessor
.
So, we need at least two beans in the IoC container to make validation work.
Besides, as far as you remember, I used custom messages in all validation annotations. However, to be more precisely, I used some codes, like
x-constraints: "/*not null value*/@NotNull(message = \"{notnull.account.agreement.type}\")"
But, actually, I want to see something like Account number shouldn't be null or empty
as an error message, not some code like notnull.account.agreement.type
.
So, we need a mapping between the code used in validation annotation and the error message. This mapping is configured for LocalValidatorFactoryBean
using org.springframework.context.MessageSource
.
If you decide to configure something else, you are welcome, I will stop at this point, and my test configuration for Javax (Jakarta) validation with Spring is
TestConfiguration.class
package org.example;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;
import java.nio.charset.StandardCharsets;
@Configuration
class TestConfiguration {
@Bean
MessageSource messageSource() {
final var messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:ValidationMessages.properties");
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());
return messageSource;
}
@Bean
LocalValidatorFactoryBean localValidatorFactoryBean(final MessageSource messages) {
final var bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messages);
return bean;
}
@Bean
MethodValidationPostProcessor validationPostProcessor() {
final var processor = new MethodValidationPostProcessor();
processor.setProxyTargetClass(true);
return processor;
}
}
and ValidationMessages.properties located in src\test\resources
ValidationMessages.properties
# suppress inspection "UnusedProperty" for whole file
notnull.account.number = Account number shouldn't be null or empty
notnull.account.agreement = Account agreement shouldn't be null or empty
notnull.account.date = Account date shouldn't be null or empty
notnull.account.agreement.type = Account agreement type shouldn't be null or empty
notnull.account.param-name = Parameter name shouldn't be null or empty
notnull.account.param.id = Id account param shouldn't be null or empty
notnull.account.param.status = Status account param shouldn't be null or empty
notnull.account.param.siebel.id = SiebelId account param shouldn't be null or empty
So, here is the test.
SimpleTest.class
package org.example;
import org.assertj.core.api.Assertions;
import org.example.common.model.AccountParameter;
import org.example.common.model.Parameters;
import org.example.server.api.model.AccountReportDataHttp;
import org.example.server.api.model.GetReportAccountRequestHttp;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ExtendWith({SpringExtension.class})
@ContextConfiguration(
classes = {
TestController.class, TestConfiguration.class
}
)
class SimpleTest {
@Autowired TestController controller;
@ParameterizedTest
@MethodSource("testData")
void test(final GetReportAccountRequestHttp request, final List<String> violations) {
Assertions.assertThatThrownBy(
() -> controller.generate(request)
)
.isExactlyInstanceOf(ConstraintViolationException.class)
.extracting(e -> ((ConstraintViolationException) e).getConstraintViolations())
.extracting(v -> v.stream().map(ConstraintViolation::getMessage).collect(Collectors.toList()))
.usingRecursiveComparison()
.ignoringCollectionOrder()
.isEqualTo(violations);
}
static Stream<Arguments> testData() {
return Stream.of(
Arguments.arguments(
new GetReportAccountRequestHttp(),
List.of(
"Id account param shouldn't be null or empty",
"SiebelId account param shouldn't be null or empty",
"Status account param shouldn't be null or empty"
)
),
Arguments.arguments(
new GetReportAccountRequestHttp().payload(new AccountReportDataHttp()),
List.of(
"Id account param shouldn't be null or empty",
"SiebelId account param shouldn't be null or empty",
"Status account param shouldn't be null or empty",
"Account agreement type shouldn't be null or empty",
"Account date shouldn't be null or empty",
"Account number shouldn't be null or empty ",
"Account agreement shouldn't be null or empty"
)
),
Arguments.arguments(
new GetReportAccountRequestHttp()
.payload(
new AccountReportDataHttp().addParametersItem(AccountParameter.builder().build())
),
List.of(
"Parameter name shouldn't be null or empty",
"Id account param shouldn't be null or empty",
"SiebelId account param shouldn't be null or empty",
"Status account param shouldn't be null or empty",
"Account agreement type shouldn't be null or empty",
"Account date shouldn't be null or empty",
"Account number shouldn't be null or empty ",
"Account agreement shouldn't be null or empty"
)
),
Arguments.arguments(
new GetReportAccountRequestHttp()
.payload(
new AccountReportDataHttp()
.addParametersItem(
AccountParameter.builder()
.name(Parameters.ID)
.value("2a188887-dfb9-4282-9b8f-388814208113")
.build()
)
.addParametersItem(
AccountParameter.builder()
.name(Parameters.STATUS)
.value("OPENED")
.build()
)
.addParametersItem(
AccountParameter.builder()
.name(Parameters.SIEBEL_ID)
.value("76868247")
.build()
)
.agreementNumber("123712367868")
.accountNumber("407028101261786384768234")
.date(LocalDate.now())
),
List.of(
"Account agreement type shouldn't be null or empty"
)
)
);
}
}
In every test, we get ConstraintViolationException
exception, because GetReportAccountRequestHttp
is not completely filled. ConstraintViolationException
contains a set of violations, and we can verify these are exactly those violations we configured in api.yaml
and common.yaml
. So, everything works fine, which means we generated the code correctly.
Before finishing, I think it’s reasonable to provide test dependencies as well.
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>${spring-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.20</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.el</groupId>
<artifactId>javax.el-api</artifactId>
<version>3.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>3.0.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
Conclusion
Finally, we generated the HTTP server code using the open API generator. We found out that it generates classes using mustache templates, and we can extend or amend its functionality by changing these templates. Besides, we can use vendor extensions. From version to version, new features are added, and some features are deprecated; however, the basic concept remains the same, at least up to the current version, so you can take all approaches discussed above.
If you want to try examples from this article by yourself use the same versions, in this case everything will work fine without any efforts.
Enjoy your OpenAPI generator!
Opinions expressed by DZone contributors are their own.
Comments