diff --git a/gradle.properties b/gradle.properties index e64f1d5..d28419b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,3 +5,4 @@ org.gradle.parallel=false systemProp.org.gradle.internal.publish.checksums.insecure=true ossrhUsername=xx ossrhPassword=xx +kotlin.mpp.stability.nowarn=true diff --git a/tribune-ktor/build.gradle.kts b/tribune-ktor/build.gradle.kts index c950ab7..24c7302 100644 --- a/tribune-ktor/build.gradle.kts +++ b/tribune-ktor/build.gradle.kts @@ -9,14 +9,17 @@ kotlin { val jvmMain by getting { dependencies { api(project(":tribune-core")) - api(Ktor.server.core) + api("io.ktor:ktor-server-core:2.0.3") } } val jvmTest by getting { dependencies { + implementation("io.ktor:ktor-server-content-negotiation:2.0.3") + implementation("io.ktor:ktor-serialization-jackson:2.0.3") implementation(Testing.kotest.assertions.core) implementation(Testing.kotest.runner.junit5) + implementation("io.ktor:ktor-server-test-host:2.0.3") } } } diff --git a/tribune-ktor/src/jvmMain/kotlin/com/sksamuel/tribune/ktor/validate.kt b/tribune-ktor/src/jvmMain/kotlin/com/sksamuel/tribune/ktor/validate.kt index 70ce5b6..2b4bac5 100644 --- a/tribune-ktor/src/jvmMain/kotlin/com/sksamuel/tribune/ktor/validate.kt +++ b/tribune-ktor/src/jvmMain/kotlin/com/sksamuel/tribune/ktor/validate.kt @@ -7,22 +7,28 @@ import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call import io.ktor.server.request.receive -import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.util.pipeline.PipelineContext typealias Handler = suspend (ApplicationCall, NonEmptyList) -> Unit val defaultHandler: Handler<*> = { call, errors -> - call.respond(HttpStatusCode.BadRequest, errors.joinToString(", ")) + call.respondText( + status = HttpStatusCode.BadRequest, + text = errors.joinToString(", "), + contentType = ContentType.Text.Plain, + ) } val jsonHandler: Handler = { call, errors -> val newline = System.lineSeparator() val errorLines = errors.joinToString("\",$newline\"", "\"", "\"") { it.replace("\"", "\\\"") } val json = """[$newline$errorLines$newline]""" - call.respondText(json, ContentType.Application.Json) - call.respond(HttpStatusCode.BadRequest, json) + call.respondText( + status = HttpStatusCode.BadRequest, + text = json, + contentType = ContentType.Application.Json, + ) } suspend inline fun PipelineContext.withParsedInput( diff --git a/tribune-ktor/src/jvmTest/kotlin/com/sksamuel/tribune/ktor/WithParsedInputTest.kt b/tribune-ktor/src/jvmTest/kotlin/com/sksamuel/tribune/ktor/WithParsedInputTest.kt new file mode 100644 index 0000000..c6dd64a --- /dev/null +++ b/tribune-ktor/src/jvmTest/kotlin/com/sksamuel/tribune/ktor/WithParsedInputTest.kt @@ -0,0 +1,140 @@ +package com.sksamuel.tribune.ktor + +import com.sksamuel.tribune.core.Parser +import com.sksamuel.tribune.core.compose +import com.sksamuel.tribune.core.filter +import com.sksamuel.tribune.core.map +import com.sksamuel.tribune.core.strings.length +import com.sksamuel.tribune.core.strings.nonBlankString +import com.sksamuel.tribune.core.strings.notNullOrBlank +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.call +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.response.respond +import io.ktor.server.routing.post +import io.ktor.server.testing.testApplication + +class WithParsedInputTest : FunSpec() { + init { + test("happy path should pass off to lambda") { + testApplication { + install(ContentNegotiation) { jackson() } + routing { + post("/foo") { + withParsedInput(bookParser, jsonHandler) { + call.respond(HttpStatusCode.Created, "Book created") + } + } + } + val resp = client.post("/foo") { + contentType(ContentType.Application.Json) + setBody("""{ "author": "Willy Shakes", "title": "midwinters day dream", "isbn": "1234567890" }""") + } + resp.status shouldBe HttpStatusCode.Created + } + } + + test("unhappy path with default handler") { + testApplication { + install(ContentNegotiation) { jackson() } + routing { + post("/foo") { + withParsedInput(bookParser) { + call.respond(HttpStatusCode.Created, "Book created") + } + } + } + val resp = client.post("/foo") { + contentType(ContentType.Application.Json) + setBody("""{ "author": "Willy Shakes", "isbn": "123" }""") + } + resp.status shouldBe HttpStatusCode.BadRequest + resp.bodyAsText() shouldBe """Title must be provided, Valid ISBNs have length 10 or 13""" + } + } + + test("unhappy path with json handler") { + testApplication { + install(ContentNegotiation) { jackson() } + routing { + post("/foo") { + withParsedInput(bookParser, jsonHandler) { + call.respond(HttpStatusCode.Created, "Book created") + } + } + } + val resp = client.post("/foo") { + contentType(ContentType.Application.Json) + setBody("""{ "author": "Willy Shakes", "isbn": "123" }""") + } + resp.status shouldBe HttpStatusCode.BadRequest + resp.bodyAsText() shouldBe """[ +"Title must be provided", +"Valid ISBNs have length 10 or 13" +]""" + } + } + } +} + +data class BookInput( + val title: String?, + val author: String?, + val isbn: String?, +) + +data class ParsedBook( + val title: Title, + val author: Author, + val isbn: Isbn, +) + +@JvmInline +value class Title internal constructor(private val value: String) { + val asString get() = value +} + +@JvmInline +value class Author internal constructor(private val value: String) { + val asString get() = value +} + +@JvmInline +value class Isbn internal constructor(private val value: String) { + val asString get() = value +} + +// must be at least two tokens +val authorParser: Parser = + Parser.from() + .notNullOrBlank { "Author must be provided" } + .filter({ it.contains(" ") }) { "Author must be at least two names" } + .map { Author(it) } + +val titleParser: Parser = + Parser.nonBlankString { "Title must be provided" } + .map { Title(it) } + +// must be 10 or 13 characters +val isbnParser: Parser = + Parser.from() + .notNullOrBlank { "ISBN must be provided" } + .length({ it == 10 || it == 13 }) { "Valid ISBNs have length 10 or 13" } + .filter({ it.length == 10 || it.startsWith("9") }, { "13 Digit ISBNs must start with 9" }) + .map { Isbn(it) } + +val bookParser: Parser = + Parser.compose( + titleParser.contramap { it.title }, + authorParser.contramap { it.author }, + isbnParser.contramap { it.isbn }, + ::ParsedBook, + )