diff --git a/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/Akkurate.kt b/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/Akkurate.kt index 1db8909a..5b27a1fe 100644 --- a/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/Akkurate.kt +++ b/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/Akkurate.kt @@ -20,6 +20,20 @@ package dev.nesk.akkurate.ktor.client import io.ktor.client.plugins.api.* import io.ktor.client.statement.* +/** + * A plugin that validates deserialized response bodies. + * + * ``` + * val client = HttpClient(CIO) { + * install(Akkurate) { + * registerValidator(validateBook) + * } + * install(ContentNegotiation) { + * json() + * } + * } + * ``` + */ public val Akkurate: ClientPlugin = createClientPlugin( name = "Akkurate", createConfiguration = ::AkkurateConfig diff --git a/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/AkkurateConfig.kt b/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/AkkurateConfig.kt index 920ba9f4..59d28b7e 100644 --- a/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/AkkurateConfig.kt +++ b/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/AkkurateConfig.kt @@ -19,13 +19,23 @@ package dev.nesk.akkurate.ktor.client import dev.nesk.akkurate.Validator +/** + * A config for [Akkurate] plugin. + */ public class AkkurateConfig internal constructor() { internal val validators: MutableList = mutableListOf() + /** + * Registers a new [validator], which will be executed for each deserialized response body. + */ public fun registerValidator(validator: ClientValidator) { validators += validator } + /** + * Registers a new [validator], which will be executed for each deserialized response body. + * The [contextProvider] is called on each execution, then its result is feed to the validator. + */ public inline fun registerValidator( validator: Validator.Runner.WithContext, noinline contextProvider: suspend () -> ContextType, @@ -33,10 +43,17 @@ public class AkkurateConfig internal constructor() { registerValidator(ClientValidator.Runner.WithContext(ValueType::class, validator, contextProvider)) } + /** + * Registers a new [validator], which will be executed for each deserialized response body. + */ public inline fun registerValidator(validator: Validator.Runner) { registerValidator(ClientValidator.Runner(ValueType::class, validator)) } + /** + * Registers a new [validator], which will be executed for each deserialized response body. + * The [contextProvider] is called on each execution, then its result is feed to the validator. + */ public inline fun registerValidator( validator: Validator.SuspendableRunner.WithContext, noinline contextProvider: suspend () -> ContextType, @@ -44,6 +61,9 @@ public class AkkurateConfig internal constructor() { registerValidator(ClientValidator.SuspendableRunner.WithContext(ValueType::class, validator, contextProvider)) } + /** + * Registers a new [validator], which will be executed for each deserialized response body. + */ public inline fun registerValidator(validator: Validator.SuspendableRunner) { registerValidator(ClientValidator.SuspendableRunner(ValueType::class, validator)) } diff --git a/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/ClientValidator.kt b/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/ClientValidator.kt index 0af66313..1e9feb6e 100644 --- a/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/ClientValidator.kt +++ b/akkurate-ktor-client/src/commonMain/kotlin/dev/nesk/akkurate/ktor/client/ClientValidator.kt @@ -17,12 +17,24 @@ package dev.nesk.akkurate.ktor.client +import dev.nesk.akkurate.ValidationResult import dev.nesk.akkurate.Validator import kotlin.reflect.KClass +/** + * A validator that should be registered with [AkkurateConfig.registerValidator]. + */ public interface ClientValidator { + /** + * Validates the [value]. + * + * Throws a [ValidationResult.Exception] if the value matches the excepted type and failed the validation. + */ public suspend fun validate(value: Any?) + /** + * A validator for [Validator.Runner] instances. + */ public class Runner( private val valueType: KClass<*>, private val validator: Validator.Runner, @@ -34,6 +46,9 @@ public interface ClientValidator { validator(value as ValueType).orThrow() } + /** + * A validator for [Validator.Runner.WithContext] instances. + */ public class WithContext( private val valueType: KClass<*>, private val validator: Validator.Runner.WithContext, @@ -48,6 +63,9 @@ public interface ClientValidator { } } + /** + * A validator for [Validator.SuspendableRunner] instances. + */ public class SuspendableRunner( private val valueType: KClass<*>, private val validator: Validator.SuspendableRunner, @@ -59,6 +77,9 @@ public interface ClientValidator { validator(value as ValueType).orThrow() } + /** + * A validator for [Validator.SuspendableRunner.WithContext] instances. + */ public class WithContext( private val valueType: KClass<*>, private val validator: Validator.SuspendableRunner.WithContext, diff --git a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/Akkurate.kt b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/Akkurate.kt index 1ec72495..300548cb 100644 --- a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/Akkurate.kt +++ b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/Akkurate.kt @@ -21,6 +21,22 @@ import dev.nesk.akkurate.ValidationResult import io.ktor.server.application.* import io.ktor.server.application.hooks.* +/** + * A plugin that validates received request bodies. + * + * ``` + * fun Application.configureSerialization() { + * install(Akkurate) + * + * install(ContentNegotiation) { + * json() + * } + * install(RequestValidation) { + * registerValidator(validateBook) + * } + * } + * ``` + */ public val Akkurate: ApplicationPlugin = createApplicationPlugin( name = "Akkurate", createConfiguration = ::AkkurateConfig diff --git a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/AkkurateConfig.kt b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/AkkurateConfig.kt index eea615c3..3d33cede 100644 --- a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/AkkurateConfig.kt +++ b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/AkkurateConfig.kt @@ -22,9 +22,24 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* +/** + * A config for [Akkurate] plugin. + */ public class AkkurateConfig internal constructor() { + /** + * The status code of the response, in case of validation failure. + * + * Applying the status code is the responsibility of the response builder, + * be aware of this behavior when you define your own via [buildResponse]. + */ public var status: HttpStatusCode = HttpStatusCode.UnprocessableEntity + /** + * The content type of the response, in case of validation failure. + * + * Applying the content type is the responsibility of the response builder, + * be aware of this behavior when you define your own via [buildResponse]. + */ public var contentType: ContentType = ContentType.Application.ProblemJson internal var responseBuilder: suspend (call: ApplicationCall, violations: ConstraintViolationSet) -> Unit = { call, violations -> @@ -33,6 +48,12 @@ public class AkkurateConfig internal constructor() { call.respond(ProblemDetailsMessage(status.value, violations)) } + /** + * Defines your own builder to generate a custom response on validation failure. + * + * The [status] and [contentType][AkkurateConfig.contentType] properties are applied by the + * default response builder but, once you define your own, it becomes your responsibility. + */ public fun buildResponse(block: suspend (call: ApplicationCall, violations: ConstraintViolationSet) -> Unit) { responseBuilder = block } diff --git a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/ProblemDetailsMessage.kt b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/ProblemDetailsMessage.kt index 744f83b8..913d1ab3 100644 --- a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/ProblemDetailsMessage.kt +++ b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/ProblemDetailsMessage.kt @@ -20,12 +20,33 @@ package dev.nesk.akkurate.ktor.server import dev.nesk.akkurate.constraints.ConstraintViolation import kotlinx.serialization.Serializable +/** + * + */ @Serializable public class ProblemDetailsMessage( + /** + * A JSON number indicating [the HTTP status code](https://www.rfc-editor.org/rfc/rfc9110#section-15) + * generated by the origin server for this occurrence of the problem. + */ public val status: Int, + /** + * Fields that failed validation, with their error message. + */ public val fields: Set<@Serializable(with = ConstraintViolationSerializer::class) ConstraintViolation>, ) { + /** + * A URI reference that identifies the problem type. + */ public val type: String = "https://akkurate.dev/validation-error" + + /** + * A short, human-readable summary of the problem type. + */ public val title: String = "The payload is invalid" + + /** + * A human-readable explanation specific to this occurrence of the problem. + */ public val detail: String = "The payload has been successfully parsed, but the server is unable to accept it due to validation errors." } diff --git a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/RegisterValidator.kt b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/RegisterValidator.kt index fca1dd08..1a5b7e30 100644 --- a/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/RegisterValidator.kt +++ b/akkurate-ktor-server/src/commonMain/kotlin/dev/nesk/akkurate/ktor/server/RegisterValidator.kt @@ -19,7 +19,12 @@ package dev.nesk.akkurate.ktor.server import dev.nesk.akkurate.Validator import io.ktor.server.plugins.requestvalidation.* +import io.ktor.server.request.* +/** + * Registers a new [validator], which will be executed for each [received][receive] request body. + * The [contextProvider] is called on each execution, then its result is feed to the validator. + */ public inline fun RequestValidationConfig.registerValidator( validator: Validator.Runner.WithContext, noinline contextProvider: suspend () -> ContextType, @@ -30,6 +35,9 @@ public inline fun RequestValidationConfig } } +/** + * Registers a new [validator], which will be executed for each [received][receive] request body. + */ public inline fun RequestValidationConfig.registerValidator(validator: Validator.Runner) { validate { validator(it).orThrow() @@ -37,6 +45,10 @@ public inline fun RequestValidationConfig.registerVali } } +/** + * Registers a new [validator], which will be executed for each [received][receive] request body. + * The [contextProvider] is called on each execution, then its result is feed to the validator. + */ public inline fun RequestValidationConfig.registerValidator( validator: Validator.SuspendableRunner.WithContext, noinline contextProvider: suspend () -> ContextType, @@ -47,6 +59,9 @@ public inline fun RequestValidationConfig } } +/** + * Registers a new [validator], which will be executed for each [received][receive] request body. + */ public inline fun RequestValidationConfig.registerValidator(validator: Validator.SuspendableRunner) { validate { validator(it).orThrow()