Package-level declarations

Entry pointRole
invalidFieldInside validate<T> { }: fail with one pointer + detail pair. The pointer is a string, or a property reference the pointer is derived from.
invalidFieldsInside validate<T> { }: fail with more than one pointer + detail pair from the same body. Takes them as vararg pairs or as a List.
jsonPointerBuilds a pointer that is checked against the body type's serial descriptor, instead of written out by hand.
requestValidationInside problemDetails { }: register the RequestValidationException → Problem mapping.
decodeValidationReasonReads a invalidField/invalidFields-encoded reason back into pointer + detail, for a mapping that does not go through requestValidation.

Published for jvm, js, wasmJs, linuxX64, linuxArm64, mingwX64, macosArm64, iosArm64 and iosSimulatorArm64. Depend on the plain coordinates from commonMain and Gradle picks the variant.

Usage

install(RequestValidation) {
validate<Customer> { customer ->
if (customer.age > 0) ValidationResult.Valid
else invalidField("#/age", "must be a positive integer")
}
}

install(StatusPages) {
problemDetails {
requestValidation(ValidationError)
}
}

When a plain exception is the better tool

This module exists to bridge RequestValidation's reason strings, which carry nothing but text, to errors[]. If you are not tied to that plugin, there is a shorter route to the same document with no encoding in between: throw your own exception from inside validate<T> { } and map it with problem-details-ktor's map<T> { }. Ktor calls validators without a try, so the exception reaches StatusPages untouched, carrying whatever typed payload you gave it.

class InvalidCustomer(val errors: List<FieldError>) : RuntimeException()

install(RequestValidation) {
validate<Customer> { customer -> if (customer.isValid) ValidationResult.Valid else throw InvalidCustomer(...) }
}

install(StatusPages) {
problemDetails {
map<InvalidCustomer> { _, cause -> /* build errors[] from cause.errors, fully typed */ }
}
}

What that gives up is aggregation. The plugin runs every validator registered for a body type and concatenates their reasons; a thrown exception stops at the first one. With a single validator per type — the usual arrangement — nothing is lost, since that validator already collects its own findings before returning.

So reach for this module when you cannot leave the ValidationResult protocol: several validators on one body type, validators written elsewhere, or a codebase already returning ValidationResult.Invalid throughout. That is what it is good at, and it is a narrower claim than "the way to do validation problems in Ktor".

Pointers that cannot go stale

"#/age" written as a string is a claim about the request body that nothing checks. Rename the property and the pointer keeps naming a member that is gone. jsonPointer resolves the path against the body type's serial descriptor instead, so a path that no longer exists fails loudly:

invalidFields(
jsonPointer(Customer::age) to "must be a positive integer", // renaming `age` stops compiling
jsonPointer<Customer>("profile", "color") to "must be 'green', 'red' or 'blue'",
)

invalidField(Customer::age, "must be a positive integer") // the same, for a single field

The property form is checked by the compiler; the string form is checked when the pointer is built, segment by segment, and reports the members that do exist when one does not match. Segments are escaped for you — ~ and / per RFC 6901 §3, then anything a URI fragment does not admit is percent-encoded.

Two limits are worth knowing before you rely on the property form. A property renamed with @SerialName cannot be resolved from the reference — mapping a Kotlin property to its serial name needs kotlin-reflect, which this library does not use — so jsonPointer(Customer::emailAddress) throws rather than guessing, and you name it as jsonPointer<Customer>("email_address") instead. And a JsonNamingStrategy on the application's Json is invisible to the descriptor altogether; a project using one should build pointers from strings and test them against a real response.

What degrades, and how

A reason not produced by invalidField/invalidFields — a hand-written ValidationResult.Invalid("text"), or the byte-array validate { } overload, which has no field to point at — is never dropped and never throws. It becomes an errors[] entry with detail alone, no pointer.

A custom errors[] entry

requestValidation's entries are always a flat {detail, pointer} object, using RFC 9457's own example member names. For anything else — other names, extra members, a typed object — bypass it and write the RequestValidationException mapping directly, using decodeValidationReason to read what invalidField/invalidFields encoded and io.github.ilyankin.rfc9457.encodeToProblemValue (problem-details-core) to turn a typed value into a io.github.ilyankin.rfc9457.ProblemValue:

Types

Link copied to clipboard
data class ValidationReason(val pointer: String?, val detail: String)

One decoded RequestValidation reason.

Functions

Link copied to clipboard

Decodes one RequestValidationException reason. This is what requestValidation does internally for every entry of errors[]. It is exposed so a mapping that needs a shape requestValidation cannot produce — a typed errors[] element, differently named members, an extra member per entry — can still read ValidationReason.pointer/ValidationReason.detail back out of a reason built by invalidField/invalidFields, instead of re-inventing the encoding.

Link copied to clipboard

Builds a ValidationResult.Invalid that requestValidation decodes into one errors[] entry carrying both pointer and detail.

inline fun <T> invalidField(property: KProperty1<T, *>, detail: String): ValidationResult.Invalid

Builds a ValidationResult.Invalid for property of the request body, deriving its pointer with jsonPointer rather than taking one written by hand. Renaming the property is then a compile error, not a pointer that quietly names a member that is gone.

Link copied to clipboard

The List form of invalidFields, for a validator that accumulates its findings before returning rather than knowing them all at the call site.

Builds one ValidationResult.Invalid carrying one errors[] entry per pair in fields. Use this for a validator that finds more than one invalid member in the same request body, RFC 9457's own recommended shape for multi-field validation.

Link copied to clipboard
inline fun <T> jsonPointer(property: KProperty1<T, *>): String

Builds the JSON Pointer (RFC 6901) that identifies property in T's serialized form, in the URI fragment notation RFC 9457's own example uses: #/age.

inline fun <T> jsonPointer(vararg path: String): String

Builds the JSON Pointer (RFC 6901) for path in T's serialized form, in the URI fragment notation RFC 9457's own example uses: jsonPointer<Customer>("profile", "color") gives #/profile/color.

Link copied to clipboard

Maps Ktor's RequestValidation plugin failures to type, carrying every validation reason as one entry of an errors[] extension array. This is the multi-error pattern RFC 9457 itself recommends for field-level validation failures.