An opinionated Kotlin backend service - API Routing & Documentation
This is the third part of my series on Kotlin based backend services.
If you missed the first two parts:
- An opinionated Kotlin backend service — Framework
- An opinionated Kotlin backend service — Build & Deployment
API Routing and documentation
Ktor Routing
Defining routes in Ktor is simple and very concise due to the DSL. To define a route, call the routing
function and add the routes you need:
routing {
// here go your routes
}
Using different http methods:
routing {
get("/customer/{id}") { /* handler */ }
post("/customer") { /* handler */ }
put("/customer/{id}") { /* handler */ }
}
Grouped by path:
routing {
route("/customer") {
get("/{id}") { /* handler */ }
post("/") { /* handler */ }
}
route("/account") {
get("/{id}") { /* handler */ }
}
}
Hierarchical:
routing {
route("/customer") {
route("/order") {
get("/{id}") { /* handler */ }
delete("/{id}") { /* handler */ }
}
}
}
Modular (using extension functions):
routing {
customerByIdRoute()
createCustomerRoute()
orderByIdRoute()
}fun Route.customerByIdRoute() {
get("/customer/{id}") { /* handler */ }
}
fun Route.createCustomerRoute() {
post("/customer") { /* handler */ }
}
fun Route.orderByIdRoute() {
get("/order/{id}") { /* handler */ }
}
I don’t want to elaborate on the standard Ktor routing more in-depth because I’m actually not using it. If you’re interested to learn more about the regular Ktor routing read one of the numerous articles e.g. here, here or here.
OpenAPI Generation
The reason I’m not using the normal Ktor routing function is explained in this article: OpenAPI Generation with Ktor.
The gist is that I wanted to have an OpenAPI documentation and I decided to write code and use a generator to create the documentation automatically (as opposed to writing documentation and generating the code automatically). The code I write looks like this:
Definitions like the one above are used to:
- define the application routes
- and generate the OpenAPI documentation:
Route Definition
Let’s go through the definition step by step.
tag(Tags.Account) {
route("/api/v1/admin/accounts") {
- The tag function is used to group routes together (in this case into groups like Misc, Customer and Account).
- The route function is used to define the routes matching a specific path.
You can also create hierarchies of routes:
route("/api/v1") {
route("/misc") { }
route("/accounts") { }
route("/customers") { }
}
The actual routes are defined within the route function:
/**
* Create a new account.
*/
post<Unit, ResponseAccount, RequestAccount>(
info(
summary = "Create an account.",
description = "Create a new account"
),
status(HttpStatusCode.OK),
exceptions = listOf(badRequest),
exampleRequest = accountExampleRequest,
exampleResponse = accountExampleResponse
) { _, account ->
val newAccount = controller.createAccount(account)
respond(newAccount)
}
The two or three generic types (two for a get: get<AccountUUIDParam, ResponseAccount>
three for a post: post<Unit, ResponseAccount, RequestAccount>
) define the types for
- path parameters (e.g. path/{accountUUID})
- response
- request body (post, put)
The definition of these types looks like this (path parameter):
@Path("{accountUUID}")
data class AccountUUIDParam(
@PathParam("UUID of the account.")
val accountUUID: String
)
or (response):
@Response("Account Response.")
data class Account(
val accountUUID: String,
val createdAt: Instant,
val modifiedAt: Instant,
val status: AccountStatus
)
These data classes are used to:
- Create the OpenAPI documentation:
- Define the result of serialization/deserialization of requests/responses (to and from Json / data classes)
- Define object validation rules. Further down I will elaborate on object validation in more detail.
Method Parameters
The method parameters for get
, post
, put
, patch
, delete
and head
functions are almost exclusively used to define the OpenAPI documentation (the exceptions parameters also tie into the object validation).
info(
summary = "Create an account.",
description = "Create a new account"
),
status(HttpStatusCode.OK),
exceptions = listOf(badRequest),
exampleRequest = accountExampleRequest,
exampleResponse = accountExampleResponse
- The purpose of the
info
andstatus
parameters is obvious (describe the route and the response status code). They are bothRouteOpenAPIModule
vararg parameters of which there are two more:
1)resonseAnnotationStatusCode
: the response HTTP status code is derived from the response definition, e.g:@Response("Account Response.", 202)
-> overrides thestatus(HttpStatusCode.OK)
statement
2)tags
: sets the tag for this route, if multiple tags are set, the route will appear in all the categories / groups. - The
exceptions
parameter defines possible error cases.
E.g. the badRequest
definition:
translates to:
Note that the runtime error defined with contentFn
depends on the exception message which is taken from the ConstraintViolation
thrown as part of the validation process (see “Object Validation” chapter below).
- Last but not least the
exampleRequest
andexampleResponse
parameters which should be self explaining.
Object validation
I’ve written a dedicated article about object validation: Object validation in Ktor/Kotlin. In this article I researched several validation libraries like Kalidation, Valiktor or Konform. While I declared Konform my favorite, I actually don’t use it because the Ktor-OpenAPI-Generator has a built-in validation mechanism. While I’m not a particular fan of annotation based validation (I like to use a single language for everything including configuration and build, that single language being Kotlin here), the advantage in this case is that it serves two purposes:
- Validation
- Documentation
E.g. the annotations here are used by the Ktor-OpenAPI-Generator to validate the input parameters and also to create the documentation:
Error Handling
What happens if an input parameter or the request body fails validation?
Ktor-OpenAPI-Generator throws a ConstraintViolation
which is handled by the badRequest
definition posted above:
val badRequest = apiException<ConstraintViolation, ErrorMessage> {
// more code here
}
which in turns is used in the route definition (also posted above) and we come full circle:
post<Unit, ResponseAccount, RequestAccount>(
...
exceptions = listOf(badRequest),
...
) {
...
}
That’s it for part 3 of the series. If you enjoyed this, follow up with An opinionated Kotlin backend service — Database / Migration / ORM.
As usual feel free to provide feedback. Happy coding!