Skip to content

c-classen/oak-dsl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🌳 OpenAPI Kotlin Domain Specific Language

Maven Central Badge

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.

Usage

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.

Features and Documentation

This section explains how the features of oak-dsl can help you quickly write your OpenAPI definition.

Basics

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")

Defining Endpoints

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.

Defining Types

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.

Defining parameters

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

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.

Prefix

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
}

Tagged

The same goes in case you want to add a tag to each endpoint.

tagged("SomeTag") {
    // endpoints
}

Marked

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 {}
}

Global filters and custom extensions

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"

About

Create OpenAPI Specifications using Kotlin

Topics

Resources

License

Stars

Watchers

Forks

Languages