Exposing a REST API for your Domain Services
Because writing YAML and dealing with boilerplate code is no fun...
In the following sections, we'll explore how to use ZenWave Domain Language (ZDL) as an Interface Definition Language (IDL) for OpenAPI, leveraging ZenWave SDK to generate complete OpenAPI v3 specifications from your domain models.
You can then use these generated OpenAPI definitions to build various aspects of your application using ZenWave API-First tooling.
ZenWave Domain Language as Interface Definition Language (IDL) for OpenAPI
Because writing YAML by hand is no fun... You can use ZDL models as Interface Definition Language (IDL) to generate OpenAPI v3 with ZenWave SDK ZDLToOpenAPIPlugin.
This approach allows you to:
- Define APIs using a compact, developer-friendly syntax
- Maintain consistency between your domain model and API contracts
- Consisten style between your different APIs: naming, pagination, status codes, error handling...
- Reduce manual YAML writing and potential errors
ZDL has evolved to support defining OpenAPI APIs almost completely, including file uploads and downloads, status codes, and more.
β οΈ Remember: Once generated, the OpenAPI definition becomes the source of truth for your REST API. ZDL serves as a starting point for rapid API design.
Decorating your Services with @rest
First step is to decorate your services with @rest
to indicate that they will be exposed via REST API and define the base path for all operations in this service.
@rest("/customers")service CustomerService for (Customer) {//...}
Now you can use ZDLToOpenAPIPlugin
to generate an OpenAPI definition from your ZDL model:
ZDLToOpenAPIPlugin in zenwave-scripts.zw
ZDLToOpenAPIPlugin {idType integeridTypeFormat int64targetFile "src/main/resources/public/apis/openapi.yml"}
Remember that this is equivalent to running:
ZDLToOpenAPIPlugin using JBang CLI
jbang zw -p ZDLToOpenAPIPlugin \zdlFile=zenwave-model.zdl \idType=integer \idTypeFormat=int64 \targetFolder=. \targetFile=src/main/resources/public/apis/openapi.yml

Decorating your Service Methods with @get
, @post
, @put
, @delete
, @patch
All @get
, @post
, @put
, @delete
, @patch
support the following syntax:
@get("/somepath/{somePathParam}")
: simpler form, shorthand for@get({path: "/somepath/{somePathParam}"})
@get({path: "/somepath", status: 200, params: {search: String}, operationId: "someOperationId"})
:path
: the path for this operation, relative to the service base path.status
: the default status code for this operation, this is usefull for@post
operations that return200
instead of the default201
.params
: a map of parameters and their types, they will match path params o query params if no path param was matched.operationId
: the operationId for this operation, by default it will use the service command name.
When using the ZDLToOpenAPIPlugin
, the following
decorated service:
@rest("/customers")service CustomerService for (Customer) {@postcreateCustomer(Customer) Customer withEvents CustomerEvent@get("/{id}")getCustomer(id) Customer?@put("/{id}")updateCustomer(id, Customer) Customer? withEvents CustomerEvent@delete("/{id}")deleteCustomer(id) withEvents CustomerDeletedEvent@post("/search")@paginatedsearchCustomers(CustomerSearchCriteria) Customer[]}
would produce this openapi definition:
src/main/resources/public/apis/openapi.yml
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
Using @get
As you would expect, @get
will generate a GET
operations, with path and query parameters defined in the path
and params
properties, with their corresponding data types. It will return an status code 200
by default, but you can change it using the status
property.
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@get("/{id}")getCustomer(id) Customer?
would produce this:
src/main/resources/public/apis/openapi.yml
/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"
If the method command has a command payload, it will be expanded as path or query parameters.
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@get({ path: "/{name}/patient/{patientId}/questions", params: { lang: String } })getSurveyAndQuestionsForPatient(SurveyByNameAndPatient) SurveyAndQuestions
would produce this:
src/main/resources/public/apis/surveys-public-openapi.yml
/public/surveys/{name}/patient/{patientId}/questions:get:operationId: getSurveyAndQuestionsForPatientdescription: "getSurveyAndQuestionsForPatient"tags: [Surveys]parameters:- name: "name"in: pathrequired: trueschema:type: string- name: "patientId"in: pathrequired: trueschema:type: integerformat: int64- name: "lang"in: queryschema:type: stringresponses:"200":description: "OK"content:application/json:schema:$ref: "#/components/schemas/SurveyAndQuestions"
Notice how path and query parameter data types are inferred:
name
path param is inferred fromSurveyByNameAndPatient.name
field typeString
.patientId
path param is inferred fromSurveyByNameAndPatient.patientId
field typeLong
.lang
is added as query parameter because it is not a path parameter.
Using @post
for Creating Resources
Posts are used to create new resources. The default status code is 201
, if you need to change it, because you are implementing a search operation for example, you can use the status
property.
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@postcreateCustomer(Customer) Customer withEvents CustomerEvent
would produce this:
src/main/resources/public/apis/openapi.yml
/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"
You can also use @post
to implement search operations, in this case you should use status: 200
to indicate that the operation returns a list of resources:
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@post(path: "/search", status: 200) @paginatedsearchUsers(SearchCriteria) User[]
would produce this:
src/main/resources/public/apis/openapi.yml
/users/search:post:operationId: "searchUsers"description: "searchUsers"tags:- "User"parameters:- $ref: "#/components/parameters/page"- $ref: "#/components/parameters/limit"- $ref: "#/components/parameters/sort"requestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/SearchCriteria"responses:"200":description: "OK"content:application/json:schema:$ref: "#/components/schemas/UserPaginated"
Using @put
for Updating Resources
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@put("/{id}")updateCustomer(id, Customer) Customer? withEvents CustomerEvent
would produce this:
src/main/resources/public/apis/openapi.yml
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"
Using @delete
for Deleting Resources
Delete operations would return a 204
status code by default, but you can change it using the status
property.
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@delete("/{id}")deleteCustomer(id) withEvents CustomerDeletedEvent
would produce this:
src/main/resources/public/apis/openapi.yml
delete:operationId: deleteCustomerdescription: "deleteCustomer"tags: [Customer]parameters:- name: "id"in: pathrequired: trueschema:type: integerformat: int64responses:"204":description: "OK"
Using @patch
for Partial Updates
When using the ZDLToOpenAPIPlugin
, the following
this service command:
@patch({ path: "/{surveyId}/patient/{patientId}/answers/{date}", params: { surveryId: Long } })updateSurveyAnswers(@natural id, SurveyAnswers) SurveyAnswers?
would produce this:
src/main/resources/public/apis/surveys-public-openapi.yml
patch:operationId: updateSurveyAnswersdescription: "updateSurveyAnswers"tags: [Surveys]parameters:- name: "surveyId"in: pathrequired: trueschema:type: integerformat: int64- name: "patientId"in: pathrequired: trueschema:type: integerformat: int64- name: "date"in: pathrequired: trueschema:type: stringformat: date- name: "surveryId"in: queryschema:type: integerformat: int64requestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/SurveyAnswersPatch"responses:"200":description: "OK"content:application/json:schema:$ref: "#/components/schemas/SurveyAnswers"
#/components/schemas/SurveyAnswersPatch
SurveyAnswersPatch:allOf:- $ref: "#/components/schemas/SurveyAnswers"
When configuring schemaMappings
in openapi-generator-maven-plugin
like this:
this service command:
<schemaMappings>SurveyAnswersPatch=java.util.Map</schemaMappings>
will generate a java method with Map input
as parameter:
src/main/resources/public/apis/surveys-public-openapi.yml
@Overridepublic ResponseEntity<SurveyAnswersDTO> updateSurveyAnswers(Long surveyId, Long patientId, LocalDate date, Long surveryId, Map input) {
Because Map input
contains only the properties that where actually send by the client, you can easily implement a partial update.
Returning Paginated Lists with @paginated
ZDLToOpenAPIPlugin
supports the following pagination style out of the box when using @paginated
annotation:
When using the ZDLToOpenAPIPlugin
, the following
this `@paginated` service command:
@post("/search")@paginatedsearchCustomers(CustomerSearchCriteria) Customer[]
would produce this:
src/main/resources/public/apis/openapi.yml
/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"
With the CustomerPaginated
schema defined as:
#/components/schemas/SurveyAnswersPatch
CustomerPaginated:allOf:- $ref: "#/components/schemas/Page"- x-business-entity-paginated: "Customer"- properties:content:type: "array"items:$ref: "#/components/schemas/Customer"
And the following components
definition:
Supporting Schemas for Pagination
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: string
File Uploads
ZDLToOpenAPIPlugin
supports generating OpenAPI definitions for file upload endpoints using the @fileupload("fieldName")
annotation being fieldName
the name of the field in the request body that contains the file data.
When using the ZDLToOpenAPIPlugin
, the following
this `@fileupload` service command:
@post({path: "/upload", status: 201})@fileupload("file")uploadDocument(DocumentInfo) DocumentInfo
would produce this:
src/main/resources/public/apis/openapi.yml
/documents/upload:post:operationId: uploadDocumentdescription: "uploadDocument"tags: [Document]requestBody:required: truecontent:multipart/form-data:schema:allOf:- type: objectproperties:file:type: stringformat: binary- $ref: "#/components/schemas/DocumentInfo"responses:"201":description: "OK"content:application/json:schema:$ref: "#/components/schemas/DocumentInfo"
And the corresponding SpringMVC Controller:
DocumentApiController.java#uploadDocument
@Overridepublic ResponseEntity<DocumentInfoDTO> uploadDocument(org.springframework.web.multipart.MultipartFile file,Long id,Integer version,String uuid,String fileName,String documentType,String contentType,List<String> tags,DocumentDataDTO documentData) {log.debug("REST request to uploadDocument: {}, {}, {}, {}, {}, {}, {}, {}, {}",file,id,version,uuid,fileName,documentType,contentType,tags,documentData);var input =mapper.asDocumentInfo(file, id, version, uuid, fileName, documentType, contentType, tags, documentData);var documentInfo = documentService.uploadDocument(input);DocumentInfoDTO responseDTO = mapper.asDocumentInfoDTO(documentInfo);return ResponseEntity.status(201).body(responseDTO);}
File Downloads
ZDLToOpenAPIPlugin
supports generating OpenAPI definitions for file upload endpoints using the @filedownload("javaFieldName")
annotation, being javaFieldName
the name of the field in java model that contains the binary data to be downloaded.
When using the ZDLToOpenAPIPlugin
, the following
this `@filedownload` service command:
@get({path: "/{id}", params: {preview: boolean} })@filedownload("documentData.data")downloadDocument(id) DocumentInfo
would produce this:
src/main/resources/public/apis/openapi.yml
/documents/{id}:get:operationId: downloadDocumentdescription: "downloadDocument"tags: [Document]parameters:- name: "id"in: pathrequired: trueschema:type: integerformat: int64- name: "preview"in: queryschema:type: booleanresponses:"200":description: "OK"headers:Content-Disposition:description: "Controls file download behavior. Values: 'inline' (display in browser) or 'attachment; filename=example.pdf' (download file)"schema:type: stringcontent:'*/*':schema:type: stringformat: binary
And the corresponding SpringMVC Controller:
DocumentApiController.java#uploadDocument
@Overridepublic ResponseEntity<Resource> downloadDocument(Long id, Boolean preview) {log.debug("REST request to downloadDocument: {}, {}", id, preview);var documentInfo = documentService.downloadDocument(id);byte[] bytes = null; // TODO get bytes from documentData.dataByteArrayResource resource = new ByteArrayResource(bytes);return ResponseEntity.status(200).header("Content-Disposition", "inline") // or attachment; filename=example.pdf.contentType(MediaType.APPLICATION_OCTET_STREAM) // TODO: set content type.body(resource);}