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
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.1description: "ZenWave Customer JPA Example"contact:email: email@domain.comservers:- description: localhosturl: http://localhost:8080/api- description: customurl: "{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: createCustomerdescription: "createCustomer"tags: [Customer]requestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/Customer"responses:"201":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Customer"/customers/{id}:get:operationId: getCustomerdescription: "getCustomer"tags: [Customer]parameters:- name: "id"in: pathrequired: trueschema:type: integerformat: int64responses:"200":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Customer"put:operationId: updateCustomerdescription: "updateCustomer"tags: [Customer]parameters:- name: "id"in: pathrequired: trueschema:type: integerformat: int64requestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/Customer"responses:"200":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Customer"delete:operationId: deleteCustomerdescription: "deleteCustomer"tags: [Customer]parameters:- name: "id"in: pathrequired: trueschema:type: integerformat: int64responses:"204":description: "OK"/customers/search:post:operationId: searchCustomersdescription: "searchCustomers"tags: [Customer]parameters:- $ref: "#/components/parameters/page"- $ref: "#/components/parameters/limit"- $ref: "#/components/parameters/sort"requestBody:required: truecontent: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"version:type: "integer"name:type: "string"maxLength: 254description: "Customer name"email:type: "string"maxLength: 254pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}"description: ""addresses:type: "array"items:$ref: "#/components/schemas/Address"minLength: 1maxLength: 5paymentMethods:type: "array"items:$ref: "#/components/schemas/PaymentMethod"CustomerPaginated:allOf:- $ref: "#/components/schemas/Page"- x-business-entity-paginated: "Customer"- 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: 254city:type: "string"maxLength: 254PaymentMethod:type: "object"x-business-entity: "PaymentMethod"required:- "type"- "cardNumber"properties:id:type: "integer"format: "int64"version:type: "integer"type:$ref: "#/components/schemas/PaymentMethodType"cardNumber:type: "string"Page:type: objectrequired:- "content"- "totalElements"- "totalPages"- "size"- "number"properties:number:type: integerminimum: 0numberOfElements:type: integerminimum: 0size:type: integerminimum: 0maximum: 200multipleOf: 25totalElements:type: integertotalPages:type: integerparameters:page:name: pagein: querydescription: The number of results pageschema:type: integerformat: int32default: 0limit:name: limitin: querydescription: The number of results in a single pageschema:type: integerformat: int32default: 20sort:name: sortin: querydescription: The number of results pageschema:type: arrayitems:type: stringsecuritySchemes:basicAuth: # <-- arbitrary name for the security schemetype: httpscheme: basicbearerAuth: # <-- arbitrary name for the security schemetype: httpscheme: bearerbearerFormat: JWT # optional, arbitrary value for documentation purposessecurity:- basicAuth: [] # <-- use the same name here- bearerAuth: [] # <-- use the same name here
This 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>7.10.0</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
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-compile
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
this zdl script:
OpenAPIControllersPlugin {openapiFile "src/main/resources/public/apis/openapi.yml"}
would produce this:
CustomerApiController.java
/*** REST controller for CustomerApi.*/@RestController@RequestMapping("/api")public class CustomerApiController implements CustomerApi {private final Logger log = LoggerFactory.getLogger(getClass());@Autowiredprivate NativeWebRequest request;private CustomerService customerService;@Autowiredpublic 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);}@Overridepublic 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);}@Overridepublic 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();}}@Overridepublic 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();}}@Overridepublic ResponseEntity<Void> deleteCustomer(Long id) {log.debug("REST request to deleteCustomer: {}", id);customerService.deleteCustomer(id);return ResponseEntity.status(204).build();}@Overridepublic 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());@BeforeEachvoid setUp() {context.reloadTestData();}@Testvoid 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
You can also generate API Tests for your REST APIs from your OpenAPI specifications.
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
zenwave-scripts.zw
SpringWebTestClientPlugin {openapiFile "src/main/resources/public/apis/openapi.yml"}
would produce this API test:
CustomerApiIntegrationTest.java (excerpt)
@Testvoid 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
zenwave-scripts.zw
SpringWebTestClientPlugin {openapiFile "src/main/resources/public/apis/openapi.yml"groupBy businessFlowbusinessFlowTestName CreateUpdateDeleteCustomerIntegrationTestoperationIds createCustomer,updateCustomer,deleteCustomer,getCustomer}
would produce this Business Flow API test:
CustomerApiIntegrationTest
package io.zenwave360.example.adapters.web;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;import static org.springframework.http.HttpMethod.*;/*** Business Flow Test for: createCustomer, getCustomer, updateCustomer, deleteCustomer.*/class CreateUpdateDeleteCustomerIntegrationTest extends BaseWebTestClientTest {/*** Business Flow Test for: createCustomer, getCustomer, updateCustomer, deleteCustomer.*/@Testvoid testCreateUpdateDeleteCustomerIntegrationTest() {// createCustomer: createCustomervar 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: getCustomervar 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: updateCustomerCustomerDTO 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: deleteCustomerwebTestClient.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
@ActiveProfiles("test")@DockerComposeInitializer.EnableDockerCompose@org.springframework.transaction.annotation.Transactionalpublic abstract class BaseWebTestClientTest {@Autowiredprotected WebApplicationContext context;protected WebTestClient webTestClient;@BeforeEachvoid 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
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
zenwave-scripts.zw
OpenAPIKaratePlugin {openapiFile "src/main/resources/public/apis/openapi.yml"}
would produce this API test:
CustomerApi.feature KarateDSL API Test
@openapi-file=src/main/resources/public/apis/openapi.ymlFeature: CustomerApiBackground:* url baseUrl# * def auth = { username: '', password: '' }* def authHeader = call read('classpath:karate-auth.js') auth* configure headers = authHeader || {}@operationId=createCustomerScenario: createCustomerGiven 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: searchCustomersGiven 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 validation
When using the OpenAPIKaratePlugin, the following
zenwave-scripts.zw
OpenAPIKaratePlugin {openapiFile "src/main/resources/public/apis/openapi.yml"groupBy businessFlowbusinessFlowTestName CreateUpdateDeleteCustomerKarateTestoperationIds createCustomer,updateCustomer,deleteCustomer,getCustomer}
would produce this Business Flow API test:
CreateUpdateDeleteCustomerKarateTest.feature KarateDSL Business Flow Test
@openapi-file=src/main/resources/public/apis/openapi.ymlFeature: CreateUpdateDeleteCustomerKarateTestBackground:* 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