API-First with OpenAPI
Section titled “API-First with OpenAPI”ZenWave SDK API-First tooling for OpenAPI
Whether you write your OpenAPI definition manually or generate it using ZenWave Domain Model as an Interface Definition Language (IDL), ZenWave SDK provides several tools to build different aspects of your application:
- Use OpenAPI Generator Maven plugin to generate SpringMVC Controller Interfaces and DTOs from OpenAPI.
- Use OpenAPIControllersPlugin to generate SpringMVC Controllers (skeletons) implementing OpenAPI Controller Interfaces.
- Use SpringWebTestClientPlugin to generate Spring WebTestClient Tests for your SpringMVC Controllers.
- Use OpenAPIKaratePlugin to generate KarateDSL API Tests for your REST APIs from your OpenAPI specifications.
You can also:
- Debug Step-by-Step KarateDSL Tests with sister project ZenWave KarateIDE VSCode extension
- Stateful Mocks with KarateDSL and ZenWave APIMock (Deprecated)
- Reverse Engineering ZDL/JDL Models from OpenAPI schemas
Using OpenAPI Generator Maven Plugin
Section titled “Using OpenAPI Generator Maven Plugin”The following examples are based on Customer Service JPA Example Project and its OpenAPI definition:
OpenAPI definition for Customer Service JPA Example
openapi: 3.0.1info: title: "ZenWave Customer JPA Example" version: 0.0.1 description: "ZenWave Customer JPA Example" contact: email: email@domain.comservers: - description: localhost url: http://localhost:8080/api - description: custom url: "{protocol}://{server}/{path}" variables: protocol: enum: ['http', 'https'] default: 'http' server: default: 'localhost:8080' path: default: 'api'tags: - name: "Default" - name: "Customer"
paths: /customers: post: operationId: createCustomer description: "createCustomer" tags: [Customer] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Customer" responses: "201": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Customer" /customers/{id}: get: operationId: getCustomer description: "getCustomer" tags: [Customer] parameters: - name: "id" in: path required: true schema: type: integer format: int64 responses: "200": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Customer" put: operationId: updateCustomer description: "updateCustomer" tags: [Customer] parameters: - name: "id" in: path required: true schema: type: integer format: int64 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Customer" responses: "200": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Customer" delete: operationId: deleteCustomer description: "deleteCustomer" tags: [Customer] parameters: - name: "id" in: path required: true schema: type: integer format: int64 responses: "204": description: "OK" /customers/search: post: operationId: searchCustomers description: "searchCustomers" tags: [Customer] parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/sort" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CustomerSearchCriteria" responses: "200": description: "OK" content: application/json: schema: $ref: "#/components/schemas/CustomerPaginated"
components: schemas: Customer: type: "object" x-business-entity: "Customer" required: - "name" - "email" properties: id: type: "integer" format: "int64" readOnly: true version: type: "integer" default: "null" description: "Version of the document (required in PUT for concurrency control,\ \ should be null in POSTs)." name: type: "string" maxLength: 254 description: "Customer name" email: type: "string" maxLength: 254 pattern: "^[^@]+@[^\\s@]+\\.[^\\s@]+$" description: "" addresses: type: "array" items: $ref: "#/components/schemas/Address" minLength: 1 maxLength: 5 paymentMethods: type: "array" items: $ref: "#/components/schemas/PaymentMethod" CustomerPaginated: allOf: - $ref: "#/components/schemas/Page" - properties: content: type: "array" items: $ref: "#/components/schemas/Customer" CustomerSearchCriteria: type: "object" x-business-entity: "CustomerSearchCriteria" properties: name: type: "string" email: type: "string" city: type: "string" state: type: "string" PaymentMethodType: type: "string" x-business-entity: "PaymentMethodType" enum: - "VISA" - "MASTERCARD" Address: type: "object" x-business-entity: "Address" required: - "street" - "city" properties: street: type: "string" maxLength: 254 city: type: "string" maxLength: 254 PaymentMethod: type: "object" x-business-entity: "PaymentMethod" required: - "type" - "cardNumber" properties: id: type: "integer" format: "int64" readOnly: true version: type: "integer" default: "null" description: "Version of the document (required in PUT for concurrency control,\ \ should be null in POSTs)." type: $ref: "#/components/schemas/PaymentMethodType" cardNumber: type: "string"
Page: type: object required: - "content" - "totalElements" - "totalPages" - "size" - "number" properties: number: type: integer minimum: 0 numberOfElements: type: integer minimum: 0 size: type: integer minimum: 0 maximum: 200 multipleOf: 25 totalElements: type: integer totalPages: type: integer
parameters: page: name: page in: query description: The number of results page schema: type: integer format: int32 default: 0 limit: name: limit in: query description: The number of results in a single page schema: type: integer format: int32 default: 20 sort: name: sort in: query description: Sort results by field name and direction (asc or desc) schema: type: array items: type: string
securitySchemes: basicAuth: # <-- arbitrary name for the security scheme type: http scheme: basic bearerAuth: # <-- arbitrary name for the security scheme type: http scheme: bearer bearerFormat: JWT # optional, arbitrary value for documentation purposessecurity: - basicAuth: [] # <-- use the same name here - bearerAuth: [] # <-- use the same name hereThis is how you can configure openapi-generator-maven-plugin in your pom.xml to make it compatible with OpenAPIControllersPlugin:
openapi-generator-maven-plugin in pom.xml
<plugin> <groupId>org.openapitools</groupId> <artifactId>openapi-generator-maven-plugin</artifactId> <version>${openapi-generator-maven-plugin.version}</version> <executions> <execution> <goals> <goal>generate</goal> </goals> <phase>generate-sources</phase> <configuration> <inputSpec>${project.basedir}/src/main/resources/public/apis/openapi.yml</inputSpec> <skipIfSpecIsUnchanged>true</skipIfSpecIsUnchanged> <generatorName>spring</generatorName> <apiPackage>${openApiApiPackage}</apiPackage> <modelPackage>${openApiModelPackage}</modelPackage> <modelNameSuffix>DTO</modelNameSuffix> <addCompileSourceRoot>true</addCompileSourceRoot> <generateSupportingFiles>false</generateSupportingFiles> <typeMappings> <typeMapping>Double=java.math.BigDecimal</typeMapping> </typeMappings> <configOptions> <useSpringBoot3>true</useSpringBoot3> <documentationProvider>none</documentationProvider> <openApiNullable>false</openApiNullable> <useOptional>false</useOptional> <useTags>true</useTags> <interfaceOnly>true</interfaceOnly> <skipDefaultInterface>true</skipDefaultInterface> <delegatePattern>false</delegatePattern> <sortParamsByRequiredFlag>false</sortParamsByRequiredFlag> </configOptions> </configuration> </execution> </executions> </plugin>Important configuration properties for compatibility with OpenAPIControllersPlugin:
<useSpringBoot3>true</useSpringBoot3>use SpringBoot 3 and jakarta annotations<openApiNullable>false</openApiNullable>we use java.util.Optional instead<useOptional>false</useOptional>do not use java.util.Optional for parameters<useTags>true</useTags>required for grouping the operations in services by tag<sortParamsByRequiredFlag>false</sortParamsByRequiredFlag>required for matching path params in generated code
Generating SpringMVC Controller Interfaces and DTOs from OpenAPI yaml
Section titled “Generating SpringMVC Controller Interfaces and DTOs from OpenAPI yaml”Previous openapi-generator-maven-plugin will generate as part of the maven build the following java code:
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/CustomerApi.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/CustomerDTO.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/AddressDTO.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/PaymentMethodDTO.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/PaymentMethodTypeDTO.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/CustomerSearchCriteriaDTO.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/CustomerPaginatedDTO.java
- target/generated-sources/openapi/src/main/java/io/zenwave360/example/adapters/web/model/PageDTO.java
These classes are part of your project compilee sources and will be compiled and packaged as part of your application. In the next section we will be implementing CustomerApi.java generated interface.
Do not edit this code by hand, it will be overwritten by the maven build. You will need to edit openapi.yml definition instead and then run maven build again, for instance:
mvn clean test-compileGenerating SpringMVC Controller Implementations from OpenAPI yaml
Section titled “Generating SpringMVC Controller Implementations from OpenAPI yaml”While openapi-generator-maven-plugin can generate SpringMVC annotated Controller Interfaces and their DTOs, you will need to implement the interface methods yourself.
Thankfully, ZenWave SDK provides a plugin to generate SpringMVC Controller Implementations from OpenAPI yaml.
With OpenAPIControllersPlugin you can generate SpringMVC Controller Implementations from OpenAPI yaml.
When using the `OpenAPIControllersPlugin`, the following
would produce this:
CustomerApiController.java
@RestController@RequestMapping("/api")public class CustomerApiController implements CustomerApi {
private final Logger log = LoggerFactory.getLogger(getClass());
@Autowired private NativeWebRequest request;
private CustomerService customerService;
@Autowired public CustomerApiController setCustomerService(CustomerService customerService) { this.customerService = customerService; return this; }
private CustomerDTOsMapper mapper = CustomerDTOsMapper.INSTANCE;
public CustomerApiController(CustomerService customerService) {
this.customerService = customerService; }
public Optional<NativeWebRequest> getRequest() { return Optional.ofNullable(request); }
@Override public ResponseEntity<CustomerDTO> createCustomer(CustomerDTO reqBody) { log.debug("REST request to createCustomer: {}", reqBody); var input = mapper.asCustomer(reqBody); var customer = customerService.createCustomer(input); CustomerDTO responseDTO = mapper.asCustomerDTO(customer); return ResponseEntity.status(201).body(responseDTO); }
@Override public ResponseEntity<CustomerDTO> getCustomer(Long id) { log.debug("REST request to getCustomer: {}", id); var customer = customerService.getCustomer(id); if (customer.isPresent()) { CustomerDTO responseDTO = mapper.asCustomerDTO(customer.get()); return ResponseEntity.status(200).body(responseDTO); } else { return ResponseEntity.notFound().build(); } }
@Override public ResponseEntity<CustomerDTO> updateCustomer(Long id, CustomerDTO reqBody) { log.debug("REST request to updateCustomer: {}, {}", id, reqBody); var input = mapper.asCustomer(reqBody); var customer = customerService.updateCustomer(id, input); if (customer.isPresent()) { CustomerDTO responseDTO = mapper.asCustomerDTO(customer.get()); return ResponseEntity.status(200).body(responseDTO); } else { return ResponseEntity.notFound().build(); } }
@Override public ResponseEntity<Void> deleteCustomer(Long id) { log.debug("REST request to deleteCustomer: {}", id); customerService.deleteCustomer(id); return ResponseEntity.status(204).build(); }
@Override public ResponseEntity<CustomerPaginatedDTO> searchCustomers( Integer page, Integer limit, List<String> sort, CustomerSearchCriteriaDTO reqBody) { log.debug("REST request to searchCustomers: {}, {}, {}, {}", page, limit, sort, reqBody); var input = mapper.asCustomerSearchCriteria(reqBody); var customerPage = customerService.searchCustomers(input, pageOf(page, limit, sort)); var responseDTO = mapper.asCustomerPaginatedDTO(customerPage); return ResponseEntity.status(200).body(responseDTO); }
protected Pageable pageOf(Integer page, Integer limit, List<String> sort) { Sort sortOrder = sort != null ? Sort.by(sort.stream() .map(sortParam -> { String[] parts = sortParam.split(":"); String property = parts[0]; Sort.Direction direction = parts.length > 1 ? Sort.Direction.fromString(parts[1]) : Sort.Direction.ASC; return new Sort.Order(direction, property); }) .toList()) : Sort.unsorted(); return PageRequest.of(page != null ? page : 0, limit != null ? limit : 10, sortOrder); }}And Unit Tests for your Controller:
CustomerApiControllerTest.java (excerpt)
/** Test controller for CustomerApiController. */class CustomerApiControllerTest {
private final Logger log = LoggerFactory.getLogger(getClass());
ServicesInMemoryConfig context = new ServicesInMemoryConfig();
CustomerApiController controller = new CustomerApiController(context.customerService());
@BeforeEach void setUp() { context.reloadTestData(); }
@Test void createCustomerTest() { CustomerDTO reqBody = new CustomerDTO(); reqBody.setName("John Doe"); reqBody.setEmail("john.doe@example.com"); reqBody.setAddresses(List.of(new AddressDTO("Anytown", "123 Main St"))); reqBody.setPaymentMethods(List.of(new PaymentMethodDTO(PaymentMethodTypeDTO.VISA, "1234567890123456"))); var response = controller.createCustomer(reqBody); Assertions.assertEquals(201, response.getStatusCode().value()); }Testing REST APIs
Section titled “Testing REST APIs”You can also generate API Tests for your REST APIs from your OpenAPI specifications.
Generating Spring WebTestClient Tests from OpenAPI yaml
Section titled “Generating Spring WebTestClient Tests from OpenAPI yaml”WithWebTestClient you can test your REST API via HTTP semantics while in the same JVM process as your application.
ZenWaveSpringWebTestClientPlugin can generate both single endpoint tests as well as business flows spanning multiple endpoints:
When using the `SpringWebTestClientPlugin`, the following
would produce this API test:
CustomerApiIntegrationTest.java (excerpt)
@Test void testCreateCustomer_201() { var requestBody = """ { "email": "jane.doe@example.com", "name": "Jane Doe", "addresses": [ { "city": "Othertown", "street": "456 Elm St" } ], "paymentMethods": [ { "type": "VISA", "cardNumber": "6543210987654321" } ] } """;
webTestClient .method(POST) .uri("/api/customers") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .bodyValue(requestBody) .exchange() .expectStatus() .isEqualTo(201) .expectHeader() .contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.id") .isNotEmpty() .jsonPath("$.version") .isNotEmpty() .jsonPath("$.name") .isNotEmpty() .jsonPath("$.email") .isNotEmpty() .jsonPath("$.addresses") .isNotEmpty() .jsonPath("$.addresses") .isArray() .jsonPath("$.addresses[0].street") .isNotEmpty() .jsonPath("$.addresses[0].city") .isNotEmpty() .jsonPath("$.paymentMethods") .isNotEmpty() .jsonPath("$.paymentMethods") .isArray() .jsonPath("$.paymentMethods[0].id") .isNotEmpty() .jsonPath("$.paymentMethods[0].version") .isNotEmpty() .jsonPath("$.paymentMethods[0].type") .isNotEmpty() .jsonPath("$.paymentMethods[0].cardNumber") .isNotEmpty(); }When using the `SpringWebTestClientPlugin`, the following
would produce this Business Flow API test:
CustomerApiIntegrationTest
package io.zenwave360.example.adapters.web;
import static org.springframework.http.HttpMethod.*;
import io.zenwave360.example.adapters.web.model.CustomerDTO;import io.zenwave360.example.adapters.web.model.PaymentMethodTypeDTO;import org.junit.jupiter.api.Test;import org.springframework.http.MediaType;
/** * Business Flow Test for: createCustomer, getCustomer, updateCustomer, deleteCustomer. */class CreateUpdateDeleteCustomerIntegrationTest extends BaseWebTestClientTest {
/** * Business Flow Test for: createCustomer, getCustomer, updateCustomer, deleteCustomer. */ @Test void testCreateUpdateDeleteCustomerIntegrationTest() { // createCustomer: createCustomer var customerRequestBody0 = """ { "email": "jane.doe@example.com", "name": "Jane Doe", "addresses": [ { "city": "Othertown", "street": "456 Elm St" } ], "paymentMethods": [ { "type": "VISA", "cardNumber": "6543210987654321" } ] } """;
var createCustomerResponse0 = webTestClient .method(POST) .uri("/api/customers") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .bodyValue(customerRequestBody0) .exchange() .expectStatus() .isEqualTo(201) .expectHeader() .contentType(MediaType.APPLICATION_JSON) .returnResult(CustomerDTO.class);
// getCustomer: getCustomer var id = createCustomerResponse0.getResponseBody().blockFirst().getId();
var getCustomerResponse1 = webTestClient .method(GET) .uri("/api/customers/{id}", id) .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isEqualTo(200) .expectHeader() .contentType(MediaType.APPLICATION_JSON) .returnResult(CustomerDTO.class);
// updateCustomer: updateCustomer CustomerDTO customerRequestBody2 = getCustomerResponse1.getResponseBody().blockFirst(); customerRequestBody2.setName("updated"); customerRequestBody2.setEmail("updated@email.com"); customerRequestBody2.getPaymentMethods().get(0).setType(PaymentMethodTypeDTO.VISA);
var updateCustomerResponse2 = webTestClient .method(PUT) .uri("/api/customers/{id}", id) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .bodyValue(customerRequestBody2) .exchange() .expectStatus() .isEqualTo(200) .expectHeader() .contentType(MediaType.APPLICATION_JSON) .returnResult(CustomerDTO.class);
// deleteCustomer: deleteCustomer
webTestClient .method(DELETE) .uri("/api/customers/{id}", id) .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isEqualTo(204);
// getCustomer: getCustomer (not found) webTestClient .method(GET) .uri("/api/customers/{id}", id) .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isEqualTo(404); }}You can control whether these are Unit or Integration Tests with @SpringBootTest in
BaseWebTestClientTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)@ActiveProfiles("test")@DockerComposeInitializer.EnableDockerCompose@org.springframework.transaction.annotation.Transactionalpublic abstract class BaseWebTestClientTest {
@Autowired protected WebApplicationContext context;
protected WebTestClient webTestClient;
@BeforeEach void setup() { this.webTestClient = MockMvcWebTestClient.bindToApplicationContext(this.context).build(); }}As always, it’s your responsibility as the developer to provide test data and parameters that match the initial state, whether you’re using a containerized database or an in-memory implementation.
Generating KarateDSL Tests from OpenAPI yaml
Section titled “Generating KarateDSL Tests from OpenAPI yaml”You can also generate KarateDSL Tests from OpenAPI yaml. Both single endpoint tests as well as business flows spanning multiple endpoints.
When using the `OpenAPIKaratePlugin`, the following
would produce this API test:
CustomerApi.feature KarateDSL API Test
@openapi-file=src/main/resources/public/apis/openapi.ymlFeature: CustomerApi
Background:* url baseUrl# * def auth = { username: '', password: '' }* def authHeader = call read('classpath:karate-auth.js') auth* configure headers = authHeader || {}
@operationId=createCustomerScenario: createCustomer
Given path '/customers'And request"""{ "name" : "name-iv8wf61cpxe13fa3j0", "email" : "kraig.hermann@gmail.com", "addresses" : [ { "street" : "street-wivpwm357mj4r1", "city" : "Connellyside" } ], "paymentMethods" : [ { "type" : "VISA", "cardNumber" : "cardNumber-ydvoeke77t3z" } ]}"""When method postThen status 201* def createCustomerResponse = response* def customerId = response.id# TODO: Add response validation
@operationId=getCustomerScenario: getCustomer
* def pathParams = { id: 53 }Given path '/customers/', pathParams.idWhen method getThen status 200* def getCustomerResponse = response* def customerId = response.id# TODO: Add response validation
@operationId=updateCustomerScenario: updateCustomer
* def pathParams = { id: 53 }Given path '/customers/', pathParams.idAnd request"""{ "name" : "name-t58rqm1n6zb", "email" : "reta.wiza@gmail.com", "addresses" : [ { "street" : "street-e2pn9ih8t7du14h93", "city" : "East Genesis" } ], "paymentMethods" : [ { "type" : "MASTERCARD", "cardNumber" : "cardNumber-qx3ag249vxzjgkh" } ]}"""When method putThen status 200* def updateCustomerResponse = response* def customerId = response.id# TODO: Add response validation
@operationId=deleteCustomerScenario: deleteCustomer
* def pathParams = { id: 89 }Given path '/customers/', pathParams.idWhen method deleteThen status 204* def deleteCustomerResponse = response# TODO: Add response validation
@operationId=searchCustomersScenario: searchCustomers
Given path '/customers/search'And def queryParams = { page: 71, limit: 20, sort: ['name:asc'] }And request"""{ "name" : "name-xrx9m9s1", "email" : "tyron.heidenreich@yahoo.co", "city" : "Bellamouth", "state" : "state-y1x18r2h"}"""When method postThen status 201* def searchCustomersResponse = response* def customerPaginatedId = response.id# TODO: Add response validationWhen using the `OpenAPIKaratePlugin`, the following
would produce this Business Flow API test:
CreateUpdateDeleteCustomerKarateTest.feature KarateDSL Business Flow Test
@openapi-file=src/main/resources/public/apis/openapi.ymlFeature: CreateUpdateDeleteCustomerKarateTest
Background:* url baseUrl# * def auth = { username: '', password: '' }* def authHeader = call read('classpath:karate-auth.js') auth* configure headers = authHeader || {}
@business-flow@operationId=createCustomer,updateCustomer,deleteCustomer,getCustomerScenario: CreateUpdateDeleteCustomerKarateTest
# createCustomerGiven path '/customers'And request"""{ "id" : 21, "version" : 13, "name" : "name-5ugq20772n1", "email" : "rosaria.hyatt@gmail.com", "addresses" : [ { "street" : "street-z", "city" : "West Edmundbury" } ], "paymentMethods" : [ { "id" : 37, "version" : 10, "type" : "MASTERCARD", "cardNumber" : "cardNumber-9c2xzm77ig5" } ]}"""When method postThen status 201* def createCustomerResponse = response* def customerId = response.id# TODO: Add response validation
# updateCustomer* def pathParams = { id: 2 }Given path '/customers/', pathParams.idAnd request"""{ "id" : 79, "version" : 7, "name" : "name-mu8j1eqcaoy2a7ex9uup1", "email" : "jerica.emmerich@hotmail.co", "addresses" : [ { "street" : "street-7ug0jz4c5ghumuz2yst", "city" : "Sydneychester" } ], "paymentMethods" : [ { "id" : 30, "version" : 82, "type" : "MASTERCARD", "cardNumber" : "cardNumber-f" } ]}"""When method putThen status 200* def updateCustomerResponse = response* def customerId = response.id# TODO: Add response validation
# deleteCustomer* def pathParams = { id: 42 }Given path '/customers/', pathParams.idWhen method deleteThen status 204* def deleteCustomerResponse = response# TODO: Add response validation
# getCustomer* def pathParams = { id: 71 }Given path '/customers/', pathParams.idWhen method getThen status 404* def getCustomerResponse = response* def customerId = response.id# TODO: Add response validation