Skip to content

Alternative Ktor Raise DSL #3581

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi

plugins {
id("arrow.kotlin")
alias(libs.plugins.kotlinx.serialization)
}

kotlin {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions.freeCompilerArgs.add("-Xcontext-receivers")

sourceSets {
commonMain {
dependencies {
Expand All @@ -18,6 +14,8 @@ kotlin {

commonTest {
dependencies {
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.serialization.kotlinxJson)
implementation(libs.ktor.test)
implementation(libs.bundles.testing)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package arrow.raise.ktor.server

import arrow.core.NonEmptyList
import arrow.raise.ktor.server.request.RequestError
import arrow.raise.ktor.server.request.toSimpleMessage
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.content.TextContent
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.RouteScopedPlugin
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.util.AttributeKey
import io.ktor.util.Attributes

private typealias ErrorResponse = (NonEmptyList<RequestError>) -> Response

private val errorResponseKey = AttributeKey<ErrorResponse>("ktor-raise-error-response")

internal var Attributes.errorResponse: ErrorResponse
get() = getOrNull(errorResponseKey) ?: ::defaultErrorsResponse
private set(value) {
put(errorResponseKey, value)
}

@PublishedApi
internal fun ApplicationCall.errorResponse(errors: NonEmptyList<RequestError>): Response = attributes.errorResponse(errors)

public class RaiseErrorResponseConfig(
public var errorResponse: ErrorResponse = ::defaultErrorsResponse,
) {
public fun errorResponse(response: (NonEmptyList<RequestError>) -> Response) {
errorResponse = response
}
}

public val RaiseErrorResponse: RouteScopedPlugin<RaiseErrorResponseConfig> = createRouteScopedPlugin(
name = "RequestLoggingPlugin",
createConfiguration = { RaiseErrorResponseConfig() }
) {
onCall { call ->
call.attributes.errorResponse = pluginConfig.errorResponse
}
}

private fun defaultErrorsResponse(errors: NonEmptyList<RequestError>): Response =
Response.Companion.raw(
TextContent(
text = errors.joinToString("\n") { it.toSimpleMessage() },
contentType = ContentType.Text.Plain,
status = BadRequest,
),
)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package arrow.raise.ktor.server

import arrow.core.nel
import arrow.core.raise.ExperimentalRaiseAccumulateApi
import arrow.core.raise.Raise
import arrow.core.raise.RaiseAccumulate
import arrow.raise.ktor.server.request.Parameter
import arrow.raise.ktor.server.request.ParameterTransform
import arrow.raise.ktor.server.request.RaisingParameterProvider
import arrow.raise.ktor.server.request.RequestError
import arrow.raise.ktor.server.request.pathOrRaise
import arrow.raise.ktor.server.request.queryOrRaise
import arrow.raise.ktor.server.request.receiveNullableOrRaise
import arrow.raise.ktor.server.request.receiveOrRaise
import io.ktor.http.*
import io.ktor.server.routing.*
import kotlin.jvm.JvmName

public class RaiseRoutingContext(
private val raise: Raise<Response>,
routingContext: RoutingContext,
) : CallRaiseContext(routingContext.call), Raise<Response> by raise {
@PublishedApi
internal val errorRaise: Raise<RequestError> =
object : Raise<RequestError> {
override fun raise(requestError: RequestError) = raise(call.errorResponse(requestError.nel()))
}

public fun raise(requestError: RequestError): Nothing = errorRaise.raise(requestError)

public val pathRaising: RaisingParameterProvider = call.pathParameters.delegate(Parameter::Path)
public val queryRaising: RaisingParameterProvider = call.queryParameters.delegate(Parameter::Query)
public suspend fun formParametersDelegate(): RaisingParameterProvider =
errorRaise.receiveOrRaise<Parameters>(call).delegate(Parameter::Form)

private fun Parameters.delegate(parameter: (String) -> Parameter) = RaisingParameterProvider(errorRaise, this, parameter)
}

/** Temporary intersection type, until we have context parameters */
public open class CallRaiseContext internal constructor(public val call: RoutingCall) {
// <editor-fold desc="raising extensions">
@JvmName("pathOrRaiseReified")
public inline fun <reified A : Any> Raise<RequestError>.pathOrRaise(name: String): A = pathOrRaise<A>(call, name)
public inline fun <A : Any> Raise<RequestError>.pathOrRaise(name: String, transform: ParameterTransform<A>): A = pathOrRaise(call, name, transform)
public fun Raise<RequestError>.pathOrRaise(name: String): String = pathOrRaise(call, name)

@JvmName("queryOrRaiseReified")
public inline fun <reified A : Any> Raise<RequestError>.queryOrRaise(name: String): A = queryOrRaise<A>(call, name)
public inline fun <A : Any> Raise<RequestError>.queryOrRaise(name: String, transform: ParameterTransform<A>): A = queryOrRaise(call, name, transform)
public fun Raise<RequestError>.queryOrRaise(name: String): String = queryOrRaise(call, name)

public suspend inline fun <reified A : Any> Raise<RequestError>.receiveOrRaise(): A = receiveOrRaise(call)
public suspend inline fun <reified A : Any> Raise<RequestError>.receiveNullableOrRaise(): A? = receiveNullableOrRaise(call)
// </editor-fold>

// <editor-fold desc="accumulating extensions">
@ExperimentalRaiseAccumulateApi
@JvmName("pathOrAccumulateReified")
public inline fun <reified A : Any> RaiseAccumulate<RequestError>.pathOrAccumulate(name: String): RaiseAccumulate.Value<A> = accumulating { pathOrRaise<A>(call, name) }

@ExperimentalRaiseAccumulateApi
public inline fun <A : Any> RaiseAccumulate<RequestError>.pathOrAccumulate(name: String, transform: ParameterTransform<A>): RaiseAccumulate.Value<A> = accumulating { pathOrRaise(call, name, transform) }

@ExperimentalRaiseAccumulateApi
public fun RaiseAccumulate<RequestError>.pathOrAccumulate(name: String): RaiseAccumulate.Value<String> = accumulating { pathOrRaise(call, name) }

@ExperimentalRaiseAccumulateApi
@JvmName("queryOrAccumulateReified")
public inline fun <reified A : Any> RaiseAccumulate<RequestError>.queryOrAccumulate(name: String): RaiseAccumulate.Value<A> = accumulating { queryOrRaise<A>(call, name) }

@ExperimentalRaiseAccumulateApi
public inline fun <A : Any> RaiseAccumulate<RequestError>.queryOrAccumulate(name: String, transform: ParameterTransform<A>): RaiseAccumulate.Value<A> = accumulating { queryOrRaise(call, name, transform) }

@ExperimentalRaiseAccumulateApi
public fun RaiseAccumulate<RequestError>.queryOrAccumulate(name: String): RaiseAccumulate.Value<String> = accumulating { queryOrRaise(call, name) }


@ExperimentalRaiseAccumulateApi
public suspend inline fun <reified A : Any> RaiseAccumulate<RequestError>.receiveOrAccumulate(): RaiseAccumulate.Value<A> = accumulating { receiveOrRaise(call) }

@ExperimentalRaiseAccumulateApi
public suspend inline fun <reified A : Any> RaiseAccumulate<RequestError>.receiveNullableOrAccumulate(): RaiseAccumulate.Value<A?> = accumulating { receiveNullableOrRaise(call) }
// </editor-fold>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package arrow.raise.ktor.server

import io.ktor.http.*
import io.ktor.server.routing.*
import io.ktor.util.reflect.*

internal typealias KtorRoutingHandler = RoutingHandler
// this is missing in Ktor - they have a RouteHandler typealias, but no "receiving" equivalent
internal typealias KtorReceivingRoutingHandler<T> = suspend RoutingContext.(T) -> Unit

internal typealias RespondOrRaiseHandler<Response> = suspend RaiseRoutingContext.() -> Response

// due to compilation ambiguity between n-ary lambdas on function resolution, by using this SAM on the API it's resolved with a lower priority
// which mitigates the ambiguity of `handler { }` vs `handler { it -> }`
public fun interface ReceivingRespondOrRaiseHandler<Request, Response> {
public suspend fun RaiseRoutingContext.handle(request: Request): Response
}

@PublishedApi
internal inline fun <reified Response> RespondOrRaiseHandler<Response>.asKtorHandler(statusCode: HttpStatusCode?) =
asKtorHandler(statusCode, typeInfo<Response>())

@PublishedApi
internal fun <Response> RespondOrRaiseHandler<Response>.asKtorHandler(
statusCode: HttpStatusCode?, typeInfo: TypeInfo,
): KtorRoutingHandler = { respondOrRaise(statusCode, typeInfo, ::invoke) }

@PublishedApi
internal inline fun <Request, reified Response> ReceivingRespondOrRaiseHandler<Request, Response>.asKtorHandler(statusCode: HttpStatusCode?) =
asKtorHandler(statusCode, typeInfo<Response>())

@PublishedApi
internal fun <Request, Response> ReceivingRespondOrRaiseHandler<Request, Response>.asKtorHandler(
statusCode: HttpStatusCode?, typeInfo: TypeInfo
): KtorReceivingRoutingHandler<Request> = { respondOrRaise(statusCode, typeInfo) { handle(it) } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package arrow.raise.ktor.server

import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.util.reflect.*

public sealed interface Response {
public suspend fun respondTo(call: ApplicationCall)

public companion object {
@PublishedApi
internal fun Response(statusCode: HttpStatusCode, value: Any?, typeInfo: TypeInfo): Response = Typed(statusCode, value, typeInfo)

// faux constructors
public fun Response(outgoingContent: OutgoingContent): Response = Raw(outgoingContent)
public fun Response(statusCode: HttpStatusCode): Response = Raw(HttpStatusCodeContent(statusCode))
public inline fun <reified T> Response(statusCode: HttpStatusCode, value: T): Response = Response(statusCode, value, typeInfo<T>())

// TODO: not sure if we want these three or not - allows for `Response.empty(OK)` and `Response.of(myPayload)` etc
public fun empty(statusCode: HttpStatusCode = HttpStatusCode.Companion.NoContent): Response = Response(statusCode)
public fun raw(outgoingContent: OutgoingContent): Response = Response(outgoingContent)
public inline fun <reified T> payload(value: T, statusCode: HttpStatusCode = HttpStatusCode.Companion.OK): Response = Response(statusCode, value, typeInfo<T>())

// allows using a HttpStatusCode as a "constructor" of a response, i.e. `NotFound("user was missing")`
public inline operator fun <reified T : Any> HttpStatusCode.invoke(payload: T) = Response(this, payload, typeInfo<T>())
}
}

private data class Typed(val statusCode: HttpStatusCode, val content: Any?, val typeInfo: TypeInfo) : Response {
override suspend fun respondTo(call: ApplicationCall) = call.respond(statusCode, content, typeInfo)
}

private data class Raw(val outgoingContent: OutgoingContent) : Response {
override suspend fun respondTo(call: ApplicationCall) = call.respond(outgoingContent, null)
}

This file was deleted.

Loading
Loading