diff --git a/README.md b/README.md index 45a1046..bfdd9a0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/tyntec/ktor-problem.svg?branch=master)](https://travis-ci.org/tyntec/ktor-problem) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://raw.githubusercontent.com/zalando/problem/master/LICENSE) -Feature for [Ktor](https://ktor.io) implementing [Rfc7807](https://tools.ietf.org/html/rfc7807) +Plugin for [Ktor](https://ktor.io) implementing [Rfc7807](https://tools.ietf.org/html/rfc7807) It is inspired by Zalando's [Problem](https://github.com/zalando/problem) library. diff --git a/build.gradle.kts b/build.gradle.kts index df0566d..8eeb320 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,40 +1,43 @@ plugins { - kotlin("jvm") version "1.4.21" - id("org.jetbrains.dokka") version "0.9.18" - id("io.gitlab.arturbosch.detekt") version "1.15.0" + kotlin("jvm") version "1.8.0" + id("org.jetbrains.dokka") version "1.7.20" + id("io.gitlab.arturbosch.detekt") version "1.22.0" `maven-publish` signing } group = "com.tyntec" -version = "0.8" +version = "0.8.1" -val ktorVersion = "1.5.0" +val ktorVersion = "2.2.2" val jacksonVersion = "2.12.0" val junitVersion = "5.4.2" val ossUsername: String? by project val ossPassword: String? by project detekt { - input = files("src/main/kotlin") + source = files("src/main/kotlin") config = files("config/detekt/config.yml") } repositories { - jcenter() + mavenCentral() + mavenLocal() } dependencies { - implementation(ktor("jackson")) - implementation(ktor("gson")) + implementation(ktor("serialization-jackson")) + implementation(ktor("serialization-gson")) + implementation(ktor("client-content-negotiation")) implementation(kotlin("stdlib-jdk8")) - + detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.22.0") + api(ktorServer("core")) testImplementation(ktorServer("tests")) testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.17") testImplementation(junit("api")) testImplementation(junit("params")) - testRuntime(junit("engine", "5.3.2")) + testImplementation(junit("engine", "5.3.2")) } fun DependencyHandler.ktor(module: String, version: String = ktorVersion): Any = @@ -62,8 +65,8 @@ tasks.register("sourcesJar") { } tasks.register("javadocJar") { - dependsOn("dokka") - from(tasks["dokka"]) + dependsOn("dokkaHtml") + from(tasks["dokkaHtml"]) archiveClassifier.set("javadoc") } diff --git a/gradle.properties b/gradle.properties index 9d22842..a94cb65 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,4 @@ kotlin.code.style=official -ktorVersion=1.5.0 -kotlin_version=1.3.41 \ No newline at end of file +ktorVersion=2.2.2 +kotlin_version=1.8.0 +org.gradle.jvmargs=-XX:MaxMetaspaceSize=512m \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8fb61a1..9e4f374 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip diff --git a/out/production/classes/META-INF/ktor-problem.kotlin_module b/out/production/classes/META-INF/ktor-problem.kotlin_module deleted file mode 100644 index 5a1c897..0000000 Binary files a/out/production/classes/META-INF/ktor-problem.kotlin_module and /dev/null differ diff --git a/out/test/classes/META-INF/ktor-problem.kotlin_module b/out/test/classes/META-INF/ktor-problem.kotlin_module deleted file mode 100644 index a49347a..0000000 Binary files a/out/test/classes/META-INF/ktor-problem.kotlin_module and /dev/null differ diff --git a/src/main/kotlin/com/tyntec/ktor/problem/ProblemContext.kt b/src/main/kotlin/com/tyntec/ktor/problem/ProblemContext.kt index a661cb8..5de6cad 100644 --- a/src/main/kotlin/com/tyntec/ktor/problem/ProblemContext.kt +++ b/src/main/kotlin/com/tyntec/ktor/problem/ProblemContext.kt @@ -16,7 +16,7 @@ package com.tyntec.ktor.problem -import io.ktor.application.ApplicationCall +import io.ktor.server.application.ApplicationCall /** * Container class available when exception based configuration is used. diff --git a/src/main/kotlin/com/tyntec/ktor/problem/RFC7807Problems.kt b/src/main/kotlin/com/tyntec/ktor/problem/RFC7807Problems.kt index 719f8d5..97046a2 100644 --- a/src/main/kotlin/com/tyntec/ktor/problem/RFC7807Problems.kt +++ b/src/main/kotlin/com/tyntec/ktor/problem/RFC7807Problems.kt @@ -16,15 +16,15 @@ package com.tyntec.ktor.problem import com.tyntec.ktor.problem.ExceptionLogLevel.* -import io.ktor.application.* + import io.ktor.content.TextContent import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.content.OutgoingContent -import io.ktor.request.httpMethod -import io.ktor.request.path -import io.ktor.response.ApplicationSendPipeline -import io.ktor.response.respond +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* + import io.ktor.util.AttributeKey import io.ktor.util.pipeline.PipelineContext @@ -98,31 +98,31 @@ class RFC7807Problems(configuration: Configuration) { } } - companion object Feature : ApplicationFeature { + companion object Plugin : BaseApplicationPlugin { override val key = AttributeKey("Problems") override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): RFC7807Problems { val configuration = Configuration().apply(configure) - val feature = RFC7807Problems(configuration) + val plugin = RFC7807Problems(configuration) - if (feature.enableAutomaticResponseConversion) + if (plugin.enableAutomaticResponseConversion) pipeline.sendPipeline.intercept(ApplicationSendPipeline.After) { message -> - feature.interceptResponse(this, message) + plugin.interceptResponse(this, message) } pipeline.intercept(ApplicationCallPipeline.Monitoring) { try { proceed() } catch (e: Throwable) { - feature.interceptExceptions(this, call, e) + plugin.interceptExceptions(this, call, e) } } pipeline.intercept(ApplicationCallPipeline.Fallback) { - feature.notFound(call) + plugin.notFound(call) } - return feature + return plugin } } diff --git a/src/test/kotlin/com/tyntec/ktor/problem/ProblemsTest.kt b/src/test/kotlin/com/tyntec/ktor/problem/ProblemsTest.kt index b514b7b..56f4d19 100644 --- a/src/test/kotlin/com/tyntec/ktor/problem/ProblemsTest.kt +++ b/src/test/kotlin/com/tyntec/ktor/problem/ProblemsTest.kt @@ -22,19 +22,15 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.tyntec.ktor.problem.gson.gson import com.tyntec.ktor.problem.jackson.jackson -import io.ktor.application.call -import io.ktor.application.install -import io.ktor.content.TextContent -import io.ktor.http.ContentType -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.request.path -import io.ktor.response.respond -import io.ktor.routing.get -import io.ktor.routing.routing -import io.ktor.server.testing.contentType -import io.ktor.server.testing.handleRequest -import io.ktor.server.testing.withTestApplication +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.content.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* import org.junit.jupiter.api.Test class RFC7807ProblemsShould { @@ -42,34 +38,30 @@ class RFC7807ProblemsShould { val objectMapper = ObjectMapper().findAndRegisterModules() @Test - internal fun `respond with internal server error by default`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with internal server error by default`() = testApplication { + install(RFC7807Problems) { jackson { } } - application.routing { + routing { get("/error") { throw IllegalArgumentException() } } - handleRequest(HttpMethod.Get, "/error") { + val response = client.get("/error") + val content = objectMapper.readTree(response.body()) - }.response.let {response -> - - val content = objectMapper.readTree(response.content) - - assertThat(response.status()).isEqualTo(HttpStatusCode.InternalServerError) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("instance").textValue()).isEqualTo("/error") - assertThat(content.get("status").intValue()).isEqualTo(500) - assertThat(content.get("title").textValue()).isEqualTo("Internal Server Error") - } + assertThat(response.status).isEqualTo(HttpStatusCode.InternalServerError) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("instance").textValue()).isEqualTo("/error") + assertThat(content.get("status").intValue()).isEqualTo(500) + assertThat(content.get("title").textValue()).isEqualTo("Internal Server Error") } @Test - internal fun `respond with configured error object`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with configured error object`() = testApplication { + install(RFC7807Problems) { exception { ctx -> statusCode = HttpStatusCode.MethodNotAllowed detail = "You're not allowed to trigger this action" @@ -79,7 +71,7 @@ class RFC7807ProblemsShould { jackson { } } - application.routing { + routing { get("/error") { throw IllegalArgumentException() } @@ -88,25 +80,21 @@ class RFC7807ProblemsShould { } } - handleRequest(HttpMethod.Get, "/definedError") { - - }.response.let {response -> + val response = client.get("/definedError") + val content = objectMapper.readTree(response.body()) - val content = objectMapper.readTree(response.content) - - assertThat(response.status()).isEqualTo(HttpStatusCode.MethodNotAllowed) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("instance").textValue()).isEqualTo("bad resource") - assertThat(content.get("status").intValue()).isEqualTo(405) - assertThat(content.get("title").textValue()).isEqualTo("Method Not Allowed") - assertThat(content.get("type").textValue()).isEqualTo("Test-DefaultProblem") - assertThat(content.get("detail").textValue()).isEqualTo("You're not allowed to trigger this action") - } + assertThat(response.status).isEqualTo(HttpStatusCode.MethodNotAllowed) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("instance").textValue()).isEqualTo("bad resource") + assertThat(content.get("status").intValue()).isEqualTo(405) + assertThat(content.get("title").textValue()).isEqualTo("Method Not Allowed") + assertThat(content.get("type").textValue()).isEqualTo("Test-DefaultProblem") + assertThat(content.get("detail").textValue()).isEqualTo("You're not allowed to trigger this action") } @Test - internal fun `respond with customized default`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with customized default`() = testApplication { + install(RFC7807Problems) { default {ctx -> statusCode = HttpStatusCode.PaymentRequired instance = ctx.call.request.path() @@ -114,229 +102,205 @@ class RFC7807ProblemsShould { jackson { } } - application.routing { + routing { get("/customizedError") { throw IllegalStateException() } } - handleRequest(HttpMethod.Get, "/customizedError") { - - }.response.let {response -> + val response = client.get("/customizedError") - val content = objectMapper.readTree(response.content) + val content = objectMapper.readTree(response.body()) - assertThat(response.status()).isEqualTo(HttpStatusCode.PaymentRequired) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("instance").textValue()).isEqualTo("/customizedError") - assertThat(content.get("status").intValue()).isEqualTo(402) - assertThat(content.get("title").textValue()).isEqualTo("Payment Required") - } + assertThat(response.status).isEqualTo(HttpStatusCode.PaymentRequired) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("instance").textValue()).isEqualTo("/customizedError") + assertThat(content.get("status").intValue()).isEqualTo(402) + assertThat(content.get("title").textValue()).isEqualTo("Payment Required") } @Test - internal fun `respond with throwable problem implementation`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with throwable problem implementation`() = testApplication { + install(RFC7807Problems) { jackson { } } - application.routing { + routing { get("/customizedError") { throw TestBusinessException(businessDetail = "a test detail") } } - handleRequest(HttpMethod.Get, "/customizedError") { - - }.response.let {response -> + val response = client.get("/customizedError") - val content = objectMapper.readTree(response.content) + val content = objectMapper.readTree(response.body()) - assertThat(response.status()).isEqualTo(HttpStatusCode.BadRequest) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("status").intValue()).isEqualTo(400) - assertThat(content.get("title").textValue()).isEqualTo("Awesome title") - assertThat(content.get("businessDetail").textValue()).isEqualTo("a test detail") - } + assertThat(response.status).isEqualTo(HttpStatusCode.BadRequest) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("status").intValue()).isEqualTo(400) + assertThat(content.get("title").textValue()).isEqualTo("Awesome title") + assertThat(content.get("businessDetail").textValue()).isEqualTo("a test detail") } @Test - internal fun `use jackson override`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `use jackson override`() = testApplication { + install(RFC7807Problems) { jackson { propertyNamingStrategy = PropertyNamingStrategy.UPPER_CAMEL_CASE } } - application.routing { + routing { get("/error") { throw IllegalArgumentException() } } - handleRequest(HttpMethod.Get, "/error") { + val response = client.get("/error") - }.response.let {response -> + val content = objectMapper.readTree(response.body()) - val content = objectMapper.readTree(response.content) - - assertThat(response.status()).isEqualTo(HttpStatusCode.InternalServerError) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("Instance").textValue()).isEqualTo("/error") - assertThat(content.get("Status").intValue()).isEqualTo(500) - assertThat(content.get("Title").textValue()).isEqualTo("Internal Server Error") - } + assertThat(response.status).isEqualTo(HttpStatusCode.InternalServerError) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("Instance").textValue()).isEqualTo("/error") + assertThat(content.get("Status").intValue()).isEqualTo(500) + assertThat(content.get("Title").textValue()).isEqualTo("Internal Server Error") } @Test - internal fun `respond with a 404 problem on unknown paths`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with a 404 problem on unknown paths`() = testApplication { + install(RFC7807Problems) { jackson {} } - application.routing { + routing { get("/error") { throw IllegalArgumentException() } } - handleRequest(HttpMethod.Get, "/i-do-no-exist") { - - }.response.let {response -> + val response = client.get("/i-do-no-exist") - val content = objectMapper.readTree(response.content) + val content = objectMapper.readTree(response.body()) - assertThat(response.status()).isEqualTo(HttpStatusCode.NotFound) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("instance").textValue()).isEqualTo("/i-do-no-exist") - assertThat(content.get("status").intValue()).isEqualTo(404) - assertThat(content.get("title").textValue()).isEqualTo("Not Found") - } + assertThat(response.status).isEqualTo(HttpStatusCode.NotFound) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("instance").textValue()).isEqualTo("/i-do-no-exist") + assertThat(content.get("status").intValue()).isEqualTo(404) + assertThat(content.get("title").textValue()).isEqualTo("Not Found") } @Test - internal fun `respond with a problem matching the error response status`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with a problem matching the error response status`() = testApplication { + install(RFC7807Problems) { jackson {} } - application.routing { + routing { get("/error") { call.respond(HttpStatusCode.MethodNotAllowed) } } - handleRequest(HttpMethod.Get, "/error") { - - }.response.let {response -> + val response = client.get("/error") - val content = objectMapper.readTree(response.content) + val content = objectMapper.readTree(response.body()) - assertThat(response.status()).isEqualTo(HttpStatusCode.MethodNotAllowed) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("instance").textValue()).isEqualTo("/error") - assertThat(content.get("status").intValue()).isEqualTo(405) - assertThat(content.get("title").textValue()).isEqualTo("Method Not Allowed") - } + assertThat(response.status).isEqualTo(HttpStatusCode.MethodNotAllowed) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("instance").textValue()).isEqualTo("/error") + assertThat(content.get("status").intValue()).isEqualTo(405) + assertThat(content.get("title").textValue()).isEqualTo("Method Not Allowed") } @Test - internal fun `respond with unmodified response when enableAutomaticResponseConversion is disabled`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `respond with unmodified response when enableAutomaticResponseConversion is disabled`() = testApplication { + install(RFC7807Problems) { jackson {} enableAutomaticResponseConversion = false } - application.routing { + routing { get("/error") { call.respond(TextContent("test", ContentType.parse("application/my-problem") , HttpStatusCode.MethodNotAllowed)) } } - handleRequest(HttpMethod.Get, "/error") { + val response = client.get("/error") + assertThat(response.body()).isEqualTo("test") + assertThat(response.status).isEqualTo(HttpStatusCode.MethodNotAllowed) + assertThat(response.contentType()).isEqualTo(ContentType("application", "my-problem")) - }.response.let {response -> - assertThat(response.content).isEqualTo("test") - assertThat(response.status()).isEqualTo(HttpStatusCode.MethodNotAllowed) - assertThat(response.contentType()).isEqualTo(ContentType("application", "my-problem")) - } } @Test - internal fun `ignore all non error response codes`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `ignore all non error response codes`() = testApplication { + install(RFC7807Problems) { jackson {} } - application.routing { + routing { get("/redirect") { call.respond(HttpStatusCode.PermanentRedirect, "Hello world") } } - handleRequest(HttpMethod.Get, "/redirect") { - - }.response.let {response -> + val client = createClient { + followRedirects = false + } - val content = response.content!! + val response = client.get("/redirect") + val content = response.body() - assertThat(response.status()).isEqualTo(HttpStatusCode.PermanentRedirect) - assertThat(content).isEqualTo("Hello world") - } + assertThat(response.status).isEqualTo(HttpStatusCode.PermanentRedirect) + assertThat(content).isEqualTo("Hello world") } @Test - internal fun `use gson`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `use gson`() = testApplication { + install(RFC7807Problems) { gson{} } - application.routing { + routing { get("/error") { throw IllegalArgumentException() } } - handleRequest(HttpMethod.Get, "/error") { + val response = client.get("/error") - }.response.let {response -> + val content = objectMapper.readTree(response.body()) + println(response.body()) - val content = objectMapper.readTree(response.content) - println(response.content) - - assertThat(response.status()).isEqualTo(HttpStatusCode.InternalServerError) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("instance").textValue()).isEqualTo("/error") - assertThat(content.get("status").intValue()).isEqualTo(500) - assertThat(content.get("title").textValue()).isEqualTo("Internal Server Error") - } + assertThat(response.status).isEqualTo(HttpStatusCode.InternalServerError) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("instance").textValue()).isEqualTo("/error") + assertThat(content.get("status").intValue()).isEqualTo(500) + assertThat(content.get("title").textValue()).isEqualTo("Internal Server Error") } @Test - internal fun `use custom problem converter`() = withTestApplication{ - application.install(RFC7807Problems) { + internal fun `use custom problem converter`() = testApplication { + install(RFC7807Problems) { converter(TestProblemConverter()) } - application.routing { + routing { get("/error") { throw IllegalArgumentException() } } - handleRequest(HttpMethod.Get, "/error") { - - }.response.let {response -> + val response = client.get("/error") - val content = objectMapper.readTree(response.content) - println(response.content) + val content = objectMapper.readTree(response.body()) + println(response.body()) - assertThat(response.status()).isEqualTo(HttpStatusCode.InternalServerError) - assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) - assertThat(content.get("my_title").textValue()).isEqualTo("is testie") - } + assertThat(response.status).isEqualTo(HttpStatusCode.InternalServerError) + assertThat(response.contentType()).isEqualTo(ContentType("application", "problem+json")) + assertThat(content.get("my_title").textValue()).isEqualTo("is testie") } - } class TestProblemConverter : ProblemConverter { @@ -347,7 +311,6 @@ class TestProblemConverter : ProblemConverter { } """.trimIndent() } - } class TestBusinessException(