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 (πŸ‘‡ view source)
ZDLToOpenAPIPlugin {
idType integer
idTypeFormat int64
targetFile "src/main/resources/public/apis/openapi.yml"
}
πŸ”—Navigate to source

Remember that this is equivalent to running:

ZDLToOpenAPIPlugin using JBang CLI (πŸ‘‡ view source)
jbang zw -p ZDLToOpenAPIPlugin \
zdlFile=zenwave-model.zdl \
idType=integer \
idTypeFormat=int64 \
targetFolder=. \
targetFile=src/main/resources/public/apis/openapi.yml
πŸ”—Navigate to source
OpenAPI Customer Service Outline

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 return 200 instead of the default 201.
    • 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: (πŸ‘‡ view source)
@rest("/customers")
service CustomerService for (Customer) {
@post
createCustomer(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")
@paginated
searchCustomers(CustomerSearchCriteria) Customer[]
}
πŸ”—Navigate to source

would produce this openapi definition:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
openapi: 3.0.1
info:
title: "ZenWave Customer JPA Example"
version: 0.0.1
description: "ZenWave Customer JPA Example"
contact:
email: email@domain.com
servers:
- 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"
version:
type: "integer"
name:
type: "string"
maxLength: 254
description: "Customer name"
email:
type: "string"
maxLength: 254
pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}"
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"
- 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: 254
city:
type: "string"
maxLength: 254
PaymentMethod:
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: 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: The number of results page
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 purposes
security:
- basicAuth: [] # <-- use the same name here
- bearerAuth: [] # <-- use the same name here
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@get("/{id}")
getCustomer(id) Customer?
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/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"
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@get({ path: "/{name}/patient/{patientId}/questions", params: { lang: String } })
getSurveyAndQuestionsForPatient(SurveyByNameAndPatient) SurveyAndQuestions
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/surveys-public-openapi.yml (πŸ‘‡ view source)
/public/surveys/{name}/patient/{patientId}/questions:
get:
operationId: getSurveyAndQuestionsForPatient
description: "getSurveyAndQuestionsForPatient"
tags: [Surveys]
parameters:
- name: "name"
in: path
required: true
schema:
type: string
- name: "patientId"
in: path
required: true
schema:
type: integer
format: int64
- name: "lang"
in: query
schema:
type: string
responses:
"200":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyAndQuestions"
πŸ”—Navigate to source

Notice how path and query parameter data types are inferred:

  • name path param is inferred from SurveyByNameAndPatient.name field type String.
  • patientId path param is inferred from SurveyByNameAndPatient.patientId field type Long.
  • 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: (πŸ‘‡ view source)
@post
createCustomer(Customer) Customer withEvents CustomerEvent
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/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"
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@post(path: "/search", status: 200) @paginated
searchUsers(SearchCriteria) User[]
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/users/search:
post:
operationId: "searchUsers"
description: "searchUsers"
tags:
- "User"
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/sort"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SearchCriteria"
responses:
"200":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/UserPaginated"
πŸ”—Navigate to source

Using @put for Updating Resources

βš™οΈ

When using the ZDLToOpenAPIPlugin, the following

this service command: (πŸ‘‡ view source)
@put("/{id}")
updateCustomer(id, Customer) Customer? withEvents CustomerEvent
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
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"
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@delete("/{id}")
deleteCustomer(id) withEvents CustomerDeletedEvent
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
delete:
operationId: deleteCustomer
description: "deleteCustomer"
tags: [Customer]
parameters:
- name: "id"
in: path
required: true
schema:
type: integer
format: int64
responses:
"204":
description: "OK"
πŸ”—Navigate to source

Using @patch for Partial Updates

βš™οΈ

When using the ZDLToOpenAPIPlugin, the following

this service command: (πŸ‘‡ view source)
@patch({ path: "/{surveyId}/patient/{patientId}/answers/{date}", params: { surveryId: Long } })
updateSurveyAnswers(@natural id, SurveyAnswers) SurveyAnswers?
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/surveys-public-openapi.yml (πŸ‘‡ view source)
patch:
operationId: updateSurveyAnswers
description: "updateSurveyAnswers"
tags: [Surveys]
parameters:
- name: "surveyId"
in: path
required: true
schema:
type: integer
format: int64
- name: "patientId"
in: path
required: true
schema:
type: integer
format: int64
- name: "date"
in: path
required: true
schema:
type: string
format: date
- name: "surveryId"
in: query
schema:
type: integer
format: int64
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyAnswersPatch"
responses:
"200":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyAnswers"
πŸ”—Navigate to source

#/components/schemas/SurveyAnswersPatch (πŸ‘‡ view source)
SurveyAnswersPatch:
allOf:
- $ref: "#/components/schemas/SurveyAnswers"
πŸ”—Navigate to source
βš™οΈ

When configuring schemaMappings in openapi-generator-maven-plugin like this:

this service command: (πŸ‘‡ view source)
<schemaMappings>
SurveyAnswersPatch=java.util.Map
</schemaMappings>
πŸ”—Navigate to source

will generate a java method with Map input as parameter:

src/main/resources/public/apis/surveys-public-openapi.yml (πŸ‘‡ view source)
@Override
public ResponseEntity<SurveyAnswersDTO> updateSurveyAnswers(
Long surveyId, Long patientId, LocalDate date, Long surveryId, Map input) {
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@post("/search")
@paginated
searchCustomers(CustomerSearchCriteria) Customer[]
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/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"
πŸ”—Navigate to source

With the CustomerPaginated schema defined as:

#/components/schemas/SurveyAnswersPatch (πŸ‘‡ view source)
CustomerPaginated:
allOf:
- $ref: "#/components/schemas/Page"
- x-business-entity-paginated: "Customer"
- properties:
content:
type: "array"
items:
$ref: "#/components/schemas/Customer"
πŸ”—Navigate to source

And the following components definition:

Supporting Schemas for Pagination (πŸ‘‡ view source)
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: The number of results page
schema:
type: array
items:
type: string
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@post({path: "/upload", status: 201})
@fileupload("file")
uploadDocument(DocumentInfo) DocumentInfo
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/documents/upload:
post:
operationId: uploadDocument
description: "uploadDocument"
tags: [Document]
requestBody:
required: true
content:
multipart/form-data:
schema:
allOf:
- type: object
properties:
file:
type: string
format: binary
- $ref: "#/components/schemas/DocumentInfo"
responses:
"201":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentInfo"
πŸ”—Navigate to source

And the corresponding SpringMVC Controller:

DocumentApiController.java#uploadDocument (πŸ‘‡ view source)
@Override
public 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);
}
πŸ”—Navigate to source

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: (πŸ‘‡ view source)
@get({path: "/{id}", params: {preview: boolean} })
@filedownload("documentData.data")
downloadDocument(id) DocumentInfo
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/documents/{id}:
get:
operationId: downloadDocument
description: "downloadDocument"
tags: [Document]
parameters:
- name: "id"
in: path
required: true
schema:
type: integer
format: int64
- name: "preview"
in: query
schema:
type: boolean
responses:
"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: string
content:
'*/*':
schema:
type: string
format: binary
πŸ”—Navigate to source

And the corresponding SpringMVC Controller:

DocumentApiController.java#uploadDocument (πŸ‘‡ view source)
@Override
public 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.data
ByteArrayResource 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);
}
πŸ”—Navigate to source