Skip to content

Commit

Permalink
wip: ktor
Browse files Browse the repository at this point in the history
  • Loading branch information
nesk committed Nov 26, 2024
1 parent c5a95c6 commit 4b8ca5b
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 101 deletions.
Binary file modified documentation/images/social-ktor-server.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions documentation/topics/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This article will show you how to install %product% and write your first validat
[KSP](https://kotlinlang.org/docs/ksp-overview.html). Follow the installation instructions below, according to your
project structure.

<procedure title="Install in a single-platform project" id="single-platform-installation" collapsible="true" default-state="collapsed">
<procedure title="Install %product% in a single-platform project" id="single-platform-installation" collapsible="true" default-state="collapsed">

<step>
Add KSP to your plugin list; make sure to <a href="https://github.com/google/ksp/releases">use the appropriate
Expand All @@ -33,7 +33,7 @@ dependencies {

</procedure>

<procedure title="Install in a multiplatform project" id="multiplatform-installation" collapsible="true" default-state="collapsed">
<procedure title="Install %product% in a multiplatform project" id="multiplatform-installation" collapsible="true" default-state="collapsed">

<step>
Add KSP to your plugin list; make sure to <a href="https://github.com/google/ksp/releases">use the appropriate
Expand Down
12 changes: 6 additions & 6 deletions documentation/topics/ktor-server-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ automatically validate incoming data.

Before using %product% with Ktor, you need to add the following dependency to your Gradle script:

<procedure title="Add the %product% plugin">
<procedure title="Add the %product% plugin for Ktor" id="install-akkurate">

<code-block lang="kotlin">
implementation("dev.nesk.akkurate:akkurate-ktor-server:%version%")
</code-block>

</procedure>

The %product% plugin requires the [Content Negociation](https://ktor.io/docs/server-serialization.html)
The %product% plugin requires the [Content Negotiation](https://ktor.io/docs/server-serialization.html)
and [Request Validation](https://ktor.io/docs/server-request-validation.html) plugins:

<procedure title="Add the required plugins">
<procedure title="Add the required plugins for Ktor">

<code-block lang="kotlin">
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
Expand Down Expand Up @@ -92,9 +92,9 @@ curl 127.0.0.1:8080/books \
--data '{"title": ""}'
```

```text
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
```json5
// HTTP/1.1 422 Unprocessable Entity
// Content-Type: application/problem+json

{
"status": 422,
Expand Down
135 changes: 71 additions & 64 deletions documentation/topics/ktor-validation-tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ unique <tooltip term="ISBN">ISBN</tooltip>, as well as a valid title.
## Setting up the project

You can download a generated Ktor
project [by following this link.](https://start.ktor.io/settings?name=akkuratewithktor&website=com.example&artifact=com.example.akkuratewithktor.akkuratewithktor&kotlinVersion=2.0.10&ktorVersion=2.3.12&buildSystem=GRADLE_KTS&engine=NETTY&configurationIn=HOCON&addSampleCode=false&plugins=routing%2Ckotlinx-serialization%2Ccontent-negotiation%2Cexposed%2Cstatus-pages)
Then click <ui-path>Add plugins | Generate project</ui-path> and, once the project is downloaded, open it in IntelliJ.
project [via Ktor's Project Generator](https://start.ktor.io/settings/?name=akkuratewithktor&website=akkuratewithktor.example.com&artifact=com.example.akkuratewithktor.akkuratewithktor&kotlinVersion=2.0.21&ktorVersion=3.0.1&buildSystem=GRADLE_KTS&buildSystemArgs.version_catalog=true&engine=NETTY&configurationIn=HOCON&addSampleCode=false&plugins=routing%252Ckotlinx-serialization%252Ccontent-negotiation%252Cexposed)
and clicking <ui-path>Download</ui-path>. Once the project is downloaded, open it in IntelliJ.

> The following plugins are already preconfigured:
> - **Content Negotiation & kotlinx.serialization** to handle JSON payloads;
> - **Exposed** to easily read/write to the database;
> - **Routing** to handle the requests;
> - **Status Pages** to return a specific response when an exception is thrown.
> - **Routing** to handle the requests.
## Defining and persisting the data model

Expand Down Expand Up @@ -120,8 +119,10 @@ fun Application.configureDatabases() {

## Handling the requests

We will need two routes for our HTTP API; `POST /books` to register a new book to the database, and `GET /books` to list
all the books in the database.
We will need two routes for our HTTP API:

- `POST /books` to store a new book in the database.
- `GET /books` to list all the books in the database.

Open the <path>Routing.kt</path> file and copy the following code in the `configureRouting` function:

Expand All @@ -140,8 +141,8 @@ routing {
}
```

The `POST /books` route deserializes the payload, stores it in the database, and returns a 201 HTTP status code. The
`GET /books` route fetches all the books and serializes them into the response.
The `POST /books` route deserializes the request payload, stores it in the database and returns a 201 HTTP status code.
The `GET /books` route fetches all the books and serializes them into the response.

## What can we improve?

Expand Down Expand Up @@ -171,8 +172,9 @@ Now list the books:
curl 127.0.0.1:8080/books -v
```

```text
HTTP/1.1 200 OK
```json5
// HTTP/1.1 200 OK

[
{
"isbn": "123 ",
Expand All @@ -183,7 +185,7 @@ HTTP/1.1 200 OK

</compare>

We can see two issues in this response; the ISBN is now filled up to the 13 required characters, and we shouldn't allow
We can see two issues in this response: the ISBN is now padded with space to reach 13 characters, and we shouldn't allow
empty titles.

Try to run the first query again:
Expand Down Expand Up @@ -212,7 +214,7 @@ Finally, try to create a book with a title over 50 characters:
```shell
curl 127.0.0.1:8080/books -v \
--header 'Content-Type: application/json' \
--data '{"isbn": "1234", "title": "this a really long title and it will not fit our database column"}'
--data '{"isbn": "456", "title": "this a really long title and it will not fit our database column"}'
```

```text
Expand All @@ -221,13 +223,12 @@ HTTP/1.1 500 Internal Server Error

</compare>

Once again, we see another internal error, because our title is composed of 64 characters meanwhile our database column
Once again, we see another internal error, because our title is composed of 64 characters, meanwhile our database column
can contain a maximum of 50 characters.

## Validating the requests

All these issues can be fixed by validating the requests. We will use %product% coupled
to [Ktor request validation](https://ktor.io/docs/request-validation.html).
All these issues can be fixed by validating the requests, first we need to set up our validator.

### Enhancing the DAO

Expand All @@ -236,7 +237,9 @@ by its ISBN:

```kotlin
suspend fun existsWithIsbn(isbn: String): Boolean = dbQuery {
Books.select { Books.isbn eq isbn }.singleOrNull() != null
Books.selectAll()
.where { Books.isbn eq isbn }
.singleOrNull() != null
}
```

Expand All @@ -253,9 +256,14 @@ Then mark the `Book` class with the `@Validate` annotation:
```kotlin
@Validate
@Serializable
data class Book(/* ... */)
data class Book(
val isbn: String = "",
val title: String = "",
)
```

{collapsible="true" default-state="collapsed" collapsed-title="@Validate @Serializable data class Book"}

Just like in the [Getting Started guide](getting-started.md), we create a `Validator` instance and add our constraints
to it:

Expand Down Expand Up @@ -298,39 +306,27 @@ There are multiple things to explain here:

## Wiring to Ktor validation

%product% runs the validation and returns a result, but it needs to provide the latter to Ktor in order to generate a
response. This requires [the Request Validation plugin](https://ktor.io/docs/request-validation.html):
%product% provides [a Ktor plugin](ktor-server-integration.md) to automatically validate deserialized payloads:

<include from="ktor-server-integration.md" element-id="install-akkurate" />

This plugin also requires [the Request Validation plugin](https://ktor.io/docs/request-validation.html) to be added:

```kotlin
implementation("io.ktor:ktor-server-request-validation")
implementation("io.ktor:ktor-server-request-validation:$ktor_version")
```

This plugin allows configuring a validation function for a specific class; it will be executed on each deserialization.
A validation result must be returned, we can generate it from %product%'s own result:
Now we must install both plugins and declare what types we want to validate on deserialization:

```kotlin
import dev.nesk.akkurate.ValidationResult.Failure as AkkurateFailure
import dev.nesk.akkurate.ValidationResult.Success as AkkurateSuccess

fun Application.configureValidation() {
install(Akkurate)
install(RequestValidation) {
validate<Book> { book ->
when (val result = validateBook(bookDao, book)) {
is AkkurateSuccess -> ValidationResult.Valid
is AkkurateFailure -> {
val reasons = result.violations.map {
"${it.path.joinToString(".")}: ${it.message}"
}
ValidationResult.Invalid(reasons)
}
}
}
registerValidator(validateBook) { bookDao }
}
}
```

Notice how we execute our `validateBook` function with the provided book, then we map the result to Ktor's result.

We also have to call our `configureValidation` function on application start, this is done in the <path>
Application.kt</path> file:

Expand All @@ -343,20 +339,8 @@ fun Application.module() {
}
```

When the validation fails, the plugin throws a `RequestValidationException`. To handle this exception and return a
proper response, we use [the Status Pages plugin](https://ktor.io/docs/status-pages.html).

Open the <path>Routing.kt</path> file, navigate to the `configureRouting` function, then the `install(StatusPages) {}`
lambda, and add the following code:

```kotlin
exception<RequestValidationException> { call, cause ->
call.respond(HttpStatusCode.UnprocessableEntity, cause.reasons)
}
```

When the `RequestValidationException` is thrown, the Status Page plugin catches it and returns a response with a 422
HTTP status code, along with a JSON array of validation messages.
When the validation fails, the server returns a default response,
based [on the RFC 9457 (Problem Details for HTTP APIs).](https://www.rfc-editor.org/rfc/rfc9457.html)

## Conformance checking

Expand All @@ -372,12 +356,25 @@ curl 127.0.0.1:8080/books -v \
--data '{"isbn": "123", "title": ""}'
```

```text
HTTP/1.1 422 Unprocessable Entity
[
"isbn: Must be a valid ISBN (13 digits)",
"title: Must not be blank"
]
```json5
// HTTP/1.1 422 Unprocessable Entity

{
"status": 422,
"fields": [
{
"message": "Must be a valid ISBN (13 digits)",
"path": "isbn"
},
{
"message": "Must not be blank",
"path": "title"
}
],
"type": "https://akkurate.dev/validation-error",
"title": "The payload is invalid",
"detail": "The payload has been successfully parsed, but the server is unable to accept it due to validation errors."
}
```

</compare>
Expand All @@ -391,7 +388,7 @@ Now try to create a book with valid values:
```shell
curl 127.0.0.1:8080/books -v \
--header 'Content-Type: application/json' \
--data '{"isbn": "1234567891234", "title": "The Lord of the Rings"}'
--data '{"isbn": "9780261103207", "title": "The Lord of the Rings"}'
```

```text
Expand All @@ -404,11 +401,21 @@ The request is considered valid and we received a 201 HTTP status code.

What if we try to create the same book a second time?

```text
HTTP/1.1 422 Unprocessable Entity
[
"isbn: This ISBN is already registered"
]
```json5
// HTTP/1.1 422 Unprocessable Entity

{
"status": 422,
"fields": [
{
"message": "This ISBN is already registered",
"path": "isbn"
}
],
"type": "https://akkurate.dev/validation-error",
"title": "The payload is invalid",
"detail": "The payload has been successfully parsed, but the server is unable to accept it due to validation errors."
}
```

As expected, we can't register the same ISBN twice.
Expand Down
1 change: 1 addition & 0 deletions examples/ktor-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies {

implementation(project(":akkurate-core"))
implementation(project(":akkurate-ksp-plugin"))
implementation(project(":akkurate-ktor-server"))
ksp(project(":akkurate-ksp-plugin"))
implementation("io.ktor:ktor-server-request-validation:$ktor_version")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ class BookDao(database: Database) {
}

suspend fun existsWithIsbn(isbn: String): Boolean = dbQuery {
Books.select { Books.isbn eq isbn }.singleOrNull() != null
Books.selectAll()
.where { Books.isbn eq isbn }
.singleOrNull() != null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,11 @@ package dev.nesk.akkurate.examples.ktor.server.plugins

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.requestvalidation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
exception<RequestValidationException> { call, cause ->
call.respond(HttpStatusCode.UnprocessableEntity, cause.reasons)
}
}

routing {
post("/books") {
val book = call.receive<Book>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,15 @@ import dev.nesk.akkurate.constraints.constrain
import dev.nesk.akkurate.constraints.otherwise
import dev.nesk.akkurate.examples.ktor.server.plugins.validation.accessors.isbn
import dev.nesk.akkurate.examples.ktor.server.plugins.validation.accessors.title
import dev.nesk.akkurate.ktor.server.Akkurate
import dev.nesk.akkurate.ktor.server.registerValidator
import io.ktor.server.application.*
import io.ktor.server.plugins.requestvalidation.*
import dev.nesk.akkurate.ValidationResult.Failure as AkkurateFailure
import dev.nesk.akkurate.ValidationResult.Success as AkkurateSuccess

fun Application.configureValidation() {
install(Akkurate)
install(RequestValidation) {
validate<Book> { book ->
when (val result = validateBook(bookDao, book)) {
is AkkurateSuccess -> ValidationResult.Valid
is AkkurateFailure -> {
val reasons = result.violations.map {
"${it.path.joinToString(".")}: ${it.message}"
}
ValidationResult.Invalid(reasons)
}
}
}
registerValidator(validateBook) { bookDao }
}
}

Expand Down
8 changes: 4 additions & 4 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
org.gradle.jvmargs=-Xmx4g
kotlin.code.style=official
kotlin_version=1.9.10
ktor_version=2.3.5
logback_version=1.3.11
exposed_version=0.41.1
h2_version=2.1.214
ktor_version=3.0.1
logback_version=1.4.14
exposed_version=0.56.0
h2_version=2.3.232

0 comments on commit 4b8ca5b

Please sign in to comment.