API-First Design with OpenAPI Generator
APIs are contracts between services and their clients. These contracts are usually documented using an?interface description language?(IDL). Nowadays, the most popular IDL for RESTful interfaces is the?OpenAPI Specification.
Unfortunately, even in small projects, it often happens that some of the interfaces don’t match what’s actually implemented. To solve this problem,?we can adopt an “API-First Design” approach.
“An API-first approach involves developing APIs that are consistent and reusable, which can be accomplished by using an API description language to establish a contract for how the API is supposed to behave. Establishing a contract involves spending more time thinking about the design of an API. It also often involves additional planning and collaboration with the stakeholders providing feedback on the design of an API before any code is written.” —?Swagger
This approach can be summarized in three simple steps:
In this article, we’ll look at how?OpenAPI Generator?can help us enforce this approach when building Spring Boot applications.
Setting Up the Project
The OpenAPI Generator Plugin
A Maven plugin supports the OpenAPI generator project.
Add the following plugin in the?pom.xml?file:
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<!-- RELEASE_VERSION -->
<version>${openapi-generator-maven-plugin.version}</version>
<!-- /RELEASE_VERSION -->
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<!-- specify the openapi description file -->
<inputSpec>${project.basedir}/src/main/resources/openapi.yaml</inputSpec>
<!-- target to generate java server code -->
<generatorName>spring</generatorName>
<!-- pass any necessary config options -->
<configOptions>
<documentationProvider>springdoc</documentationProvider>
<modelPackage>org.company.model</modelPackage>
<apiPackage>org.company.api</apiPackage>
<openApiNullable>false</openApiNullable>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
You need to configure the?inputSpec?tag value with the full path to your?OpenAPI description file.
All the?plugin configuration parameters?are contained in the?configOptions?tag. Make sure you set the?modelPackage?and?apiPackage?tags with the package names in your project.
Dependencies
Models and APIs are generated using?SpringDoc, as well as?Bean Validation 2.0 (JSR 380).
In your?pom.xml?file, include the following dependencies:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Bean Validation API support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
</dependency>
First Step: Define and Design
Our plan is to create two endpoints for the?Orders Service API:
We use?OpenAPI Specification 3.0.3?in this tutorial. At the time I’m writing this article, most?Specification 3.0?features are supported by OpenAPI Generator. However,?Specification 3.1?will be supported shortly.
Below is an example of an?openapi.yml?file that you can use as a reference for creating your OpenAPI files.
领英推荐
openapi: 3.0.3
info:
title: Order Service API
version: 1.0.0
paths:
/orders:
post:
summary: creates an order
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderRequest'
responses:
201:
description: Order created.
400:
description: Malformed syntax of the request params.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ErrorDetails'
/orders/{id}:
get:
summary: Returns the order information
operationId: getOrder
parameters:
- in: path
name: id
allowEmptyValue: false
description: Order guid.
required: true
schema:
type: string
pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'
example: 'e06bf865-312c-4e2a-85c3-cc20db4a4c1d'
responses:
200:
description: Order details.
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
400:
description: Malformed syntax of the request params.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ErrorDetails'
404:
description: The requested resource doesn't exists or was removed.
components:
schemas:
OrderRequest:
description: Order placed from a consumer.
type: object
properties:
notes:
description: Notes from consumer.
type: string
maxLength: 1000
example: add mayonnaise
orderItems:
type: array
items:
$ref: '#/components/schemas/OrderItemRequest'
consumer:
$ref: '#/components/schemas/ConsumerRequest'
OrderItemRequest:
description: Item in the order.
type: object
properties:
name:
description: Item name.
type: string
minLength: 3
maxLength: 32
example: Royale with Cheese
quantity:
description: Item quantity.
type: integer
minimum: 1
maximum: 100
example: 2
ConsumerRequest:
description: Consumer information.
type: object
properties:
name:
description: Consumer name.
type: string
minLength: 5
maxLength: 64
example: Vincent Vega
address:
description: Consumer address.
type: string
minLength: 5
maxLength: 64
example: 1234 Big Kahuna St, Los Angeles CA
phone:
description: Consumer phone number.
type: string
minLength: 10
maxLength: 12
pattern: ^[+]?[0-9]*$
example: +1223334444
OrderResponse:
description: Order placed from a consumer.
type: object
properties:
id:
description: Order guid.
type: string
example: 'e06bf865-312c-4e2a-85c3-cc20db4a4c1d'
state:
description: Order state.
type: string
enum: [ 'APPROVAL_PENDING','APPROVED','REJECTED','CANCEL_PENDING','CANCELLED','REVISION_PENDING' ]
example: 'APPROVAL_PENDING'
notes:
description: Notes from consumer.
type: string
example: add mayonnaise
orderItems:
type: array
items:
$ref: '#/components/schemas/OrderItemResponse'
consumer:
$ref: '#/components/schemas/ConsumerResponse'
OrderItemResponse:
description: Item in the Order.
type: object
properties:
name:
description: Item name.
type: string
example: Royale with Cheese
quantity:
description: Item quantity.
type: integer
example: 2
ConsumerResponse:
description: Consumer information.
type: object
properties:
name:
description: Consumer name.
type: string
example: Vincent Vega
address:
description: Consumer address.
type: string
example: 123 Big Kahuna St, Los Angeles CA
phone:
description: Consumer phone number.
type: string
example: +1223334444
ErrorDetails:
type: object
properties:
code:
description: Application error code.
type: integer
nullable: false
example: 400
detail:
description: A short, summary of the problem type.
type: string
nullable: false
example: 'size must be between 10 and 12.'
field:
description: The field that caused the error.
type: string
example: 'consumer.phone'
value:
description: The value of the field that caused the error.
type: object
example: null
location:
description: The location of the field that caused the error.
type: string
enum: [ 'BODY','PATH','QUERY','HEADER' ]
example: 'BODY'
Second Step: Review with Stakeholders
Stakeholders need to validate the API definition once it has been created. To generate the API stub, compile the application with the command below.
$ mvn clean compile
Next, run the application.
$ mvn spring-boot:run
You can access the Swagger UI by opening the following URL in your browser:?https://localhost:8080/swagger-ui/index.html.
Third Step: Implement
As a next step, let’s implement the service in accordance with the definition.
OrderController
package org.company.rs;
import org.company.model.*;
import org.company.api.OrdersApi;
@RestController
public class OrderController implements OrdersApi {
private final OrderService service;
private final OrderControllerMapper mapper;
public OrderController(OrderService service, OrderControllerMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@Override
public ResponseEntity<Void> createOrder(OrderRequest orderRequest) {
final UUID id = service.createOrder(
mapper.orderRequestToOrder(orderRequest)
);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(id)
.toUri();
return ResponseEntity.created(location).build();
}
@Override
public ResponseEntity<OrderResponse> getOrder(String id) {
Order order = service.getOrder(
UUID.fromString(id)
);
return ResponseEntity.ok(
mapper.orderToOrderResponse(order)
);
}
}
Here, we have created the?OrdersController?class that implements the generated?org.company.api.OrdersApi?interface.
Additionally, we have imported?org.company.model.*, which includes all generated request and response objects.
ExceptionController
As mentioned earlier, OpenAPI Generator supports Bean Validation. Hence,?we can handle exceptions thrown by these validations and send descriptive error responses to clients.
package org.company.rs;
import org.company.rs.model.ErrorDetails;
@ControllerAdvice
public class ExceptionController {
@ExceptionHandler(BindException.class)
ResponseEntity<List<ErrorDetails>> handleBindException(BindException ex) {
List<ErrorDetails> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> {
ErrorDetails errorDetails = new ErrorDetails();
errorDetails.setCode(400);
errorDetails.setDetail(fieldError.getDefaultMessage());
errorDetails.setField(fieldError.getField());
errorDetails.setValue(fieldError.getRejectedValue());
errorDetails.setLocation(ErrorDetails.LocationEnum.BODY);
return errorDetails;
}).toList();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
@ExceptionHandler(ConstraintViolationException.class)
ResponseEntity<List<ErrorDetails>> handleConstraintViolationException(ConstraintViolationException ex) {
List<ErrorDetails> errors = ex.getConstraintViolations().stream()
.map(constraintViolation -> {
ErrorDetails errorDetails = new ErrorDetails();
errorDetails.setCode(400);
errorDetails.setDetail(constraintViolation.getMessage());
errorDetails.setField(constraintViolation.getPropertyPath().toString());
errorDetails.setValue(constraintViolation.getInvalidValue());
errorDetails.setLocation(ErrorDetails.LocationEnum.PATH);
return errorDetails;
}).toList();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
@ControllerAdvice?is an annotation that allows us to handle exceptions in one place across the entire application.
To handle errors on the client side, a handler method annotated with?@ExceptionHandler?is defined for?BindException.class?and?ConstraintValidationException.class.
Thanks for reading. I hope this was helpful!
The example code is available on?GitHub.
and I'm also curious :)
1 年also add these dependencies: <dependency> <groupId>javax.annotation</groupId> <artifactId>javax.annotation-api</artifactId> <version>${javax.annotation.javax.annotation-api.version}</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>${javax.servlet.javax.servlet-api.version}</version> <scope>provided</scope> </dependency>