diff --git a/voyager/build.gradle.kts b/voyager/build.gradle.kts index 932fece..74feb6e 100644 --- a/voyager/build.gradle.kts +++ b/voyager/build.gradle.kts @@ -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) } diff --git a/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResources.kt b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResources.kt new file mode 100644 index 0000000..c561831 --- /dev/null +++ b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResources.kt @@ -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 { userById -> + * val userId: Long = userById.id + * } + * post { 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 { + + override val key: AttributeKey = 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 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 Application.href(resource: T, urlBuilder: URLBuilder) { + href(plugin(VoyagerResources).resourcesFormat, resource, urlBuilder) +} diff --git a/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResourcesBuilder.kt b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResourcesBuilder.kt new file mode 100644 index 0000000..77f0d95 --- /dev/null +++ b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResourcesBuilder.kt @@ -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 Route.screen( + noinline body: suspend PipelineContext.(T) -> Screen +): Route { + val serializer = serializer() + 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 Route.screen(): Route = screen { 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 Route.screen( + method: RouteMethod, + noinline body: suspend PipelineContext.(T) -> Screen +): Route { + val serializer = serializer() + 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 Route.screen( + method: RouteMethod, +): Route = screen(method) { screen -> screen } + +@PublishedApi +internal val ResourceInstanceKey: AttributeKey = 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 Route.screen( + serializer: KSerializer, + 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 Route.handle( + serializer: KSerializer, + body: suspend PipelineContext.(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 Routing.unregisterScreen() { + val serializer = serializer() + val route = screen(serializer) {} + unregisterRoute(route) +} diff --git a/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResourcesRoutingExt.kt b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResourcesRoutingExt.kt new file mode 100644 index 0000000..9c897da --- /dev/null +++ b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerResourcesRoutingExt.kt @@ -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 Routing.push(resource: T) { + checkNotNull(application.pluginOrNull(VoyagerResources)) { + "VoyagerResources plugin not installed" + } + push(path = application.href(resource)) +} + +public inline fun Routing.replace(resource: T) { + checkNotNull(application.pluginOrNull(VoyagerResources)) { + "VoyagerResources plugin not installed" + } + replace(path = application.href(resource)) +} + +public inline fun Routing.replaceAll(resource: T) { + checkNotNull(application.pluginOrNull(VoyagerResources)) { + "VoyagerResources plugin not installed" + } + replaceAll(path = application.href(resource)) +} diff --git a/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerRoutingBuilder.kt b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerRoutingBuilder.kt index f9fe48c..5c46e1a 100644 --- a/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerRoutingBuilder.kt +++ b/voyager/common/src/dev/programadorthi/routing/voyager/VoyagerRoutingBuilder.kt @@ -13,7 +13,7 @@ import io.ktor.util.pipeline.PipelineContext public fun Route.screen( path: String, name: String? = null, - body: PipelineContext.() -> Screen, + body: suspend PipelineContext.() -> Screen, ): Route = route(path = path, name = name) { screen(body) } @KtorDsl @@ -21,12 +21,12 @@ public fun Route.screen( path: String, method: RouteMethod, name: String? = null, - body: PipelineContext.() -> Screen, + body: suspend PipelineContext.() -> Screen, ): Route = route(path = path, name = name, method = method) { screen(body) } @KtorDsl public fun Route.screen( - body: PipelineContext.() -> Screen, + body: suspend PipelineContext.() -> Screen, ) { handle { screen { @@ -35,36 +35,8 @@ public fun Route.screen( } } -/*@KtorDsl -public inline fun Route.screen( - noinline body: PipelineContext.(T) -> Screen -): Route = resource { - handle(serializer()) { value -> - screen { - body(value) - } - } -} - -public inline fun Route.screen( - method: RouteMethod, - noinline body: PipelineContext.(T) -> Screen -): Route { - lateinit var builtRoute: Route - resource { - builtRoute = method(method) { - handle(serializer()) { value -> - screen { - body(value) - } - } - } - } - return builtRoute -}*/ - -public fun PipelineContext.screen( - body: () -> Screen, +public suspend fun PipelineContext.screen( + body: suspend () -> Screen, ) { val navigator = call.voyagerNavigator when (call.routeMethod) { diff --git a/voyager/common/test/dev/programadorthi/routing/voyager/VoyagerResourcesRoutingTest.kt b/voyager/common/test/dev/programadorthi/routing/voyager/VoyagerResourcesRoutingTest.kt new file mode 100644 index 0000000..97821a4 --- /dev/null +++ b/voyager/common/test/dev/programadorthi/routing/voyager/VoyagerResourcesRoutingTest.kt @@ -0,0 +1,351 @@ +package dev.programadorthi.routing.voyager + +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.Navigator +import dev.programadorthi.routing.core.application.MissingApplicationPluginException +import dev.programadorthi.routing.core.install +import dev.programadorthi.routing.core.routing +import dev.programadorthi.routing.voyager.helper.FakeScreen +import dev.programadorthi.routing.voyager.helper.Path +import dev.programadorthi.routing.voyager.helper.runComposeTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +internal class VoyagerResourcesRoutingTest { + + @Test + fun shouldThrowExceptionWhenThePluginIsNotInstalled() { + val exception = assertFails { + routing { + screen() + } + } + assertIs(exception) + assertEquals("Application plugin VoyagerResources is not installed", exception.message) + } + + @Test + fun shouldPushAScreen() = + runComposeTest { coroutineContext, composition, clock -> + // GIVEN + var navigator: Navigator? = null + + val routing = routing(parentCoroutineContext = coroutineContext) { + install(VoyagerResources) + + screen() + } + + composition.setContent { + VoyagerRouting( + routing = routing, + initialScreen = FakeScreen(), + ) { nav -> + navigator = nav + CurrentScreen() + } + } + + // WHEN + routing.push( + resource = FakeScreen().apply { + content = "Hey, I am the pushed screen" + } + ) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val lastScreen = navigator?.lastItemOrNull as? FakeScreen + + // THEN + assertEquals("Hey, I am the pushed screen", lastScreen?.composed) + assertEquals(false, lastScreen?.disposed, "Last screen should not be disposed") + } + + @Test + fun shouldPushAScreenUsingOtherResource() = + runComposeTest { coroutineContext, composition, clock -> + // GIVEN + var navigator: Navigator? = null + + val routing = routing(parentCoroutineContext = coroutineContext) { + install(VoyagerResources) + + screen { + FakeScreen().apply { + content = "Hey, I am the pushed screen" + } + } + } + + composition.setContent { + VoyagerRouting( + routing = routing, + initialScreen = FakeScreen(), + ) { nav -> + navigator = nav + CurrentScreen() + } + } + + // WHEN + routing.push(resource = Path()) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val lastScreen = navigator?.lastItemOrNull as? FakeScreen + + // THEN + assertEquals("Hey, I am the pushed screen", lastScreen?.composed) + assertEquals(false, lastScreen?.disposed, "Last screen should not be disposed") + } + + @Test + fun shouldReplaceAScreen() = + runComposeTest { coroutineContext, composition, clock -> + // GIVEN + var navigator: Navigator? = null + + val routing = routing(parentCoroutineContext = coroutineContext) { + install(VoyagerResources) + + screen() + } + + composition.setContent { + VoyagerRouting( + routing = routing, + initialScreen = FakeScreen(), + ) { nav -> + navigator = nav + CurrentScreen() + } + } + + // WHEN + routing.push( + resource = FakeScreen().apply { + content = "Hey, I am the pushed screen" + } + ) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val pushedScreen = navigator?.lastItemOrNull as? FakeScreen + + routing.replace( + resource = FakeScreen().apply { + content = "Hey, I am the replaced screen" + } + ) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val replacedScreen = navigator?.lastItemOrNull as? FakeScreen + + // THEN + assertEquals("Hey, I am the pushed screen", pushedScreen?.composed) + assertEquals(true, pushedScreen?.disposed, "First screen should be disposed") + assertEquals("Hey, I am the replaced screen", replacedScreen?.composed) + assertEquals(false, replacedScreen?.disposed, "Replaced screen should not be disposed") + } + + @Test + fun shouldReplaceAScreenUsingOtherResource() = + runComposeTest { coroutineContext, composition, clock -> + // GIVEN + var navigator: Navigator? = null + + val routing = routing(parentCoroutineContext = coroutineContext) { + install(VoyagerResources) + + screen { + FakeScreen().apply { + content = "Hey, I am the pushed screen" + } + } + + screen { + FakeScreen().apply { + content = "Hey, I am the replaced screen" + } + } + } + + composition.setContent { + VoyagerRouting( + routing = routing, + initialScreen = FakeScreen(), + ) { nav -> + navigator = nav + CurrentScreen() + } + } + + // WHEN + routing.push(resource = Path()) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val pushedScreen = navigator?.lastItemOrNull as? FakeScreen + + routing.replace(resource = Path.Id(id = 123)) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val replacedScreen = navigator?.lastItemOrNull as? FakeScreen + + // THEN + assertEquals("Hey, I am the pushed screen", pushedScreen?.composed) + assertEquals(true, pushedScreen?.disposed, "First screen should be disposed") + assertEquals("Hey, I am the replaced screen", replacedScreen?.composed) + assertEquals(false, replacedScreen?.disposed, "Replaced screen should not be disposed") + } + + @Test + fun shouldReplaceAllScreens() = + runComposeTest { coroutineContext, composition, clock -> + // GIVEN + var counter = 1 + var navigator: Navigator? = null + + val routing = routing(parentCoroutineContext = coroutineContext) { + install(VoyagerResources) + + screen() + } + + composition.setContent { + VoyagerRouting( + routing = routing, + initialScreen = FakeScreen(), + ) { nav -> + navigator = nav + CurrentScreen() + } + } + + // WHEN + routing.push( + resource = FakeScreen().apply { + content = "Hey, I am the pushed screen number ${counter++}" + } + ) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val firstPushedScreen = navigator?.lastItemOrNull as? FakeScreen + + routing.push( + resource = FakeScreen().apply { + content = "Hey, I am the pushed screen number ${counter++}" + } + ) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val secondPushedScreen = navigator?.lastItemOrNull as? FakeScreen + + routing.replaceAll( + resource = FakeScreen().apply { + content = "Hey, I am the replaced all screen" + } + ) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val replacedAllScreen = navigator?.lastItemOrNull as? FakeScreen + + // THEN + assertEquals("Hey, I am the pushed screen number 1", firstPushedScreen?.composed) + assertEquals( + true, + firstPushedScreen?.disposed, + "First pushed screen should be disposed" + ) + assertEquals("Hey, I am the pushed screen number 2", secondPushedScreen?.composed) + assertEquals( + true, + secondPushedScreen?.disposed, + "Second pushed screen should be disposed" + ) + assertEquals("Hey, I am the replaced all screen", replacedAllScreen?.composed) + assertEquals( + false, + replacedAllScreen?.disposed, + "Replaced all screen should not be disposed" + ) + } + + @Test + fun shouldReplaceAllScreensUsingOtherResource() = + runComposeTest { coroutineContext, composition, clock -> + // GIVEN + var counter = 1 + var navigator: Navigator? = null + + val routing = routing(parentCoroutineContext = coroutineContext) { + install(VoyagerResources) + + screen { + FakeScreen().apply { + content = "Hey, I am the pushed screen number ${counter++}" + } + } + + screen { + FakeScreen().apply { + content = "Hey, I am the pushed screen number ${counter++}" + } + } + + screen { + FakeScreen().apply { + content = "Hey, I am the replaced all screen" + } + } + } + + composition.setContent { + VoyagerRouting( + routing = routing, + initialScreen = FakeScreen(), + ) { nav -> + navigator = nav + CurrentScreen() + } + } + + // WHEN + routing.push(resource = Path()) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val firstPushedScreen = navigator?.lastItemOrNull as? FakeScreen + + routing.push(resource = Path.Id(id = 123)) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val secondPushedScreen = navigator?.lastItemOrNull as? FakeScreen + + routing.replaceAll(resource = Path.Name()) + advanceTimeBy(99) // Ask for routing + clock.sendFrame(0L) // Ask for recomposition + val replacedAllScreen = navigator?.lastItemOrNull as? FakeScreen + + // THEN + assertEquals("Hey, I am the pushed screen number 1", firstPushedScreen?.composed) + assertEquals( + true, + firstPushedScreen?.disposed, + "First pushed screen should be disposed" + ) + assertEquals("Hey, I am the pushed screen number 2", secondPushedScreen?.composed) + assertEquals( + true, + secondPushedScreen?.disposed, + "Second pushed screen should be disposed" + ) + assertEquals("Hey, I am the replaced all screen", replacedAllScreen?.composed) + assertEquals( + false, + replacedAllScreen?.disposed, + "Replaced all screen should not be disposed" + ) + } +} diff --git a/voyager/common/test/dev/programadorthi/routing/voyager/helper/FakeScreen.kt b/voyager/common/test/dev/programadorthi/routing/voyager/helper/FakeScreen.kt index 6e124ea..bddbcff 100644 --- a/voyager/common/test/dev/programadorthi/routing/voyager/helper/FakeScreen.kt +++ b/voyager/common/test/dev/programadorthi/routing/voyager/helper/FakeScreen.kt @@ -6,8 +6,11 @@ import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import dev.programadorthi.routing.voyager.VoyagerRoutingPopResult import io.ktor.http.Parameters +import io.ktor.resources.Resource +import kotlinx.serialization.Transient import kotlin.random.Random +@Resource("/fakescreen") class FakeScreen : Screen, VoyagerRoutingPopResult { var composed = "" @@ -18,6 +21,7 @@ class FakeScreen : Screen, VoyagerRoutingPopResult { var disposed = false private set + @Transient var parameters = Parameters.Empty private set diff --git a/voyager/common/test/dev/programadorthi/routing/voyager/helper/Path.kt b/voyager/common/test/dev/programadorthi/routing/voyager/helper/Path.kt new file mode 100644 index 0000000..70f1553 --- /dev/null +++ b/voyager/common/test/dev/programadorthi/routing/voyager/helper/Path.kt @@ -0,0 +1,12 @@ +package dev.programadorthi.routing.voyager.helper + +import io.ktor.resources.Resource + +@Resource("/path") +class Path { + @Resource("{id}") + class Id(val parent: Path = Path(), val id: Int) + + @Resource("/name") + class Name(val parent: Path = Path()) +}