diff --git a/documentation/images/social-ktor-server.png b/documentation/images/social-ktor-server.png index dff70cc1..0665cdff 100644 Binary files a/documentation/images/social-ktor-server.png and b/documentation/images/social-ktor-server.png differ diff --git a/documentation/topics/getting-started.md b/documentation/topics/getting-started.md index fd760d4c..c765c9c6 100644 --- a/documentation/topics/getting-started.md +++ b/documentation/topics/getting-started.md @@ -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. - + Add KSP to your plugin list; make sure to use the appropriate @@ -33,7 +33,7 @@ dependencies { - + Add KSP to your plugin list; make sure to use the appropriate diff --git a/documentation/topics/ktor-server-integration.md b/documentation/topics/ktor-server-integration.md index c212aa65..10a45dbb 100644 --- a/documentation/topics/ktor-server-integration.md +++ b/documentation/topics/ktor-server-integration.md @@ -21,7 +21,7 @@ automatically validate incoming data. Before using %product% with Ktor, you need to add the following dependency to your Gradle script: - + implementation("dev.nesk.akkurate:akkurate-ktor-server:%version%") @@ -29,10 +29,10 @@ implementation("dev.nesk.akkurate:akkurate-ktor-server:%version%") -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: - + implementation("io.ktor:ktor-server-content-negotiation:$ktor_version") @@ -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, diff --git a/documentation/topics/ktor-validation-tutorial.md b/documentation/topics/ktor-validation-tutorial.md index 92bd5583..b8d7a881 100644 --- a/documentation/topics/ktor-validation-tutorial.md +++ b/documentation/topics/ktor-validation-tutorial.md @@ -20,14 +20,13 @@ unique ISBN, 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 Add plugins | Generate project 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 Download. 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 @@ -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 Routing.kt file and copy the following code in the `configureRouting` function: @@ -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? @@ -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 ", @@ -183,7 +185,7 @@ HTTP/1.1 200 OK -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: @@ -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 @@ -221,13 +223,12 @@ HTTP/1.1 500 Internal Server Error -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 @@ -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 } ``` @@ -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: @@ -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: + + + +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 -> - 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 Application.kt file: @@ -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 Routing.kt file, navigate to the `configureRouting` function, then the `install(StatusPages) {}` -lambda, and add the following code: - -```kotlin -exception { 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 @@ -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." +} ``` @@ -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 @@ -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. diff --git a/examples/ktor-server/build.gradle.kts b/examples/ktor-server/build.gradle.kts index ff79c29f..ed2f3e0a 100755 --- a/examples/ktor-server/build.gradle.kts +++ b/examples/ktor-server/build.gradle.kts @@ -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") } diff --git a/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Databases.kt b/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Databases.kt index a3e59994..bf09a52e 100755 --- a/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Databases.kt +++ b/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Databases.kt @@ -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 } } diff --git a/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Routing.kt b/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Routing.kt index ce078efa..0f751581 100755 --- a/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Routing.kt +++ b/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Routing.kt @@ -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 { call, cause -> - call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) - } - exception { call, cause -> - call.respond(HttpStatusCode.UnprocessableEntity, cause.reasons) - } - } - routing { post("/books") { val book = call.receive() diff --git a/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Validation.kt b/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Validation.kt index a11171b9..9c0aa48e 100644 --- a/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Validation.kt +++ b/examples/ktor-server/src/main/kotlin/dev/nesk/akkurate/examples/ktor/server/plugins/Validation.kt @@ -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 -> - 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 } } } diff --git a/gradle.properties b/gradle.properties index b5d05d26..0d2dfb50 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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