Skip to content

Commit

Permalink
wip: ktor
Browse files Browse the repository at this point in the history
  • Loading branch information
nesk committed Nov 29, 2024
1 parent cde0ba4 commit df17493
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<AkkurateConfig> = createClientPlugin(
name = "Akkurate",
createConfiguration = ::AkkurateConfig
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,51 @@ 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<ClientValidator> = 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 <ContextType, reified ValueType> registerValidator(
validator: Validator.Runner.WithContext<ContextType, ValueType>,
noinline contextProvider: suspend () -> ContextType,
) {
registerValidator(ClientValidator.Runner.WithContext(ValueType::class, validator, contextProvider))
}

/**
* Registers a new [validator], which will be executed for each deserialized response body.
*/
public inline fun <reified ValueType> registerValidator(validator: Validator.Runner<ValueType>) {
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 <ContextType, reified ValueType> registerValidator(
validator: Validator.SuspendableRunner.WithContext<ContextType, ValueType>,
noinline contextProvider: suspend () -> ContextType,
) {
registerValidator(ClientValidator.SuspendableRunner.WithContext(ValueType::class, validator, contextProvider))
}

/**
* Registers a new [validator], which will be executed for each deserialized response body.
*/
public inline fun <reified ValueType> registerValidator(validator: Validator.SuspendableRunner<ValueType>) {
registerValidator(ClientValidator.SuspendableRunner(ValueType::class, validator))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValueType>(
private val valueType: KClass<*>,
private val validator: Validator.Runner<ValueType>,
Expand All @@ -34,6 +46,9 @@ public interface ClientValidator {
validator(value as ValueType).orThrow()
}

/**
* A validator for [Validator.Runner.WithContext] instances.
*/
public class WithContext<ContextType, ValueType>(
private val valueType: KClass<*>,
private val validator: Validator.Runner.WithContext<ContextType, ValueType>,
Expand All @@ -48,6 +63,9 @@ public interface ClientValidator {
}
}

/**
* A validator for [Validator.SuspendableRunner] instances.
*/
public class SuspendableRunner<ValueType>(
private val valueType: KClass<*>,
private val validator: Validator.SuspendableRunner<ValueType>,
Expand All @@ -59,6 +77,9 @@ public interface ClientValidator {
validator(value as ValueType).orThrow()
}

/**
* A validator for [Validator.SuspendableRunner.WithContext] instances.
*/
public class WithContext<ContextType, ValueType>(
private val valueType: KClass<*>,
private val validator: Validator.SuspendableRunner.WithContext<ContextType, ValueType>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AkkurateConfig> = createApplicationPlugin(
name = "Akkurate",
createConfiguration = ::AkkurateConfig
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ContextType, reified ValueType : Any> RequestValidationConfig.registerValidator(
validator: Validator.Runner.WithContext<ContextType, ValueType>,
noinline contextProvider: suspend () -> ContextType,
Expand All @@ -30,13 +35,20 @@ public inline fun <ContextType, reified ValueType : Any> RequestValidationConfig
}
}

/**
* Registers a new [validator], which will be executed for each [received][receive] request body.
*/
public inline fun <reified ValueType : Any> RequestValidationConfig.registerValidator(validator: Validator.Runner<ValueType>) {
validate<ValueType> {
validator(it).orThrow()
ValidationResult.Valid
}
}

/**
* 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 <ContextType, reified ValueType : Any> RequestValidationConfig.registerValidator(
validator: Validator.SuspendableRunner.WithContext<ContextType, ValueType>,
noinline contextProvider: suspend () -> ContextType,
Expand All @@ -47,6 +59,9 @@ public inline fun <ContextType, reified ValueType : Any> RequestValidationConfig
}
}

/**
* Registers a new [validator], which will be executed for each [received][receive] request body.
*/
public inline fun <reified ValueType : Any> RequestValidationConfig.registerValidator(validator: Validator.SuspendableRunner<ValueType>) {
validate<ValueType> {
validator(it).orThrow()
Expand Down

0 comments on commit df17493

Please sign in to comment.