diff --git a/documentation/akkurate.tree b/documentation/akkurate.tree index fb42f419..5c7890d6 100644 --- a/documentation/akkurate.tree +++ b/documentation/akkurate.tree @@ -15,14 +15,8 @@ - - - - - - - + diff --git a/documentation/cfg/glossary.xml b/documentation/cfg/glossary.xml index 2d1bae45..24a2e0fd 100644 --- a/documentation/cfg/glossary.xml +++ b/documentation/cfg/glossary.xml @@ -3,4 +3,6 @@ Don't Repeat Yourself Domain-Specific Language + Data Access Object + International Standard Book Number diff --git a/documentation/topics/getting-started.md b/documentation/topics/getting-started.md index c833586a..abcd8fc7 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 diff --git a/documentation/topics/ktor-validation-tutorial.md b/documentation/topics/ktor-validation-tutorial.md new file mode 100644 index 00000000..a2db3776 --- /dev/null +++ b/documentation/topics/ktor-validation-tutorial.md @@ -0,0 +1,401 @@ +# Server-side validation with Ktor + +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 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=akkuratewithktor.example.com&artifact=com.example.akkuratewithktor.akkuratewithktor&kotlinVersion=1.9.20&ktorVersion=2.3.5&buildSystem=GRADLE_KTS&engine=NETTY&configurationIn=HOCON&plugins=routing%2Ccontent-negotiation%2Ckotlinx-serialization%2Cstatus-pages%2Cexposed&addSampleCode=false) +Then click Add plugins | Generate project and, 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. + +## Defining and persisting the data model + +A book is composed of an [ISBN](https://en.wikipedia.org/wiki/ISBN) and a title. ISBN stands for _International Standard +Book Number_, a unique identifier assigned to each book. + +To represent the book in the application, we will use the following data class: + +```kotlin +@Serializable +data class Book( + val isbn: String = "", + val title: String = "", +) +``` + +The `@Serializable` annotation is necessary because this class will be used to transmit JSON data over HTTP. + +To store the `Book` class, we will use [Exposed](https://github.com/JetBrains/Exposed) and follow what's recommended in +[Database persistence with Exposed](https://ktor.io/docs/interactive-website-add-persistence.html); allowing us to +easily query our database and keep this tutorial as simple as possible. + +First, we need to define our database schema to store all the properties of our books: + +```kotlin +object Books : Table() { + val isbn = char("isbn", length = 13) + val title = varchar("title", length = 50) + + override val primaryKey = PrimaryKey(isbn) +} +``` + +We're making the `isbn` the primary key, since it's already a unique identifier. It's also a `char(13)` type instead of +a `varchar` because an ISBN is always composed of 13 characters. + +Next, we have to create our DAO, which will provide two essential methods: + +- `create()` to store a new book in the database, +- `list()` to retrieve all the books stored in our database. + +```kotlin +class BookDao(database: Database) { + init { + transaction(database) { + SchemaUtils.create(Books) + } + } + + private suspend fun dbQuery(block: suspend () -> T): T = + newSuspendedTransaction(Dispatchers.IO) { block() } + + private fun ResultRow.toBook() = Book( + isbn = this[Books.isbn], + title = this[Books.title], + ) + + suspend fun create(book: Book): Unit = dbQuery { + Books.insert { + it[isbn] = book.isbn + it[title] = book.title + } + } + + suspend fun list(): List = dbQuery { + Books.selectAll().map { it.toBook() } + } +} +``` + +Finally, we need to instantiate our DAO with a database connection when the application starts up. Open the +Databases.kt file, create a top level variable `lateinit var bookDao: BookDao`, and define it inside +the `configureDatabases` function: + +```kotlin +lateinit var bookDao: BookDao + +fun Application.configureDatabases() { + val database = Database.connect(/* ... */) + bookDao = BookDao(database) +} +``` + +> This tutorial stores the DAO inside a global variable for simplicity. However, this is not recommended for a +> production +> application. You should instead use a Dependency Injection framework like [Koin](https://insert-koin.io/) +> or [Kodein-DI](https://kosi-libs.org/kodein/7.19/index.html). + +{style="warning"} + +## Handling the requests + +We will need two routes for HTTP API; `POST /books` to register a new book to the database, and `GET /books` to list all +the books in the database. + +Open the Routing.kt file and copy the following code in the `configureRouting` function: + +```kotlin +routing { + post("/books") { + val book = call.receive() + bookDao.create(book) + call.respond(HttpStatusCode.Created) + } + + get("/books") { + val books = bookDao.list() + call.respond(HttpStatusCode.OK, books) + } +} +``` + +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? + +Our API is done, you can run it either with IntelliJ or with the `./gradlew run` command. + +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' +``` + +```text +HTTP/1.1 201 Created +``` + + + +Now list the books: + + + +```shell +curl 127.0.0.1:8080/books -v +``` + +```text +HTTP/1.1 200 OK +[ + { + "isbn": "123 ", + "title": "" + } +] +``` + + + +We can see two issues in this response; the ISBN is now filled up to the 13 required characters, and we shouldn't allow +empty titles. + +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' +``` + +```text +HTTP/1.1 500 Internal Server Error +``` + + + +Now we have an internal error because we're trying to insert a second book with the same ISBN as the first one. This is +impossible due to the ISBN being the primary key of the table. + +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' +``` + +```text +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 +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). + +### Enhancing the DAO + +Before writing any validation code, we need to add the following method to our `BookDao` class to allow searching a book +by its ISBN: + +```kotlin +suspend fun findByIsbn(isbn: String): Book? = dbQuery { + Books.select { Books.isbn eq isbn } + .map { it.toBook() } + .singleOrNull() +} +``` + +That way, we can check if a book exists by running `bookDao.findByIsbn(isbn) != null`. + +### Writing validation constraints + +First, we must install %product% dependencies: + + + +Then mark the `Book` class with the `@Validate` annotation: + +```kotlin +@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: + +```kotlin +val validateBook = Validator.suspendable { dao -> + isbn { + val (isValidIsbn) = isMatching(Regex("""\d{13}""")) otherwise { + "Must be a valid ISBN (13 digits)" + } + + if (isValidIsbn) { + constrain { dao.findByIsbn(it) == null } otherwise { + "This ISBN is already registered" + } + } + } + + title { + isNotBlank() + hasLengthLowerThanOrEqualTo(50) + } +} +``` + +> Remember to trigger code generation to access the properties inside the `Validator`, you can do this by building +> your project. + +{style="note"} + +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.findByIsbn` method, to ensure a book isn't already registered in our database. +- The call to `findByIsbn` 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. +- We ensure the title is not blank but also that it isn't longer than 50 characters, otherwise the database will reject + it with an exception. + +## 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): + +```kotlin +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: + +```kotlin +fun Application.configureValidation() { + install(RequestValidation) { + validate { book -> + when (val result = validateBook(bookDao, book)) { + is dev.nesk.akkurate.ValidationResult.Success -> ValidationResult.Valid + is dev.nesk.akkurate.ValidationResult.Failure -> { + val reasons = result.violations.map { + "${it.path.joinToString(".")}: ${it.message}" + } + ValidationResult.Invalid(reasons) + } + } + } + } +} +``` + +Notice how we execute our `validationBook` 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, open the Application.kt file: + +```kotlin +fun Application.module() { + configureSerialization() + configureDatabases() + configureRouting() + configureValidation() // Register the validation handlers +} +``` + +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. + +## Conformance checking + +It's time to test our API once again. + +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' +``` + +```text +HTTP/1.1 422 Unprocessable Entity +[ + "isbn: Must be a valid ISBN (13 digits)", + "title: Must not be blank" +] +``` + + + +This time, our validation took care of rejecting the request. + +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' +``` + +```text +HTTP/1.1 201 Created +``` + + + +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" +] +``` + +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.