
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 for Kotlin — the
standard way for an HTTP API to say what went wrong, in one machine-readable body shape instead of
a different error format per service. This library models that document, serializes it correctly, and
generates the Ktor wiring you would otherwise hand-write into StatusPages and ContentNegotiation,
rather than reimplementing either.
Declare a problem type once, in ordinary 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 once, at startup:
install(ContentNegotiation) { problemJson() }
install(StatusPages) { problemDetails { } }Then throw it from anywhere — a service, a repository, a validator:
throw OutOfCredit.exception(detail = "Your current balance is 30, but that costs 50.")And the caller gets this, with nothing else configured:
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 came from the request path, status from the problem type. Those same two install lines
also answer every unhandled exception with a document instead of a stack trace. Bare status codes —
the 404 from a route that matched nothing — stay untouched until you ask for them with
standardStatusCodes(), because StatusPages fires that hook for every response carrying the code,
including bodies your own handlers built on purpose.
On the server. problemDetails { } writes the StatusPages registrations for you: a catch-all
that never leaks a file path or a SQL fragment into a response, mappings for exception types you do
not own, and bodies for individual status codes. problemJson() registers the codec with
ContentNegotiation. Dispatch itself stays Ktor's — nearest-parent-class exception resolution and
Accept quality values are not reimplemented here.
In your domain code. ProblemType and the throwable both live in problem-details-core, so
raising a problem from a service layer costs no dependency on a web framework. Pass cause and the
underlying failure is logged server-side and kept out of the document, which RFC 9457 §5 asks for.
Your own fields, beside the standard ones. Spread an @Serializable object into a document with
extensions(obj), read it back typed with extensionsAs<T>(). They land as siblings of
type/status/title/detail/instance — what §3.2 requires, and what implementations that nest
them under an extensions key get wrong.
Field-level validation errors. requestValidation(type) turns Ktor RequestValidation failures
into the errors[] array with JSON Pointer references that the RFC itself recommends for multi-field
validation, and jsonPointer(Customer::age) derives each pointer from the property so it cannot
drift away from the DTO it points into.
Reading problems, not only writing them. problemJson() on Ktor Client turns a problem response
from an API you call back into the same ProblemException your own server throws — one exception
type for both directions, not two.
XML when a client asks for it. The RFC Appendix B format, byte-exact against the RFC's own example in both directions, in artifacts of its own so a JSON-only application never resolves an XML parser.
| If you want to… | Add | Since |
|---|---|---|
| Return RFC 9457 documents from a Ktor server |
problem-details-core + problem-details-ktor
|
0.1.0 |
| Build or read the documents with no web framework at all | problem-details-core |
0.1.0 |
Map RequestValidation failures to errors[]
|
…plus problem-details-ktor-validation
|
0.5.0 |
Answer application/problem+xml as well as JSON |
…plus problem-details-xml and problem-details-ktor-xml
|
0.2.0 |
| Decode problem responses from APIs you call | …plus problem-details-ktor-client, and problem-details-ktor-client-xml if those answers can be XML |
0.3.0 / 0.4.0 |
All modules always share one version, so you choose a version once and use it everywhere.
| Artifact | Contains | Javadoc |
|---|---|---|
problem-details-core |
Problem, ProblemType, ProblemValue, the problem { } builder, typed extension reading, and the 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's ContentNegotiation (problemXml()) |
javadoc.io |
problem-details-ktor-client |
problemJson() — decode a recognized problem response into the same ProblemException the server throws |
javadoc.io |
problem-details-ktor-client-xml |
problemXml() — the same for application/problem+xml
|
javadoc.io |
problem-details-ktor-validation |
invalidField/invalidFields, jsonPointer, requestValidation(type) — RequestValidationException to errors[] with JSON Pointer |
javadoc.io |
API reference for every module: https://ilyankin.github.io/kotlin-rfc9457/, regenerated from
main on every push. Per-artifact documentation is also served unversioned by javadoc.io, resolving
to the latest release, once that module has a version published.
Gradle (Kotlin DSL):
dependencies {
implementation("io.github.ilyankin:problem-details-core:0.5.0")
implementation("io.github.ilyankin:problem-details-ktor:0.5.0")
}Maven:
<dependency>
<groupId>io.github.ilyankin</groupId>
<artifactId>problem-details-core</artifactId>
<version>0.5.0</version>
</dependency>The plain coordinates work from Maven as well as Gradle: the root POM is published with
packaging: pom and a compile-scoped dependency on the -jvm artifact, the way kotlinx-serialization
and kotlinx-coroutines do it. You do not need to write -jvm yourself.
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"
}Written as siblings of the standard members, never nested under an extensions key:
@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":"…","status":403,"balance":30,"accounts":["/account/12345"]}Reading them back is typed:
val details = problem.extensionsAs<OutOfCreditDetails>()
val balance = problem.extensions["balance"]?.intThrowing OutOfCredit.exception(…) covers your own code — see See it. Exception types
from a library get mapped declaratively instead, which leaves them free of any dependency on this one:
install(StatusPages) {
problemDetails {
map<InsufficientFundsException> { _, cause ->
problem {
type = "https://example.com/probs/out-of-credit"
status = 403
detail = "Your balance is ${cause.balance}."
}
}
forStatusCode(HttpStatusCode.NotFound) { Problem.blank(HttpStatusCode.NotFound) }
standardStatusCodes()
}
}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" }
]
}call.respondProblem(HttpStatusCode.Forbidden, problem)Things that are easy to get wrong and are therefore settled here once:
application/problem+json even when the request matched it under plain application/json, so the
media type never stops being the marker that this body is an error report.Problem.MAX_NESTING_DEPTH) and fail with SerializationException, not a StackOverflowError.problem-details-ktor never depends on the XML modules. That is the point of the split: an
application that only emits JSON does not resolve an XML parser, and optionality is expressed by
which artifact declares the registration function — so a missing dependency is a compile error at
the call site, not a runtime NoClassDefFoundError.kotlin("multiplatform") with all code in
commonMain and no expect/actual; a jvm() target is what ships today, and adding another is a
line in kotlin { } rather than a redesign.explicitApi() everywhere and api/*.api ABI dumps
checked on every build, so any accidental widening shows up in review before 1.0 freezes it.This is a 0.x release. Anything may change in any release, and nothing is frozen yet. No
deprecation cycle is owed and no binary compatibility is promised until 1.0. The public surface is
recorded in api/*.api dumps and checked on every build, so changes are at least visible in a diff.
There are consequently no @RequiresOptIn markers: opt-in annotations exist to carve unstable
islands out of a stable release, and at 0.x everything is unstable by declaration.
Backlog — scoped, timing intentionally undecided:
| Module | Would add |
|---|---|
problem-details-ktor-openapi |
Auto-documents problem responses in a generated OpenAPI spec. |
problem-details-ktor-i18n |
Accept-Language-based localization of title/detail (Spring MessageSource-style). |
problem-details-ktor-hooks |
A global enrichment hook (ASP.NET CustomizeProblemDetails-style) for adding fields like traceId to every response. |
1.0. @RequiresOptIn markers arrive for whatever isn't ready to freeze — ProblemDetailsCatalog
and the shape of ProblemType are the named candidates — once someone outside this repo has actually
used the library.
Bug reports, proposals and questions are all welcome — see CONTRIBUTING.md for
where each of them goes and what the build checks before a pull request can land. The roadmap above
is ordered by demand, so saying you need something counts as a contribution.
Apache License 2.0 — see LICENSE.
RFC 9457 Problem Details for HTTP APIs for Kotlin — the
standard way for an HTTP API to say what went wrong, in one machine-readable body shape instead of
a different error format per service. This library models that document, serializes it correctly, and
generates the Ktor wiring you would otherwise hand-write into StatusPages and ContentNegotiation,
rather than reimplementing either.
Declare a problem type once, in ordinary 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 once, at startup:
install(ContentNegotiation) { problemJson() }
install(StatusPages) { problemDetails { } }Then throw it from anywhere — a service, a repository, a validator:
throw OutOfCredit.exception(detail = "Your current balance is 30, but that costs 50.")And the caller gets this, with nothing else configured:
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 came from the request path, status from the problem type. Those same two install lines
also answer every unhandled exception with a document instead of a stack trace. Bare status codes —
the 404 from a route that matched nothing — stay untouched until you ask for them with
standardStatusCodes(), because StatusPages fires that hook for every response carrying the code,
including bodies your own handlers built on purpose.
On the server. problemDetails { } writes the StatusPages registrations for you: a catch-all
that never leaks a file path or a SQL fragment into a response, mappings for exception types you do
not own, and bodies for individual status codes. problemJson() registers the codec with
ContentNegotiation. Dispatch itself stays Ktor's — nearest-parent-class exception resolution and
Accept quality values are not reimplemented here.
In your domain code. ProblemType and the throwable both live in problem-details-core, so
raising a problem from a service layer costs no dependency on a web framework. Pass cause and the
underlying failure is logged server-side and kept out of the document, which RFC 9457 §5 asks for.
Your own fields, beside the standard ones. Spread an @Serializable object into a document with
extensions(obj), read it back typed with extensionsAs<T>(). They land as siblings of
type/status/title/detail/instance — what §3.2 requires, and what implementations that nest
them under an extensions key get wrong.
Field-level validation errors. requestValidation(type) turns Ktor RequestValidation failures
into the errors[] array with JSON Pointer references that the RFC itself recommends for multi-field
validation, and jsonPointer(Customer::age) derives each pointer from the property so it cannot
drift away from the DTO it points into.
Reading problems, not only writing them. problemJson() on Ktor Client turns a problem response
from an API you call back into the same ProblemException your own server throws — one exception
type for both directions, not two.
XML when a client asks for it. The RFC Appendix B format, byte-exact against the RFC's own example in both directions, in artifacts of its own so a JSON-only application never resolves an XML parser.
| If you want to… | Add | Since |
|---|---|---|
| Return RFC 9457 documents from a Ktor server |
problem-details-core + problem-details-ktor
|
0.1.0 |
| Build or read the documents with no web framework at all | problem-details-core |
0.1.0 |
Map RequestValidation failures to errors[]
|
…plus problem-details-ktor-validation
|
0.5.0 |
Answer application/problem+xml as well as JSON |
…plus problem-details-xml and problem-details-ktor-xml
|
0.2.0 |
| Decode problem responses from APIs you call | …plus problem-details-ktor-client, and problem-details-ktor-client-xml if those answers can be XML |
0.3.0 / 0.4.0 |
All modules always share one version, so you choose a version once and use it everywhere.
| Artifact | Contains | Javadoc |
|---|---|---|
problem-details-core |
Problem, ProblemType, ProblemValue, the problem { } builder, typed extension reading, and the 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's ContentNegotiation (problemXml()) |
javadoc.io |
problem-details-ktor-client |
problemJson() — decode a recognized problem response into the same ProblemException the server throws |
javadoc.io |
problem-details-ktor-client-xml |
problemXml() — the same for application/problem+xml
|
javadoc.io |
problem-details-ktor-validation |
invalidField/invalidFields, jsonPointer, requestValidation(type) — RequestValidationException to errors[] with JSON Pointer |
javadoc.io |
API reference for every module: https://ilyankin.github.io/kotlin-rfc9457/, regenerated from
main on every push. Per-artifact documentation is also served unversioned by javadoc.io, resolving
to the latest release, once that module has a version published.
Gradle (Kotlin DSL):
dependencies {
implementation("io.github.ilyankin:problem-details-core:0.5.0")
implementation("io.github.ilyankin:problem-details-ktor:0.5.0")
}Maven:
<dependency>
<groupId>io.github.ilyankin</groupId>
<artifactId>problem-details-core</artifactId>
<version>0.5.0</version>
</dependency>The plain coordinates work from Maven as well as Gradle: the root POM is published with
packaging: pom and a compile-scoped dependency on the -jvm artifact, the way kotlinx-serialization
and kotlinx-coroutines do it. You do not need to write -jvm yourself.
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"
}Written as siblings of the standard members, never nested under an extensions key:
@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":"…","status":403,"balance":30,"accounts":["/account/12345"]}Reading them back is typed:
val details = problem.extensionsAs<OutOfCreditDetails>()
val balance = problem.extensions["balance"]?.intThrowing OutOfCredit.exception(…) covers your own code — see See it. Exception types
from a library get mapped declaratively instead, which leaves them free of any dependency on this one:
install(StatusPages) {
problemDetails {
map<InsufficientFundsException> { _, cause ->
problem {
type = "https://example.com/probs/out-of-credit"
status = 403
detail = "Your balance is ${cause.balance}."
}
}
forStatusCode(HttpStatusCode.NotFound) { Problem.blank(HttpStatusCode.NotFound) }
standardStatusCodes()
}
}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" }
]
}call.respondProblem(HttpStatusCode.Forbidden, problem)Things that are easy to get wrong and are therefore settled here once:
application/problem+json even when the request matched it under plain application/json, so the
media type never stops being the marker that this body is an error report.Problem.MAX_NESTING_DEPTH) and fail with SerializationException, not a StackOverflowError.problem-details-ktor never depends on the XML modules. That is the point of the split: an
application that only emits JSON does not resolve an XML parser, and optionality is expressed by
which artifact declares the registration function — so a missing dependency is a compile error at
the call site, not a runtime NoClassDefFoundError.kotlin("multiplatform") with all code in
commonMain and no expect/actual; a jvm() target is what ships today, and adding another is a
line in kotlin { } rather than a redesign.explicitApi() everywhere and api/*.api ABI dumps
checked on every build, so any accidental widening shows up in review before 1.0 freezes it.This is a 0.x release. Anything may change in any release, and nothing is frozen yet. No
deprecation cycle is owed and no binary compatibility is promised until 1.0. The public surface is
recorded in api/*.api dumps and checked on every build, so changes are at least visible in a diff.
There are consequently no @RequiresOptIn markers: opt-in annotations exist to carve unstable
islands out of a stable release, and at 0.x everything is unstable by declaration.
Backlog — scoped, timing intentionally undecided:
| Module | Would add |
|---|---|
problem-details-ktor-openapi |
Auto-documents problem responses in a generated OpenAPI spec. |
problem-details-ktor-i18n |
Accept-Language-based localization of title/detail (Spring MessageSource-style). |
problem-details-ktor-hooks |
A global enrichment hook (ASP.NET CustomizeProblemDetails-style) for adding fields like traceId to every response. |
1.0. @RequiresOptIn markers arrive for whatever isn't ready to freeze — ProblemDetailsCatalog
and the shape of ProblemType are the named candidates — once someone outside this repo has actually
used the library.
Bug reports, proposals and questions are all welcome — see CONTRIBUTING.md for
where each of them goes and what the build checks before a pull request can land. The roadmap above
is ordered by demand, so saying you need something counts as a contribution.
Apache License 2.0 — see LICENSE.