Skip to content

Commit 6457152

Browse files
committed
Adds Ktor error handling and example API
Implements centralized error handling using Ktor's StatusPages feature. Introduces `KtpRspEx` and `KtpRspExNotFound` for standardized exception reporting. Includes custom error details and extends them via reflection. Provides an example API with error scenarios. Refactors example code to utilize the new error handling.
1 parent 291e56a commit 6457152

10 files changed

Lines changed: 508 additions & 63 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Use four-space indentation and idiomatic Kotlin style. Keep packages under the `
1717
Unit and integration tests rely on `kotlin.test` and Ktor's `testApplication` utilities. Name test files with the `*Test.kt` suffix and ensure each new feature has at least one covering test. Execute `./gradlew test` for module-level runs or `./gradlew check` for the full suite; investigate reports under `build/reports/tests/`. Aim to keep fast-running tests, mocking external services where needed.
1818

1919
## Commit & Pull Request Guidelines
20-
Write concise, present-tense commit subjects (e.g. `Add Vite static routing helpers`). Squash fixups before pushing. Pull requests should summarize the change, link relevant issues, list manual/automated test results, and include screenshots or config snippets when altering HTTP surfaces or build outputs.
20+
NEVER stage, commit, or push with git.
2121

2222
## Publishing & Release Notes
2323
Main-branch commits automatically produce releases; verify version bumps in `gradle.properties` when cutting tagged builds. Update `README.md` and module-level docs when introducing new public APIs, and call out breaking changes in the PR description so the release notes stay accurate.

examples/ktp-example/src/main/kotlin/ktp/example/Ktp.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package ktp.example
22

33
import io.ktor.server.application.*
4-
import ktp.example.api.installApiHello
4+
import ktp.example.api.installApi
55
import ktp.example.plugins.configureAdministration
66
import net.ghue.ktp.ktor.app.debug.ConfigDebugInfoPlugin
77
import net.ghue.ktp.ktor.plugin.installDefaultPlugins
@@ -21,7 +21,7 @@ val ktpApp = ktpAppCreate {
2121
installDefaultPlugins(config)
2222
configureAdministration()
2323
install(ConfigDebugInfoPlugin)
24-
installApiHello()
24+
installApi()
2525
}
2626
}
2727

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package ktp.example.api
2+
3+
import io.ktor.http.HttpStatusCode
4+
import io.ktor.server.application.Application
5+
import io.ktor.server.response.respondText
6+
import io.ktor.server.routing.get
7+
import io.ktor.server.routing.routing
8+
import net.ghue.ktp.ktor.error.KtpRspExNotFound
9+
import net.ghue.ktp.ktor.error.ktpRspError
10+
11+
fun Application.installApi() {
12+
routing {
13+
get("/") { call.respondText("KTP is running!") }
14+
get("/error") {
15+
ktpRspError {
16+
internalMessage = "Secret error data"
17+
status = HttpStatusCode.UnprocessableEntity
18+
title = "bad thing happened"
19+
detail = "something bad happened when you called /error"
20+
extra("foo", "bar")
21+
}
22+
}
23+
get("/error2") { throw KtpRspExNotFound(name = "User", id = "user1234") }
24+
}
25+
}

examples/ktp-example/src/main/kotlin/ktp/example/api/ApiHello.kt

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package net.ghue.ktp.ktor.error
2+
3+
import io.ktor.http.ContentType
4+
import io.ktor.server.application.ApplicationCall
5+
import io.ktor.server.request.httpMethod
6+
import io.ktor.server.request.path
7+
import io.ktor.server.response.respondText
8+
import kotlin.reflect.KVisibility
9+
import kotlin.reflect.full.declaredMemberProperties
10+
import kotlin.reflect.jvm.isAccessible
11+
import kotlinx.serialization.json.JsonArray
12+
import kotlinx.serialization.json.JsonElement
13+
import kotlinx.serialization.json.JsonNull
14+
import kotlinx.serialization.json.JsonObject
15+
import kotlinx.serialization.json.JsonPrimitive
16+
import kotlinx.serialization.json.buildJsonObject
17+
import kotlinx.serialization.json.put
18+
import net.ghue.ktp.log.log
19+
20+
private val standardProblemKeys = setOf("type", "status", "title", "detail", "instance")
21+
private val ignoredFieldNames =
22+
setOf("message", "internalMessage", "extraFields", "cause", "serialVersionUID", "Companion")
23+
24+
suspend fun processKtpRspEx(call: ApplicationCall, ex: KtpRspEx) {
25+
val requestPath = call.request.path()
26+
val reflectedFields = extractExtendedFields(ex)
27+
val problemJson = buildJsonObject {
28+
put("type", ex.type.ifBlank { "about:blank" })
29+
put("title", ex.title.ifBlank { ex.status.description })
30+
put("status", ex.status.value)
31+
put("instance", requestPath)
32+
if (ex.detail.isNotBlank()) {
33+
put("detail", ex.detail)
34+
}
35+
if (ex::class != KtpRspEx::class) {
36+
put("class", ex::class.qualifiedName ?: "Unknown")
37+
}
38+
reflectedFields.forEach { (key, value) ->
39+
if (key !in standardProblemKeys) {
40+
put(key, value)
41+
}
42+
}
43+
ex.extraFields.forEach { (key, value) ->
44+
if (key !in standardProblemKeys) {
45+
put(key, value.toJsonElement())
46+
}
47+
}
48+
}
49+
50+
log {}
51+
.error(ex) {
52+
"Error processing ${call.request.httpMethod.value} on $requestPath: '${ex.message}' $problemJson"
53+
}
54+
55+
call.respondText(
56+
text = problemJson.toString(),
57+
contentType = ContentType.Application.ProblemJson,
58+
status = ex.status,
59+
)
60+
}
61+
62+
private fun extractExtendedFields(ex: KtpRspEx): JsonObject = buildJsonObject {
63+
if (ex::class == KtpRspEx::class) {
64+
return@buildJsonObject
65+
}
66+
ex::class.declaredMemberProperties.forEach { property ->
67+
if (property.visibility != KVisibility.PUBLIC) {
68+
return@forEach
69+
}
70+
val propertyName = property.name
71+
if (
72+
propertyName.startsWith("$") ||
73+
propertyName in ignoredFieldNames ||
74+
propertyName in standardProblemKeys
75+
) {
76+
return@forEach
77+
}
78+
property.getter.isAccessible = true
79+
put(propertyName, property.getter.call(ex).toJsonElement())
80+
}
81+
}
82+
83+
@Suppress("CyclomaticComplexMethod")
84+
private fun Any?.toJsonElement(): JsonElement =
85+
when (this) {
86+
null -> JsonNull
87+
is JsonElement -> this
88+
is String -> JsonPrimitive(this)
89+
is Number -> JsonPrimitive(this)
90+
is Boolean -> JsonPrimitive(this)
91+
is Enum<*> -> JsonPrimitive(this.name)
92+
is Map<*, *> ->
93+
buildJsonObject {
94+
this@toJsonElement.forEach { (key, value) ->
95+
key?.toString()?.let { put(it, value.toJsonElement()) }
96+
}
97+
}
98+
is Iterable<*> -> JsonArray(this.map { it.toJsonElement() })
99+
is Array<*> -> JsonArray(this.map { it.toJsonElement() })
100+
is IntArray -> JsonArray(this.map { JsonPrimitive(it) })
101+
is LongArray -> JsonArray(this.map { JsonPrimitive(it) })
102+
is ShortArray -> JsonArray(this.map { JsonPrimitive(it) })
103+
is FloatArray -> JsonArray(this.map { JsonPrimitive(it) })
104+
is DoubleArray -> JsonArray(this.map { JsonPrimitive(it) })
105+
is BooleanArray -> JsonArray(this.map { JsonPrimitive(it) })
106+
is ByteArray -> JsonArray(this.map { JsonPrimitive(it) })
107+
is CharArray -> JsonArray(this.map { JsonPrimitive(it.toString()) })
108+
else -> JsonPrimitive(this.toString())
109+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package net.ghue.ktp.ktor.error
2+
3+
import io.ktor.http.HttpStatusCode
4+
5+
class KtpRspExNotFound(name: String, val id: String, cause: Throwable? = null) :
6+
KtpRspEx(
7+
status = HttpStatusCode.NotFound,
8+
title = "$name Not Found",
9+
detail = "The $name with ID '$id' does not exist.",
10+
cause = cause,
11+
)
12+
13+
open class KtpRspEx(
14+
val internalMessage: String? = null,
15+
val status: HttpStatusCode = HttpStatusCode.InternalServerError,
16+
val type: String = "",
17+
val title: String = "",
18+
val detail: String = "",
19+
val extraFields: Map<String, Any> = emptyMap(),
20+
override val cause: Throwable? = null,
21+
) : RuntimeException(internalMessage, cause)
22+
23+
class ErrorBuilder {
24+
var status: HttpStatusCode = HttpStatusCode.InternalServerError
25+
var type: String = ""
26+
var title: String = ""
27+
var detail: String = ""
28+
var internalMessage: String = ""
29+
var cause: Throwable? = null
30+
private val extraFields = mutableMapOf<String, Any>()
31+
32+
fun extra(key: String, value: String) {
33+
extraFields[key] = value
34+
}
35+
36+
fun extra(key: String, value: Int) {
37+
extraFields[key] = value
38+
}
39+
40+
fun extra(key: String, value: Boolean) {
41+
extraFields[key] = value
42+
}
43+
44+
fun buildExtraFields(): Map<String, Any> = extraFields.toMap()
45+
}
46+
47+
inline fun ktpRspError(builder: ErrorBuilder.() -> Unit): Nothing {
48+
val builderInstance = ErrorBuilder().apply(builder)
49+
throw KtpRspEx(
50+
internalMessage = builderInstance.internalMessage,
51+
status = builderInstance.status,
52+
type = builderInstance.type,
53+
title = builderInstance.title,
54+
detail = builderInstance.detail,
55+
extraFields = builderInstance.buildExtraFields(),
56+
cause = builderInstance.cause,
57+
)
58+
}

libs/ktp-ktor/src/main/kotlin/net/ghue/ktp/ktor/plugin/Default.kt

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
package net.ghue.ktp.ktor.plugin
22

3-
import io.ktor.http.*
4-
import io.ktor.serialization.kotlinx.json.*
5-
import io.ktor.server.application.*
6-
import io.ktor.server.plugins.cachingheaders.*
7-
import io.ktor.server.plugins.calllogging.*
8-
import io.ktor.server.plugins.compression.*
9-
import io.ktor.server.plugins.conditionalheaders.*
10-
import io.ktor.server.plugins.contentnegotiation.*
11-
import io.ktor.server.plugins.forwardedheaders.*
12-
import io.ktor.server.plugins.hsts.*
13-
import io.ktor.server.plugins.statuspages.*
14-
import io.ktor.server.request.*
15-
import io.ktor.server.resources.*
16-
import io.ktor.server.response.*
3+
import io.ktor.http.ContentType
4+
import io.ktor.http.HttpStatusCode
5+
import io.ktor.serialization.kotlinx.json.json
6+
import io.ktor.server.application.Application
7+
import io.ktor.server.application.install
8+
import io.ktor.server.plugins.cachingheaders.CachingHeaders
9+
import io.ktor.server.plugins.calllogging.CallLogging
10+
import io.ktor.server.plugins.compression.Compression
11+
import io.ktor.server.plugins.compression.matchContentType
12+
import io.ktor.server.plugins.compression.minimumSize
13+
import io.ktor.server.plugins.conditionalheaders.ConditionalHeaders
14+
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
15+
import io.ktor.server.plugins.forwardedheaders.ForwardedHeaders
16+
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
17+
import io.ktor.server.plugins.hsts.HSTS
18+
import io.ktor.server.plugins.statuspages.StatusPages
19+
import io.ktor.server.request.httpMethod
20+
import io.ktor.server.request.path
21+
import io.ktor.server.resources.Resources
22+
import io.ktor.server.response.respondText
1723
import net.ghue.ktp.config.KtpConfig
24+
import net.ghue.ktp.ktor.error.KtpRspEx
25+
import net.ghue.ktp.ktor.error.processKtpRspEx
1826
import net.ghue.ktp.log.log
1927
import org.slf4j.event.Level
2028

@@ -48,6 +56,7 @@ fun Application.installDefaultPlugins(config: KtpConfig) {
4856
}
4957
}
5058
install(StatusPages) {
59+
exception<KtpRspEx>(::processKtpRspEx)
5160
exception<Throwable> { call, cause ->
5261
log {}
5362
.warn(cause) {
Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,36 @@
1-
package net.ghue.ktp.ktor.plugin
2-
3-
import io.ktor.http.*
4-
import io.ktor.server.plugins.origin
5-
import io.ktor.server.routing.RoutingCall
6-
import kotlin.contracts.ExperimentalContracts
7-
import kotlin.contracts.InvocationKind
8-
import kotlin.contracts.contract
9-
import kotlin.time.Duration
10-
import kotlinx.coroutines.CoroutineScope
11-
import kotlinx.coroutines.Dispatchers
12-
import kotlinx.coroutines.slf4j.MDCContext
13-
import kotlinx.coroutines.withContext
14-
15-
fun cacheControlMaxAge(
16-
maxAge: Duration,
17-
visibility: CacheControl.Visibility = CacheControl.Visibility.Public,
18-
): CacheControl {
19-
return CacheControl.MaxAge(
20-
maxAgeSeconds = maxAge.inWholeSeconds.toInt(),
21-
visibility = visibility,
22-
)
23-
}
24-
25-
fun RoutingCall.originUrl(path: List<String> = emptyList()): Url = buildUrl {
26-
protocol = URLProtocol.createOrDefault(request.origin.scheme)
27-
host = request.origin.serverHost
28-
port = request.origin.serverPort
29-
pathSegments = path
30-
}
31-
32-
@OptIn(ExperimentalContracts::class)
33-
suspend inline fun <T> withIoContext(noinline block: suspend CoroutineScope.() -> T): T {
34-
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
35-
return withContext(context = Dispatchers.IO + MDCContext(), block = block)
36-
}
1+
package net.ghue.ktp.ktor.plugin
2+
3+
import io.ktor.http.*
4+
import io.ktor.server.application.ApplicationCall
5+
import io.ktor.server.plugins.origin
6+
import kotlin.contracts.ExperimentalContracts
7+
import kotlin.contracts.InvocationKind
8+
import kotlin.contracts.contract
9+
import kotlin.time.Duration
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.slf4j.MDCContext
13+
import kotlinx.coroutines.withContext
14+
15+
fun cacheControlMaxAge(
16+
maxAge: Duration,
17+
visibility: CacheControl.Visibility = CacheControl.Visibility.Public,
18+
): CacheControl {
19+
return CacheControl.MaxAge(
20+
maxAgeSeconds = maxAge.inWholeSeconds.toInt(),
21+
visibility = visibility,
22+
)
23+
}
24+
25+
fun ApplicationCall.originUrl(path: List<String> = emptyList()): Url = buildUrl {
26+
protocol = URLProtocol.createOrDefault(request.origin.scheme)
27+
host = request.origin.serverHost
28+
port = request.origin.serverPort
29+
pathSegments = path
30+
}
31+
32+
@OptIn(ExperimentalContracts::class)
33+
suspend inline fun <T> withIoContext(noinline block: suspend CoroutineScope.() -> T): T {
34+
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
35+
return withContext(context = Dispatchers.IO + MDCContext(), block = block)
36+
}

0 commit comments

Comments
 (0)