API-First Design with OpenAPI Generator
Images by OpenAPI Generator and Spring

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:

  • First step: Define and design the API interface.
  • Second step: Review the definition with clients and API stakeholders.
  • Third step: Implement the service.

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:

  • POST /orders — creates an order.
  • GET /orders/:id — returns the order information.

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: http://localhost:8080/swagger-ui/index.html.

No alt text provided for this image

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.

Adrian Vrabie

and I'm also curious :)

1y

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>

To view or add a comment, sign in

More articles by Jonathan Manera

Insights from the community

Others also viewed

Explore topics