Skip to content

Commit

Permalink
wip: ktor
Browse files Browse the repository at this point in the history
  • Loading branch information
nesk committed Nov 25, 2024
1 parent 86ee65d commit c5a95c6
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 43 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ jobs:
run: unzip -qq ./artifacts/${{ env.DOC_ARTIFACT }} -d ./artifacts/docs

- name: Replace Open Graph image for specific pages
run: sed -i 's/akkurate\.dev\/social\.png/akkurate.dev\/social-arrow.png/g' ./artifacts/docs/arrow-integration.html
run: |
sed -i 's/akkurate\.dev\/social\.png/akkurate.dev\/social-arrow.png/g' ./artifacts/docs/arrow-integration.html;
sed -i 's/akkurate\.dev\/social\.png/akkurate.dev\/social-ktor-server.png/g' ./artifacts/docs/ktor-server-integration.html;
sed -i 's/akkurate\.dev\/social\.png/akkurate.dev\/social-ktor-client.png/g' ./artifacts/docs/ktor-client-integration.html;
- name: Upload documentation
uses: actions/upload-artifact@v4
Expand All @@ -64,6 +67,8 @@ jobs:
path: |
./documentation/images/social.png
./documentation/images/social-arrow.png
./documentation/images/social-ktor-server.png
./documentation/images/social-ktor-client.png
retention-days: 7

- name: Upload Algolia indexes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public val Akkurate: ApplicationPlugin<AkkurateConfig> = createApplicationPlugin
) {
on(CallFailed) { call, cause ->
with(pluginConfig) {
if (!catchException) return@on
if (cause !is ValidationResult.Exception) throw cause
responseBuilder(call, cause.violations)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import io.ktor.server.application.*
import io.ktor.server.response.*

public class AkkurateConfig internal constructor() {
public var catchException: Boolean = true

public var status: HttpStatusCode = HttpStatusCode.UnprocessableEntity

public var contentType: ContentType = ContentType.Application.ProblemJson
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

package dev.nesk.akkurate.ktor.server

import dev.nesk.akkurate.ValidationResult
import dev.nesk.akkurate.Validator
import dev.nesk.akkurate.constraints.builders.isTrue
import io.ktor.client.*
Expand Down Expand Up @@ -91,19 +90,6 @@ class AkkurateTest {
)
}

@Test
fun can_disable_exception_catching() = testApplication {
install(Akkurate) {
catchException = false
}
installOtherPlugins()

receiveBoolean()

val exception = assertFails { client.sendBoolean(false) }
assertIs<ValidationResult.Exception>(exception)
}

@Test
fun does_not_catch_other_exceptions() = testApplication {
install(Akkurate)
Expand Down
2 changes: 2 additions & 0 deletions documentation/akkurate.tree
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
</toc-element>
<toc-element topic="constraints-reference.md"/>
<toc-element topic="integrations.topic">
<toc-element topic="ktor-server-integration.md"/>
<toc-element topic="ktor-client-integration.md"/>
<toc-element topic="arrow-integration.md"/>
<toc-element topic="kotlinx-datetime-integration.md"/>
</toc-element>
Expand Down
Binary file added documentation/images/social-ktor-client.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 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.
3 changes: 2 additions & 1 deletion documentation/topics/integrations.topic
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
</description>

<spotlight>
<a href="ktor-server-integration.md" type="server" />
<a href="arrow-integration.md" type="integration" />
<a href="#" type="server" summary="Once released, you will be able to automatically validate incoming requests with %product%.">Coming soon: Ktor</a>
</spotlight>

<primary>
<title>Other integrations</title>
<a href="ktor-client-integration.md" />
<a href="kotlinx-datetime-integration.md" />
</primary>
</section-starting-page>
Expand Down
16 changes: 16 additions & 0 deletions documentation/topics/ktor-client-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Ktor Client

Ktor Client is a framework for building asynchronous client-side applications. Akkurate provides an integration to
automatically validate downloaded data.

![A code example of the Ktor Client integration, used to showcase %product% on social networks.](social-ktor-client.png)
{width="700" border-effect="rounded"}

## Installation

<seealso style="cards">
<category ref="related">
<a href="ktor-server-integration.md" />
<a href="ktor-validation-tutorial.md" />
</category>
</seealso>
152 changes: 152 additions & 0 deletions documentation/topics/ktor-server-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Ktor Server

Ktor Server is a framework for building asynchronous server-side applications. Akkurate provides an integration to
automatically validate incoming data.

> If you're searching for a more step-by-step guide to server-side
> validation, [check out this tutorial](ktor-validation-tutorial.md) to build an HTTP
> API with Ktor and Akkurate.
{style="note"}

![A code example of the Ktor Server integration, used to showcase %product% on social networks.](social-ktor-server.png)
{width="700" border-effect="rounded"}

## Installation

> You can build a new Ktor project preconfigured with
> Akkurate [via start.ktor.io](https://start.ktor.io/p/akkurate)
{style="tip"}

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

<procedure title="Add the %product% plugin">

<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)
and [Request Validation](https://ktor.io/docs/server-request-validation.html) plugins:

<procedure title="Add the required plugins">

<code-block lang="kotlin">
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-server-request-validation:$ktor_version")
</code-block>

</procedure>

## Validate on deserialization

Take the following route that deserializes the body to a `Book` instance:

```kotlin
@Validate
@Serializable
data class Book(val title: String)

fun Application.configureRouting() {
routing {
post("/books") {
val book = call.receive<Book>()

// Do something with the book...
}
}
}
```

We want to make sure the book title is always filled, so we create a validator to enforce this:

```kotlin
val validateBook = Validator<Book> {
title.isNotEmpty()
}
```

Then we register this validator in our Ktor configuration:

```kotlin
fun Application.configureSerialization() {
install(ContentNegotiation) { json() }

install(Akkurate)
install(RequestValidation) {
registerValidator(validateBook)
}
}
```

Now the `Book` instance we get, when deserializing the request body, is automatically validated:

<compare type="top-bottom" first-title="cURL request" second-title="Response">

```shell
curl 127.0.0.1:8080/books \
--header 'Content-Type: application/json' \
--data '{"title": ""}'
```

```text
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"status": 422,
"fields": [
{
"message": "Must not be empty",
"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>

> You can register as many validators as you need.
{style="note"}

## Customize the response

A default response,
based [on the RFC 9457 (Problem Details for HTTP APIs)](https://www.rfc-editor.org/rfc/rfc9457.html), is returned when
the validation fails.

You can customize its status and `Content-Type` header:

```kotlin
install(Akkurate) {
status = HttpStatusCode.BadRequest
contentType = ContentType.Application.Json
}
```

If you need a different payload, you can override the whole response builder:

```kotlin
install(Akkurate) {
buildResponse { call, violations ->
call.respond(
HttpStatusCode.BadRequest,
violations.byPath.toString()
)
}
}
```

<seealso style="cards">
<category ref="related">
<a href="ktor-validation-tutorial.md" />
<a href="ktor-client-integration.md" />
</category>
</seealso>
55 changes: 31 additions & 24 deletions documentation/topics/ktor-validation-tutorial.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
# Server-side validation with Ktor

<tldr>

**Code example:** [ktor-server](%github_product_url%/tree/main/examples/ktor-server)

**Time to complete:** 20 minutes

</tldr>

This tutorial provides a sampling of how %product% helps you write server-side validation with Ktor. We're going to
create an HTTP API to manage the books contained within a library; its role is to ensure each book has a valid and
unique <tooltip term="ISBN">ISBN</tooltip>, as well as a valid title.
Expand All @@ -17,6 +9,14 @@ unique <tooltip term="ISBN">ISBN</tooltip>, as well as a valid title.
{style="note"}

<tldr>

**Code example:** [ktor-server](%github_product_url%/tree/main/examples/ktor-server)

**Time to complete:** 20 minutes

</tldr>

## Setting up the project

You can download a generated Ktor
Expand Down Expand Up @@ -99,8 +99,8 @@ class BookDao(database: Database) {
```

Finally, we need to instantiate our DAO with a database connection when the application starts up. Open the <path>
Databases.kt</path> file, create a top level variable `lateinit var bookDao: BookDao`, and define it inside
the `configureDatabases` function:
Databases.kt</path> file, create a top level variable `lateinit var bookDao: BookDao`, and define it inside the
`configureDatabases` function:

```kotlin
lateinit var bookDao: BookDao
Expand Down Expand Up @@ -140,8 +140,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 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 All @@ -153,8 +153,8 @@ Create a new book with `isbn=123` and `title` being empty:

```shell
curl 127.0.0.1:8080/books -v \
--data '{"isbn": "123", "title": ""}' \
--header 'Content-Type: application/json'
--header 'Content-Type: application/json' \
--data '{"isbn": "123", "title": ""}'
```

```text
Expand Down Expand Up @@ -192,8 +192,8 @@ Try to run the first query again:

```shell
curl 127.0.0.1:8080/books -v \
--data '{"isbn": "123", "title": ""}' \
--header 'Content-Type: application/json'
--header 'Content-Type: application/json' \
--data '{"isbn": "123", "title": ""}'
```

```text
Expand All @@ -211,8 +211,8 @@ Finally, try to create a book with a title over 50 characters:

```shell
curl 127.0.0.1:8080/books -v \
--data '{"isbn": "1234", "title": "this a really long title and it will not fit our database column"}' \
--header 'Content-Type: application/json'
--header 'Content-Type: application/json' \
--data '{"isbn": "1234", "title": "this a really long title and it will not fit our database column"}'
```

```text
Expand Down Expand Up @@ -288,8 +288,8 @@ val validateBook = Validator.suspendable<BookDao, Book> { dao ->
There are multiple things to explain here:

- We use [a suspendable validator](use-external-sources.md#suspendable-validation)
with [a context](use-external-sources.md#contextual-validation). Those allow our validator to call
the `BookDao.existsWithIsbn` method, to ensure a book isn't already registered in our database.
with [a context](use-external-sources.md#contextual-validation). Those allow our validator to call the
`BookDao.existsWithIsbn` method, to ensure a book isn't already registered in our database.
- The call to `existsWithIsbn` is done within [an inline constraint](extend.md#inline-constraints)
and [only if the ISBN is valid,](complex-structures.md#conditional-constraints) to avoid a useless query to the
database.
Expand Down Expand Up @@ -368,8 +368,8 @@ Create a new book with invalid values:

```shell
curl 127.0.0.1:8080/books -v \
--data '{"isbn": "123", "title": ""}' \
--header 'Content-Type: application/json'
--header 'Content-Type: application/json' \
--data '{"isbn": "123", "title": ""}'
```

```text
Expand All @@ -390,8 +390,8 @@ Now try to create a book with valid values:

```shell
curl 127.0.0.1:8080/books -v \
--data '{"isbn": "1234567891234", "title": "The Lord of the Rings"}' \
--header 'Content-Type: application/json'
--header 'Content-Type: application/json' \
--data '{"isbn": "1234567891234", "title": "The Lord of the Rings"}'
```

```text
Expand All @@ -414,3 +414,10 @@ HTTP/1.1 422 Unprocessable Entity
As expected, we can't register the same ISBN twice.

Our API is now fully validated, which means security is improved, and the users can understand why the request failed.

<seealso style="cards">
<category ref="related">
<a href="ktor-server-integration.md" />
<a href="ktor-client-integration.md" />
</category>
</seealso>

0 comments on commit c5a95c6

Please sign in to comment.