This Kotlin library offers a concise way to define your OpenAPI specification. For example, it is able to turn this code which defines some CRUD endpoints to manage a todo list
@file:CompilerOptions("-jvm-target", "1.8")
@file:DependsOn("io.github.c-classen.oakdsl:oakdsl:0.1.3")
import io.github.cclassen.oakdsl.builder.OpenApiBuilder
OpenApiBuilder.file("api.yaml") {
info {
title = "Test api"
version = "1.0.0"
}
post<Todo>("createTodo") / "todos" def {
requestBody<TodoPayload>()
}
get<List<Todo>>("readTodos") / "todos" def {}
get<Todo>("readTodo") / "todos" / longParam("todoId") def {}
put<Todo>("updateTodo") / "todos" / longParam("todoId") def {}
patch<Todo>("markAsDone") / "todos" / longParam("todoId") def {
boolParam("done")
}
delete("deleteTodo") / "todos" / longParam("todoId") def {}
}
interface Todo {
val id: Long
val text: String
val done: Boolean
}
interface TodoPayload {
val text: String
}
into the following OpenAPI Spec:
# This file is generated by oak-dsl (https://github.com/c-classen/oak-dsl)
openapi: 3.0.3
info:
version: "1.0.0"
title: "Test api"
paths:
"/todos":
get:
operationId: "readTodos"
responses:
"200":
description: "Success"
content:
"application/json":
schema:
type: "array"
items:
"$ref": "#/components/schemas/Todo"
post:
operationId: "createTodo"
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/TodoPayload"
responses:
"200":
description: "Success"
content:
"application/json":
schema:
"$ref": "#/components/schemas/Todo"
"/todos/{todoId}":
delete:
operationId: "deleteTodo"
parameters:
- in: path
name: "todoId"
required: true
schema:
type: integer
format: "int64"
responses:
"200":
description: "Success"
get:
operationId: "readTodo"
parameters:
- in: path
name: "todoId"
required: true
schema:
type: integer
format: "int64"
responses:
"200":
description: "Success"
content:
"application/json":
schema:
"$ref": "#/components/schemas/Todo"
patch:
operationId: "markAsDone"
parameters:
- in: path
name: "todoId"
required: true
schema:
type: integer
format: "int64"
- in: query
name: "done"
required: true
schema:
type: boolean
responses:
"200":
description: "Success"
content:
"application/json":
schema:
"$ref": "#/components/schemas/Todo"
put:
operationId: "updateTodo"
parameters:
- in: path
name: "todoId"
required: true
schema:
type: integer
format: "int64"
responses:
"200":
description: "Success"
content:
"application/json":
schema:
"$ref": "#/components/schemas/Todo"
components:
schemas:
Todo:
type: "object"
required: [ "done", "id", "text" ]
properties:
done:
type: boolean
id:
type: integer
format: "int64"
text:
type: string
TodoPayload:
type: "object"
required: [ "text" ]
properties:
text:
type: string
Although the resulting yaml could be optimized by hand to become shorter (e.g., by defining the todoId
parameter in the components section), it would still not be nearly as short as the oak-dsl version.
To achieve this brevity, oak-dsl uses two techniques.
First, it utilizes Kotlin's syntax features to make certain definitions have less boilerplate.
For example, defining a property in a schema is just one line in Kotlin (val name: Type
) while it takes two or even three lines in yaml:
name:
type: Type
Second, it makes assumptions about things that you have to specify explicitly in yaml. Take the following line as an example.
get<List<Todo>>("readTodos") / "todos" def {}
First we specify that the endpoint uses a GET HTTP method.
We then say that it returns a List of Todo objects which are defined at a later point.
After that, we specify the operation id as readTodos
and the path as /todos
.
We could have used the block following the def
keyword to further customize the endpoint, but we did not need to.
This line corresponds to the following OpenAPI definition:
"/todos":
get:
operationId: "readTodos"
responses:
"200":
description: "Success"
content:
"application/json":
schema:
type: "array"
items:
"$ref": "#/components/schemas/Todo"
OpenAPI requires more information than we specified, so oak-dsl just filled them with default values.
The first one is the response code which will usually be 200
in case no error happened.
Second is the description which is often not necessary, as the meaning of the response is clear from the context.
Oak-dsl defaults to "Success"
as a description of the default response.
The third default parameter is the content type.
There probably will be cases, where application/json
is not appropriate, e.g., because we want to download a file, but most of the time it does the job.
To generate your OpenAPI Specification with oak-dsl, you create a file named api.main.kts
and define your endpoints as seen in the first example above.
To generate the yaml file, you will need to install the Kotlin compiler.
Once you have done that, execute kotlinc -script api.main.kts
from the command line in the directory with the oak-dsl definition file.
If you are using IntelliJ, you will have working auto-completion for the api.main.kts
file.
You can also use IntelliJ to run your script, but this may currently fail in case you have other Kotlin code with errors in your project.
This section explains how the features of oak-dsl can help you quickly write your OpenAPI definition.
Your oak-dsl file should usually look as follows:
@file:CompilerOptions("-jvm-target", "1.8")
@file:DependsOn("io.github.cclassen.oakdsl:oakdsl:VERSION")
import io.github.cclassen.oakdsl.builder.OpenApiBuilder
OpenApiBuilder.file("api.yaml") {
// Endpoints
}
// Types
The first and second line configure the Kotlin compiler and declare the dependency on oak-dsl respectively.
Then the script imports the class OpenApiBuilder
and calls the file
method on its companion object.
It takes the filename of the OpenAPI YAML file to be generated, and a lambda where the file's contents can be configured.
Within that lambda, first the contents of the info section of the specification are declared.
It is also possible to define more metadata within that section and to declare servers by using the following method call.
server("http://example.com/", "Some Description")
To define an endpoint, you need to call the method corresponding to the HTTP method.
It takes an optional type parameter.
If you specify it, oak-dsl will generate a 200
response with content type application/json
and a schema that references a type corresponding to the type parameter.
The only value parameter is the operation id.
After the method call, you can define the path of the endpoint using the /
operator on the resulting EndpointPathBuilder
.
As second operand you can either use a string literal, or a parameter which is explained later.
After appending all path segments, you can conclude the endpoint definition using the def
infix function followed by a lambda that allows you to further customize your endpoint.
You can use it to define a description, tags, responses and the request body.
You can use either interfaces or classes to define the types you use as your schemas for requests and responses.
Interfaces allow for easy inheritance which is represented by a schema using allOf
.
However, the order of the fields cannot be preserved and is therefore alphabetically.
For classes, you need to put the fields in the primary constructor like this:
class Example(
firstField: Int,
secondField: String
)
All types that are referenced directly or indirectly by your specification will be translated to a schema.
Primitive types are translated to their json schema counterparts and classes, interfaces and enumerations will be defined in the components/schemas
section of the resulting YAML.
It is therefore important that they have unique names.
You can also define schemas by constructing the classes in the package io.github.cclassen.oakdsl.model.schema
directly.
It is also possible to override type resolution for your own or built-in types, as shown in the following example:
OpenApiBuilder.file("api.yaml") {
val dbId = components.type("DbId", PrimitiveSchema("integer", "int64"))
customResolve<DbId> { dbId }
// Endpoints
}
interface DbId
By calling components.type
, you can register your constructed type to be output in the components/schemas
section of the YAML.
For oak-dsl, two kinds of parameters must be distinguished. Path parameters must be declared when building the path, like in the following example:
get<Todo>("readTodo") / "todos" / longParam("todoId") def {}
Query and header parameters are declared within the endpoint definition:
get<Todo>("readTodo") / "todos" def {
longParam("todoId")
}
You can also declare have your parameter declared in the components/parameters
section of the YAML and then refer to it later by reference:
val idParam = components.parameter("idParam", "id", components.resolveClass(Long::class), kind = "path")
get<Todo>("readTodo") / "todos" / idParam def {}
Endpoint filters allow you to have sections in your oak-dsl definition that enhance readability and allow you to apply certain transformations to all endpoints within the section. They can be used as follows:
val prefix = endpointFilter { path = "/todos$path" }
prefix {
get<List<Todo>>("readTodos") def {}
get<Todo>("readTodo") / longParam("todoId") def {}
}
The two endpoints within the prefix
section will have the /todos
path segment prepended to their path.
Therefore, it does not need to be specified when declaring the endpoints.
This reduces verbosity and gives the oak-dsl document more structure.
When declaring an endpoint filter, you specify a lambda receiving an EndpointItem
which allows you to modify the Http-method
, the path
and the other properties within the endpoint
.
You can also combine multiple filters by using their and
-method which returns a new filter that applies first the right-hand filter and then the left-hand one.
There is no need to declare a custom endpoint filter if you want to prepend a prefix to all paths within the filter.
You can just use the built-in prefix
-method which creates a filter with a prefix you specify:
prefix("/prefix") {
// endpoints
}
The same goes in case you want to add a tag to each endpoint.
tagged("SomeTag") {
// endpoints
}
Endpoints can be marked, which has no effect on the generated YAML, but can be used to distinguish certain endpoints in post-processing.
enum class MicroService { A, B }
marked(MicroService.A) {
post("hello") / "hello" def {}
}
Endpoint filters can also be applied to all endpoints by using the globalFilter
method.
However, you cannot modify the HTTP method or the path this way.
This is a convenient way to put some custom extensions on your endpoints that depend on endpoint metadata.
globalFilterForMarked(MicroService.A) {
endpoint.additionalYamlProperties["x-my-extension"] = YamlMap.build {
map("my-map") {
array("my-array") {
string("SomeString")
value("SomeValue")
}
shortArray("my-short-array", listOf(1, true, -1.3, "Test"))
shortStringArray("my-string-array", listOf("a", "b"))
string("someString", "Teststring")
}
}
}
In this code, a custom extension is added to all endpoints that were marked with MicroService.A
(where MicroService
is an enum).
The extension will be serialized to the following yaml:
"x-my-extension":
"my-map":
"my-array":
- "SomeString"
- SomeValue
"my-short-array": [ 1, true, -1.3, Test ]
"my-string-array": [ "a", "b" ]
someString: "Teststring"