Skip to content

Commit

Permalink
Upgraded to ktor2, added ktor tests
Browse files Browse the repository at this point in the history
  • Loading branch information
sksamuel committed Jul 24, 2022
1 parent 6e16e1f commit 574c94b
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 5 deletions.
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion tribune-ktor/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<E> = suspend (ApplicationCall, NonEmptyList<E>) -> 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<String> = { 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 <reified I : Any, A, E> PipelineContext<Unit, ApplicationCall>.withParsedInput(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String?, Author, String> =
Parser.from<String?>()
.notNullOrBlank { "Author must be provided" }
.filter({ it.contains(" ") }) { "Author must be at least two names" }
.map { Author(it) }

val titleParser: Parser<String?, Title, String> =
Parser.nonBlankString { "Title must be provided" }
.map { Title(it) }

// must be 10 or 13 characters
val isbnParser: Parser<String?, Isbn, String> =
Parser.from<String?>()
.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<BookInput, ParsedBook, String> =
Parser.compose(
titleParser.contramap { it.title },
authorParser.contramap { it.author },
isbnParser.contramap { it.isbn },
::ParsedBook,
)

0 comments on commit 574c94b

Please sign in to comment.