Skip to content

Commit

Permalink
feat: voyager type-safe routing
Browse files Browse the repository at this point in the history
  • Loading branch information
programadorthi committed Jan 13, 2024
1 parent d33a49e commit 0799c6c
Show file tree
Hide file tree
Showing 8 changed files with 625 additions and 33 deletions.
2 changes: 2 additions & 0 deletions voyager/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ kotlin {
commonMain {
dependencies {
api(projects.core)
api(libs.ktor.resources)
api(libs.serialization.core)
api(libs.voyager.navigator)
implementation(libs.compose.runtime)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package dev.programadorthi.routing.voyager

import dev.programadorthi.routing.core.application.Application
import dev.programadorthi.routing.core.application.BaseApplicationPlugin
import dev.programadorthi.routing.core.application.plugin
import io.ktor.http.URLBuilder
import io.ktor.resources.href
import io.ktor.util.AttributeKey
import io.ktor.resources.Resources as ResourcesCore

/**
* Adds support for type-safe routing using [ResourcesCore].
*
* Example:
* ```kotlin
* @Resource("/users")
* class Users {
* @Resource("/{id}")
* class ById(val parent: Users = Users(), val id: Long)
*
* @Resource("/add")
* class Add(val parent: Users = Users(), val name: String)
* }
*
* routing {
* get<Users.ById> { userById ->
* val userId: Long = userById.id
* }
* post<Users.Add> { addUser ->
* val userName: String = addUser.name
* }
* }
*
* // client-side
* val newUserId = client.post(Users.Add("new_user"))
* val addedUser = client.get(Users.ById(newUserId))
* ```
*
* Server: [Type-safe routing](https://ktor.io/docs/type-safe-routing.html)
*
* Client: [Type-safe requests](https://ktor.io/docs/type-safe-request.html)
*
* @see Resource
*/
public object VoyagerResources :
BaseApplicationPlugin<Application, ResourcesCore.Configuration, ResourcesCore> {

override val key: AttributeKey<ResourcesCore> = AttributeKey("VoyagerResources")

override fun install(
pipeline: Application,
configure: ResourcesCore.Configuration.() -> Unit
): ResourcesCore {
val configuration = ResourcesCore.Configuration().apply(configure)
return ResourcesCore(configuration)
}
}

/**
* Constructs a URL for [resource].
*
* The class of the [resource] instance **must** be annotated with [Resource].
*/
public inline fun <reified T : Any> Application.href(resource: T): String {
return href(plugin(VoyagerResources).resourcesFormat, resource)
}

/**
* Constructs a URL for [resource].
*
* The class of the [resource] instance **must** be annotated with [Resource].
*/
public inline fun <reified T : Any> Application.href(resource: T, urlBuilder: URLBuilder) {
href(plugin(VoyagerResources).resourcesFormat, resource, urlBuilder)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package dev.programadorthi.routing.voyager

import cafe.adriel.voyager.core.screen.Screen
import dev.programadorthi.routing.core.OptionalParameterRouteSelector
import dev.programadorthi.routing.core.ParameterRouteSelector
import dev.programadorthi.routing.core.Route
import dev.programadorthi.routing.core.RouteMethod
import dev.programadorthi.routing.core.Routing
import dev.programadorthi.routing.core.application
import dev.programadorthi.routing.core.application.ApplicationCall
import dev.programadorthi.routing.core.application.ApplicationCallPipeline
import dev.programadorthi.routing.core.application.application
import dev.programadorthi.routing.core.application.call
import dev.programadorthi.routing.core.application.plugin
import dev.programadorthi.routing.core.errors.BadRequestException
import dev.programadorthi.routing.core.method
import dev.programadorthi.routing.core.route
import io.ktor.util.AttributeKey
import io.ktor.util.pipeline.PipelineContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer

/**
* Registers a typed handler [body] for a resource defined by the [T] class.
*
* A class [T] **must** be annotated with [io.ktor.resources.Resource].
*
* @param body receives an instance of the typed resource [T] as the first parameter.
*/
public inline fun <reified T : Any> Route.screen(
noinline body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Screen
): Route {
val serializer = serializer<T>()
return screen(serializer) {
handle(serializer, body)
}
}

/**
* Registers a typed handler for a [Screen] defined by the [T] class.
*
* A class [T] **must** be annotated with [io.ktor.resources.Resource].
*/
public inline fun <reified T : Screen> Route.screen(): Route = screen<T> { screen -> screen }

/**
* Registers a typed handler [body] for a [VoyagerRouteMethod] resource defined by the [T] class.
*
* A class [T] **must** be annotated with [io.ktor.resources.Resource].
*
* @param body receives an instance of the typed resource [T] as the first parameter.
*/
public inline fun <reified T : Any> Route.screen(
method: RouteMethod,
noinline body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Screen
): Route {
val serializer = serializer<T>()
lateinit var builtRoute: Route
screen(serializer) {
builtRoute = method(method) {
handle(serializer, body)
}
}
return builtRoute
}

/**
* Registers a typed handler for a [VoyagerRouteMethod] [Screen] defined by the [T] class.
*
* A class [T] **must** be annotated with [io.ktor.resources.Resource].
*/
public inline fun <reified T : Screen> Route.screen(
method: RouteMethod,
): Route = screen<T>(method) { screen -> screen }

@PublishedApi
internal val ResourceInstanceKey: AttributeKey<Any> = AttributeKey("ResourceInstance")

/**
* Registers a route [body] for a resource defined by the [T] class.
*
* @param serializer is used to decode the parameters of the request to an instance of the typed resource [T].
*
* A class [T] **must** be annotated with [io.ktor.resources.Resource].
*/
public fun <T : Any> Route.screen(
serializer: KSerializer<T>,
body: Route.() -> Unit
): Route {
val resources = application.plugin(VoyagerResources)
val path = resources.resourcesFormat.encodeToPathPattern(serializer)
val queryParameters = resources.resourcesFormat.encodeToQueryParameters(serializer)
var route = this
// Required for register to parents
route(path = path, name = null) {
route = queryParameters.fold(this) { entry, query ->
val selector = if (query.isOptional) {
OptionalParameterRouteSelector(query.name)
} else {
ParameterRouteSelector(query.name)
}
entry.createChild(selector)
}.apply(body)
}
return route
}

/**
* Registers a handler [body] for a resource defined by the [T] class.
*
* @param serializer is used to decode the parameters of the request to an instance of the typed resource [T].
* @param body receives an instance of the typed resource [T] as the first parameter.
*/
@PublishedApi
internal fun <T : Any> Route.handle(
serializer: KSerializer<T>,
body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Screen
) {
intercept(ApplicationCallPipeline.Plugins) {
val resources = application.plugin(VoyagerResources)
try {
val resource =
resources.resourcesFormat.decodeFromParameters(serializer, call.parameters)
call.attributes.put(ResourceInstanceKey, resource)
} catch (cause: Throwable) {
throw BadRequestException("Can't transform call to resource", cause)
}
}

handle {
@Suppress("UNCHECKED_CAST")
val resource = call.attributes[ResourceInstanceKey] as T
screen {
when (resource) {
is Screen -> resource
else -> body(resource)
}
}
}
}

public inline fun <reified T : Any> Routing.unregisterScreen() {
val serializer = serializer<T>()
val route = screen(serializer) {}
unregisterRoute(route)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.programadorthi.routing.voyager

import dev.programadorthi.routing.core.Routing
import dev.programadorthi.routing.core.application
import dev.programadorthi.routing.core.application.pluginOrNull

public inline fun <reified T : Any> Routing.push(resource: T) {
checkNotNull(application.pluginOrNull(VoyagerResources)) {
"VoyagerResources plugin not installed"
}
push(path = application.href(resource))
}

public inline fun <reified T : Any> Routing.replace(resource: T) {
checkNotNull(application.pluginOrNull(VoyagerResources)) {
"VoyagerResources plugin not installed"
}
replace(path = application.href(resource))
}

public inline fun <reified T : Any> Routing.replaceAll(resource: T) {
checkNotNull(application.pluginOrNull(VoyagerResources)) {
"VoyagerResources plugin not installed"
}
replaceAll(path = application.href(resource))
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@ import io.ktor.util.pipeline.PipelineContext
public fun Route.screen(
path: String,
name: String? = null,
body: PipelineContext<Unit, ApplicationCall>.() -> Screen,
body: suspend PipelineContext<Unit, ApplicationCall>.() -> Screen,
): Route = route(path = path, name = name) { screen(body) }

@KtorDsl
public fun Route.screen(
path: String,
method: RouteMethod,
name: String? = null,
body: PipelineContext<Unit, ApplicationCall>.() -> Screen,
body: suspend PipelineContext<Unit, ApplicationCall>.() -> Screen,
): Route = route(path = path, name = name, method = method) { screen(body) }

@KtorDsl
public fun Route.screen(
body: PipelineContext<Unit, ApplicationCall>.() -> Screen,
body: suspend PipelineContext<Unit, ApplicationCall>.() -> Screen,
) {
handle {
screen {
Expand All @@ -35,36 +35,8 @@ public fun Route.screen(
}
}

/*@KtorDsl
public inline fun <reified T : Any> Route.screen(
noinline body: PipelineContext<Unit, ApplicationCall>.(T) -> Screen
): Route = resource<T> {
handle(serializer<T>()) { value ->
screen {
body(value)
}
}
}
public inline fun <reified T : Any> Route.screen(
method: RouteMethod,
noinline body: PipelineContext<Unit, ApplicationCall>.(T) -> Screen
): Route {
lateinit var builtRoute: Route
resource<T> {
builtRoute = method(method) {
handle(serializer<T>()) { value ->
screen {
body(value)
}
}
}
}
return builtRoute
}*/

public fun PipelineContext<Unit, ApplicationCall>.screen(
body: () -> Screen,
public suspend fun PipelineContext<Unit, ApplicationCall>.screen(
body: suspend () -> Screen,
) {
val navigator = call.voyagerNavigator
when (call.routeMethod) {
Expand Down
Loading

0 comments on commit 0799c6c

Please sign in to comment.