
RFC 9457 Problem Details implementation for HTTP APIs: models and serializes problem documents, integrates with Ktor wiring, supports JSON/XML, typed extensions, validation-to-errors mapping, client decoding.
RFC 9457 Problem Details for HTTP APIs is the standard
machine-readable body an HTTP API sends to say what went wrong. This library gives you that document
as a Kotlin type, with codecs for JSON and XML. On top of it sits the Ktor wiring you would otherwise
hand-write into StatusPages and ContentNegotiation.
Declare a problem type once, in domain code that imports nothing web-related:
object OutOfCredit : ProblemType {
override val typeUri: String = "https://example.com/probs/out-of-credit"
override val title: String = "You do not have enough credit."
override val status: Int = 403
}Wire Ktor up at startup:
install(ContentNegotiation) { problemJson() }
install(StatusPages) { problemDetails { } }Throw it wherever the failure is detected:
throw OutOfCredit.exception(detail = "Your current balance is 30, but that costs 50.")The caller gets this:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://example.com/probs/out-of-credit",
"status": 403,
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc"
}instance comes from the request path and status from the problem type. The two install calls
also handle unmapped exceptions, preventing stack traces from reaching the client.
dependencies {
implementation(platform("io.github.ilyankin:problem-details-bom:1.0.0-RC1"))
implementation("io.github.ilyankin:problem-details-core")
implementation("io.github.ilyankin:problem-details-ktor")
}The BOM keeps every module on one version, so the modules themselves need no version.
| Requires | Version |
|---|---|
| JDK | 17+ |
| Kotlin | 2.4+ (built with 2.4.20) |
| Ktor | 3.6+, for the Ktor modules |
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.ilyankin</groupId>
<artifactId>problem-details-bom</artifactId>
<version>1.0.0-RC1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.ilyankin</groupId>
<artifactId>problem-details-core</artifactId>
</dependency>
</dependencies>| To | Add |
|---|---|
| Return RFC 9457 documents from a Ktor server |
problem-details-core + problem-details-ktor
|
| Build or parse documents without a web framework | problem-details-core |
Map RequestValidation failures to errors[]
|
+ problem-details-ktor-validation
|
| Show problem responses in generated OpenAPI documents | + problem-details-ktor-openapi (and problem-details-ktor-openapi-xml for XML) |
Answer application/problem+xml alongside JSON |
+ problem-details-xml and problem-details-ktor-xml
|
| Decode problem responses from APIs you call | + problem-details-ktor-client (and problem-details-ktor-client-xml for XML) |
All modules share the same version, and problem-details-bom pins them together.
| Artifact | Contains | Javadoc |
|---|---|---|
problem-details-core |
Problem, ProblemType, ProblemValue, the problem { } builder, typed extension access, and flattening JSON codec |
javadoc.io |
problem-details-ktor |
respondProblem, ProblemDetailsCatalog, problemDetails { }, problemJson()
|
javadoc.io |
problem-details-xml |
The RFC Appendix B XML codec (ProblemXml) |
javadoc.io |
problem-details-ktor-xml |
Registers the XML codec with Ktor ContentNegotiation (problemXml()) |
javadoc.io |
problem-details-ktor-client |
problemJson(), decoding recognized problem responses into ProblemException
|
javadoc.io |
problem-details-ktor-client-xml |
problemXml(), decoding XML problem responses into ProblemException
|
javadoc.io |
problem-details-ktor-validation |
invalidField/invalidFields, jsonPointer, requestValidation(type) to map RequestValidationException to errors[] with JSON Pointer |
javadoc.io |
problem-details-ktor-openapi |
Route.problemResponses(catalog), problemsFrom, problemResponse, problemDefault, ProblemSchemas for generated OpenAPI documents |
javadoc.io |
problem-details-ktor-openapi-xml |
problemXmlContent() for application/problem+xml in OpenAPI |
javadoc.io |
problem-details-bom |
Versions for every artifact above, imported with platform(...) or Maven <scope>import</scope>
|
— |
API reference for every module: https://ilyankin.github.io/kotlin-rfc9457/, built from the latest release, with earlier releases in its version switcher. javadoc.io also serves each artifact at every release.
val problem = problem {
type = "https://example.net/validation-error"
status = 422
title = "Your request is not valid."
detail = "The 'age' field must be a positive integer."
instance = "/account/12345/msgs/abc"
}
Json.encodeToString(problem)Problem carries its own serializer, so standard Json encodes and decodes it without registering custom serializers.
@Serializable
data class OutOfCreditDetails(val balance: Int, val accounts: List<String>)
val problem = problem {
type = "https://example.com/probs/out-of-credit"
status = 403
extensions(OutOfCreditDetails(balance = 30, accounts = listOf("/account/12345")))
}{
"type": "https://example.com/probs/out-of-credit",
"status": 403,
"balance": 30,
"accounts": ["/account/12345"]
}RFC 9457 §3.2 puts extension members at the top level of the document.
The extensions map holds them in memory and never serializes as a nested object. Reading them back is type-safe:
val details = problem.extensionsAs<OutOfCreditDetails>()
val balance = problem.extensions["balance"]?.intYour own code throws OutOfCredit.exception(…). For exception types from external libraries,
declare the mapping at startup without adding this library as a dependency to the external library:
install(StatusPages) {
problemDetails {
map<InsufficientFundsException> { _, cause ->
problem {
type = "https://example.com/probs/out-of-credit"
status = 403
detail = "Your balance is ${cause.balance}."
}
}
map<AccountLockedException>(AccountLocked) // detail comes from the exception message
standardStatusCodes()
}
}standardStatusCodes() covers the four status codes Ktor generates without a body: 404, 405,
406, and 415. Other codes require explicit mapping via forStatusCode to avoid intercepting
responses that include explicit bodies.
install(RequestValidation) {
validate<Customer> { customer ->
if (customer.age > 0) ValidationResult.Valid
else invalidField(Customer::age, "must be a positive integer")
}
}
install(StatusPages) { problemDetails { requestValidation(ValidationError) } }{
"type": "https://example.net/validation-error",
"status": 422,
"title": "Your request is not valid.",
"instance": "/customers",
"errors": [
{ "detail": "must be a positive integer", "pointer": "#/age" }
]
}The pointer derives from the property reference, making renames compile-safe. Deeper paths use
jsonPointer<Customer>("profile", "color"), which validates against the target type's serial descriptor.
install(StatusPages) {
problemDetails {
standardStatusCodes()
customize { call, problem ->
val traceId = call.request.headers["X-Trace-Id"] ?: return@customize problem
problem.copy(extensions = problem.extensions + ("traceId" to ProblemPrimitive(traceId)))
}
}
}{
"type": "about:blank",
"status": 404,
"title": "Not Found",
"instance": "/orders/17",
"traceId": "b7ad6b7169203331"
}customize runs on every document produced by the catalog before sending, whether built by
map, onUnmapped, or forStatusCode. Each call registers an additional transformation step,
running in registration order.
Use the same hook to localize title and detail by Accept-Language: read the header from
the call, resolve localized strings, and return problem.copy(...).
Catalogs are immutable after their builder block finishes. Put every mapping and customizer inside
problemCatalog { ... } or problemDetails { ... }; changing a completed catalog is rejected so
StatusPages and OpenAPI always read the same configuration.
get("/account/{id}") {
call.respondProblem(HttpStatusCode.Forbidden, problem)
}val client = HttpClient(CIO) {
expectSuccess = true
HttpResponseValidator { problemJson() }
}
try {
client.get("https://api.example.com/orders/1").body<Order>()
} catch (e: ProblemException) {
val status = e.problem.status
val reason = e.problem.detail
}The client and server use the same ProblemException. expectSuccess = true is required;
without it, Ktor does not throw on non-2xx responses, leaving nothing for the validator to intercept.
install(ContentNegotiation) {
problemJson() // register first: `Accept: */*` matches both, and Ktor breaks the tie by order
problemXml()
}<?xml version="1.0" encoding="UTF-8"?>
<problem xmlns="urn:ietf:rfc:7807">
<type>https://example.com/probs/out-of-credit</type>
<title>You do not have enough credit.</title>
<detail>Your current balance is 30, but that costs 50.</detail>
<status>403</status>
<instance>/account/12345/msgs/abc</instance>
</problem>The writer produces byte-exact output matching RFC 9457 Appendix B, and the reader parses it
back under namespace urn:ietf:rfc:7807. In client code, problemXml() inside
HttpResponseValidator { } decodes the same format into ProblemException, regardless of registration order.
val catalog = problemCatalog { standardStatusCodes() }
install(StatusPages) { problemDetails(catalog) }
routing {
problemResponses(catalog) // the catch-all and 404/405/406/415, for every endpoint below
get("/orders/{id}") { call.respondText("an order") }
.describe { responses { problemResponse(OutOfCredit) } }
}Ktor infers endpoint responses from route handler bodies. Because problem documents are generated
by StatusPages outside route lambdas, OpenAPI generation cannot detect them automatically.
problemResponses and problemResponse register application/problem+json schemas and support
extension members as siblings.
problemResponses wraps Ktor's experimental route-description API and therefore requires
@OptIn(ExperimentalProblemDetailsOpenApi::class).
| Behavior | Rationale |
|---|---|
Documents use application/problem+json even when matched by application/json. |
The media type identifies the payload as a problem details document. RFC 9457 §3 permits this override. |
instance is set from request.path(), excluding query strings. |
Query strings frequently contain sensitive tokens. Set instance explicitly if query parameters are required. |
The unmapped handler rethrows CancellationException. |
If the client disconnects, writing to the closed socket fails. TimeoutCancellationException maps to 504. |
Thrown ProblemException.cause is logged server-side and omitted from the body. |
Problem documents are sent to clients (§5), so internal file paths and SQL queries are omitted. |
Both codecs share Problem.MAX_NESTING_DEPTH. |
Sets a recursion limit, failing with SerializationException instead of encountering a StackOverflowError. |
Every module publishes for nine targets with logic in commonMain and no expect/actual declarations.
Public API signatures are tracked in api/*.api and api/*.klib.api and verified on every build to prevent
unintended API changes.
The library is feature-complete and maintained. It covers RFC 9457 in JSON and XML, with Ktor server, client, validation, and OpenAPI integrations. New features are added on request, not on a roadmap.
Releases follow Semantic Versioning:
| Release | Contains |
|---|---|
Patch, 1.0.x
|
Bug fixes and security updates of dependencies |
Minor, 1.x.0
|
New API, new targets, deprecations, a raised minimum Ktor or Kotlin version |
Major, x.0.0
|
Removal of deprecated API, a move to a new Ktor major version |
A public declaration is removed in three steps, one minor release each: @Deprecated with level
WARNING, then ERROR, then HIDDEN. The declaration itself disappears only in the next major
release. ABI dumps record every public API difference in pull request reviews.
Route.problemResponses is the one exception to this contract. It requires
@OptIn(ExperimentalProblemDetailsOpenApi::class) because it wraps Ktor's experimental
route-description API, and it may change in a minor release when Ktor changes that API.
The artifacts are built with Kotlin 2.4 against Ktor 3.6 and run on JDK 17 or newer. Security fixes
go into the latest release only; see SECURITY.md.
See CONTRIBUTING.md for issue guidelines, build instructions, and pull request requirements.
Apache License 2.0. See LICENSE.
RFC 9457 Problem Details for HTTP APIs is the standard
machine-readable body an HTTP API sends to say what went wrong. This library gives you that document
as a Kotlin type, with codecs for JSON and XML. On top of it sits the Ktor wiring you would otherwise
hand-write into StatusPages and ContentNegotiation.
Declare a problem type once, in domain code that imports nothing web-related:
object OutOfCredit : ProblemType {
override val typeUri: String = "https://example.com/probs/out-of-credit"
override val title: String = "You do not have enough credit."
override val status: Int = 403
}Wire Ktor up at startup:
install(ContentNegotiation) { problemJson() }
install(StatusPages) { problemDetails { } }Throw it wherever the failure is detected:
throw OutOfCredit.exception(detail = "Your current balance is 30, but that costs 50.")The caller gets this:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://example.com/probs/out-of-credit",
"status": 403,
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc"
}instance comes from the request path and status from the problem type. The two install calls
also handle unmapped exceptions, preventing stack traces from reaching the client.
dependencies {
implementation(platform("io.github.ilyankin:problem-details-bom:1.0.0-RC1"))
implementation("io.github.ilyankin:problem-details-core")
implementation("io.github.ilyankin:problem-details-ktor")
}The BOM keeps every module on one version, so the modules themselves need no version.
| Requires | Version |
|---|---|
| JDK | 17+ |
| Kotlin | 2.4+ (built with 2.4.20) |
| Ktor | 3.6+, for the Ktor modules |
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.ilyankin</groupId>
<artifactId>problem-details-bom</artifactId>
<version>1.0.0-RC1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.ilyankin</groupId>
<artifactId>problem-details-core</artifactId>
</dependency>
</dependencies>| To | Add |
|---|---|
| Return RFC 9457 documents from a Ktor server |
problem-details-core + problem-details-ktor
|
| Build or parse documents without a web framework | problem-details-core |
Map RequestValidation failures to errors[]
|
+ problem-details-ktor-validation
|
| Show problem responses in generated OpenAPI documents | + problem-details-ktor-openapi (and problem-details-ktor-openapi-xml for XML) |
Answer application/problem+xml alongside JSON |
+ problem-details-xml and problem-details-ktor-xml
|
| Decode problem responses from APIs you call | + problem-details-ktor-client (and problem-details-ktor-client-xml for XML) |
All modules share the same version, and problem-details-bom pins them together.
| Artifact | Contains | Javadoc |
|---|---|---|
problem-details-core |
Problem, ProblemType, ProblemValue, the problem { } builder, typed extension access, and flattening JSON codec |
javadoc.io |
problem-details-ktor |
respondProblem, ProblemDetailsCatalog, problemDetails { }, problemJson()
|
javadoc.io |
problem-details-xml |
The RFC Appendix B XML codec (ProblemXml) |
javadoc.io |
problem-details-ktor-xml |
Registers the XML codec with Ktor ContentNegotiation (problemXml()) |
javadoc.io |
problem-details-ktor-client |
problemJson(), decoding recognized problem responses into ProblemException
|
javadoc.io |
problem-details-ktor-client-xml |
problemXml(), decoding XML problem responses into ProblemException
|
javadoc.io |
problem-details-ktor-validation |
invalidField/invalidFields, jsonPointer, requestValidation(type) to map RequestValidationException to errors[] with JSON Pointer |
javadoc.io |
problem-details-ktor-openapi |
Route.problemResponses(catalog), problemsFrom, problemResponse, problemDefault, ProblemSchemas for generated OpenAPI documents |
javadoc.io |
problem-details-ktor-openapi-xml |
problemXmlContent() for application/problem+xml in OpenAPI |
javadoc.io |
problem-details-bom |
Versions for every artifact above, imported with platform(...) or Maven <scope>import</scope>
|
— |
API reference for every module: https://ilyankin.github.io/kotlin-rfc9457/, built from the latest release, with earlier releases in its version switcher. javadoc.io also serves each artifact at every release.
val problem = problem {
type = "https://example.net/validation-error"
status = 422
title = "Your request is not valid."
detail = "The 'age' field must be a positive integer."
instance = "/account/12345/msgs/abc"
}
Json.encodeToString(problem)Problem carries its own serializer, so standard Json encodes and decodes it without registering custom serializers.
@Serializable
data class OutOfCreditDetails(val balance: Int, val accounts: List<String>)
val problem = problem {
type = "https://example.com/probs/out-of-credit"
status = 403
extensions(OutOfCreditDetails(balance = 30, accounts = listOf("/account/12345")))
}{
"type": "https://example.com/probs/out-of-credit",
"status": 403,
"balance": 30,
"accounts": ["/account/12345"]
}RFC 9457 §3.2 puts extension members at the top level of the document.
The extensions map holds them in memory and never serializes as a nested object. Reading them back is type-safe:
val details = problem.extensionsAs<OutOfCreditDetails>()
val balance = problem.extensions["balance"]?.intYour own code throws OutOfCredit.exception(…). For exception types from external libraries,
declare the mapping at startup without adding this library as a dependency to the external library:
install(StatusPages) {
problemDetails {
map<InsufficientFundsException> { _, cause ->
problem {
type = "https://example.com/probs/out-of-credit"
status = 403
detail = "Your balance is ${cause.balance}."
}
}
map<AccountLockedException>(AccountLocked) // detail comes from the exception message
standardStatusCodes()
}
}standardStatusCodes() covers the four status codes Ktor generates without a body: 404, 405,
406, and 415. Other codes require explicit mapping via forStatusCode to avoid intercepting
responses that include explicit bodies.
install(RequestValidation) {
validate<Customer> { customer ->
if (customer.age > 0) ValidationResult.Valid
else invalidField(Customer::age, "must be a positive integer")
}
}
install(StatusPages) { problemDetails { requestValidation(ValidationError) } }{
"type": "https://example.net/validation-error",
"status": 422,
"title": "Your request is not valid.",
"instance": "/customers",
"errors": [
{ "detail": "must be a positive integer", "pointer": "#/age" }
]
}The pointer derives from the property reference, making renames compile-safe. Deeper paths use
jsonPointer<Customer>("profile", "color"), which validates against the target type's serial descriptor.
install(StatusPages) {
problemDetails {
standardStatusCodes()
customize { call, problem ->
val traceId = call.request.headers["X-Trace-Id"] ?: return@customize problem
problem.copy(extensions = problem.extensions + ("traceId" to ProblemPrimitive(traceId)))
}
}
}{
"type": "about:blank",
"status": 404,
"title": "Not Found",
"instance": "/orders/17",
"traceId": "b7ad6b7169203331"
}customize runs on every document produced by the catalog before sending, whether built by
map, onUnmapped, or forStatusCode. Each call registers an additional transformation step,
running in registration order.
Use the same hook to localize title and detail by Accept-Language: read the header from
the call, resolve localized strings, and return problem.copy(...).
Catalogs are immutable after their builder block finishes. Put every mapping and customizer inside
problemCatalog { ... } or problemDetails { ... }; changing a completed catalog is rejected so
StatusPages and OpenAPI always read the same configuration.
get("/account/{id}") {
call.respondProblem(HttpStatusCode.Forbidden, problem)
}val client = HttpClient(CIO) {
expectSuccess = true
HttpResponseValidator { problemJson() }
}
try {
client.get("https://api.example.com/orders/1").body<Order>()
} catch (e: ProblemException) {
val status = e.problem.status
val reason = e.problem.detail
}The client and server use the same ProblemException. expectSuccess = true is required;
without it, Ktor does not throw on non-2xx responses, leaving nothing for the validator to intercept.
install(ContentNegotiation) {
problemJson() // register first: `Accept: */*` matches both, and Ktor breaks the tie by order
problemXml()
}<?xml version="1.0" encoding="UTF-8"?>
<problem xmlns="urn:ietf:rfc:7807">
<type>https://example.com/probs/out-of-credit</type>
<title>You do not have enough credit.</title>
<detail>Your current balance is 30, but that costs 50.</detail>
<status>403</status>
<instance>/account/12345/msgs/abc</instance>
</problem>The writer produces byte-exact output matching RFC 9457 Appendix B, and the reader parses it
back under namespace urn:ietf:rfc:7807. In client code, problemXml() inside
HttpResponseValidator { } decodes the same format into ProblemException, regardless of registration order.
val catalog = problemCatalog { standardStatusCodes() }
install(StatusPages) { problemDetails(catalog) }
routing {
problemResponses(catalog) // the catch-all and 404/405/406/415, for every endpoint below
get("/orders/{id}") { call.respondText("an order") }
.describe { responses { problemResponse(OutOfCredit) } }
}Ktor infers endpoint responses from route handler bodies. Because problem documents are generated
by StatusPages outside route lambdas, OpenAPI generation cannot detect them automatically.
problemResponses and problemResponse register application/problem+json schemas and support
extension members as siblings.
problemResponses wraps Ktor's experimental route-description API and therefore requires
@OptIn(ExperimentalProblemDetailsOpenApi::class).
| Behavior | Rationale |
|---|---|
Documents use application/problem+json even when matched by application/json. |
The media type identifies the payload as a problem details document. RFC 9457 §3 permits this override. |
instance is set from request.path(), excluding query strings. |
Query strings frequently contain sensitive tokens. Set instance explicitly if query parameters are required. |
The unmapped handler rethrows CancellationException. |
If the client disconnects, writing to the closed socket fails. TimeoutCancellationException maps to 504. |
Thrown ProblemException.cause is logged server-side and omitted from the body. |
Problem documents are sent to clients (§5), so internal file paths and SQL queries are omitted. |
Both codecs share Problem.MAX_NESTING_DEPTH. |
Sets a recursion limit, failing with SerializationException instead of encountering a StackOverflowError. |
Every module publishes for nine targets with logic in commonMain and no expect/actual declarations.
Public API signatures are tracked in api/*.api and api/*.klib.api and verified on every build to prevent
unintended API changes.
The library is feature-complete and maintained. It covers RFC 9457 in JSON and XML, with Ktor server, client, validation, and OpenAPI integrations. New features are added on request, not on a roadmap.
Releases follow Semantic Versioning:
| Release | Contains |
|---|---|
Patch, 1.0.x
|
Bug fixes and security updates of dependencies |
Minor, 1.x.0
|
New API, new targets, deprecations, a raised minimum Ktor or Kotlin version |
Major, x.0.0
|
Removal of deprecated API, a move to a new Ktor major version |
A public declaration is removed in three steps, one minor release each: @Deprecated with level
WARNING, then ERROR, then HIDDEN. The declaration itself disappears only in the next major
release. ABI dumps record every public API difference in pull request reviews.
Route.problemResponses is the one exception to this contract. It requires
@OptIn(ExperimentalProblemDetailsOpenApi::class) because it wraps Ktor's experimental
route-description API, and it may change in a minor release when Ktor changes that API.
The artifacts are built with Kotlin 2.4 against Ktor 3.6 and run on JDK 17 or newer. Security fixes
go into the latest release only; see SECURITY.md.
See CONTRIBUTING.md for issue guidelines, build instructions, and pull request requirements.
Apache License 2.0. See LICENSE.