diff --git a/api/src/commonMain/kotlin/DynamicResource.kt b/api/src/commonMain/kotlin/DynamicResource.kt index 91bbb6c..368bfb5 100644 --- a/api/src/commonMain/kotlin/DynamicResource.kt +++ b/api/src/commonMain/kotlin/DynamicResource.kt @@ -1,15 +1,83 @@ package opensavvy.spine.api +/** + * A resource with a wildcard segment: `v1/users/{user}`, `v1/posts/{post}/subscribers/{user}`. + * + * To declare a resource of this type, create a singleton: + * ```kotlin + * // URL: v1 + * object Api : RootResource("v1") { + * + * // URL: v1/users + * object Users : StaticResource("users", parent = Api) { + * + * // Endpoint: GET v1/users + * val list by get() + * .response>() + * + * // URL: v1/users/{user} + * object User : DynamicResource("user", parent = Users) { + * + * // Endpoint: GET v1/users/{user} + * val get by get() + * .response() + * } + * } + * } + * ``` + * + * @see AnyEndpoint.Builder Declaring endpoints in a resource. + * @param Parent The type of the direct parent of this resource. + * Because of restrictions of the Kotlin language, it must be specified explicitly even if it already appears in the line + * because it is passed to [parent]. + * @constructor Creates a new [DynamicResource]. + * The passed [slug] should be a single word, which is the name of the wildcard added to the [parent]'s URL: for example, + * `"user"` or `"id"`. When such a resource is imported into Ktor, it recognizes that it is a wildcard. The exact value + * can be accessed on the server using the `idOf` function. + */ abstract class DynamicResource( slug: String, final override val parent: Parent, ) : Resource("{$slug}") { - class Identified> internal constructor( - val id: Path.Segment, - val self: Child, + /** + * Holder for a [DynamicResource] and a specific [slug] that matches its declared wildcard. + * + * This class is rarely used directly; it is used internally to construct instances of [ResolvedResource]. + * See [DynamicResource.invoke]. + */ + class Identified> internal constructor( + val slug: Path.Segment, + val resource: Self, ) } +/** + * Binds a specific identifier into a [DynamicResource]'s [slug][DynamicResource.slug]. + * + * This operator is part of the syntax for constructing instances of [ResolvedResource]. + * + * ```kotlin + * object Api : RootResource("v1") { + * object Users : StaticResource("users", Api) { + * object User : DynamicResource("user", Users) { + * object Favorites : StaticResource("favorites", User) { + * object Favorite : DynamicResource("favorite", Favorites) + * } + * } + * } + * } + * ``` + * + * To refer to the above resources: + * + * | Desired path | Kotlin code | + * |:--------------------------------|:--------------------------------------------------------------| + * | `"v1"` | `Api.resolved` (the root resource is special, see [resolved]) | + * | `"v1/users"` | `Api / Users` | + * | `"v1/users/1234"` | `Api / Users / User("1234")` | + * | `"v1/users/1234/favorites"` | `Api / Users / User("1234") / Favorites` | + * | `"v1/users/1234/favorites/789"` | `Api / Users / User("1234") / Favorites / Favorite("789")` | + */ operator fun > Child.invoke(id: String) = DynamicResource.Identified(Path.Segment(id), this) diff --git a/api/src/commonMain/kotlin/ResolvedResource.kt b/api/src/commonMain/kotlin/ResolvedResource.kt index 5f71697..d325592 100644 --- a/api/src/commonMain/kotlin/ResolvedResource.kt +++ b/api/src/commonMain/kotlin/ResolvedResource.kt @@ -36,7 +36,7 @@ class ResolvedResource internal constructor( } operator fun > ResolvedResource.div(child: Child): ResolvedResource = ResolvedResource(child, path + child.slug) -operator fun > ResolvedResource.div(child: DynamicResource.Identified): ResolvedResource = ResolvedResource(child.self, path + child.id) +operator fun > ResolvedResource.div(child: DynamicResource.Identified): ResolvedResource = ResolvedResource(child.resource, path + child.slug) operator fun > Root.div(child: Child) = this.resolved / child operator fun > Root.div(child: DynamicResource.Identified) = this.resolved / child diff --git a/api/src/commonMain/kotlin/Resource.kt b/api/src/commonMain/kotlin/Resource.kt index 89cd51c..843c706 100644 --- a/api/src/commonMain/kotlin/Resource.kt +++ b/api/src/commonMain/kotlin/Resource.kt @@ -2,7 +2,22 @@ package opensavvy.spine.api import io.ktor.http.* +/** + * Common parent for all resource types. + * + * Users of the library cannot directly subclass this. + * Instead, they should subclass one of its subtypes. + */ sealed class Resource( + /** + * The URL segment relating to this specific resource. + * + * For [StaticResource] and [RootResource], it is a single string like `"v1"` or `"users"`. + * + * For [DynamicResource], it is a wildcard, like `"{user}"` or `"{id}"`. + * + * To get the complete URL of this resource, starting from the root resource, see [fullSlug]. + */ val slug: String, ) { @@ -12,6 +27,8 @@ sealed class Resource( * Note that a [RootResource] has a `null` parent. * In all other cases, this attribute is non-`null`. * + * To follow the chain of parents, see [hierarchy]. + * * **Implementation note.** * This attribute must be immutable and should always return the exact same instance. */ @@ -29,13 +46,28 @@ sealed class Resource( } for (parent in hierarchy.filterNot { it == this }) { - require(parent.slug != this.slug) { "This resource cannot have the same slug as one of its parents: '$slug' is shared by $this and $parent" } + require(parent.slug != this.slug) { "This resource cannot have the same slug as one of its parents: '${this@Resource.slug}' is shared by $this and $parent" } } } + /** + * Returns resources that are direct children of the current resource. + * + * Note that resources are registered when they are first initialized by the runtime, on first access. + * If nothing in the program refers to a specific resource, it is possible that it doesn't appear + * in this sequence, even if it should. + */ val children: Sequence get() = _children.asSequence() + /** + * Returns all endpoints that are declared on this resource. + * + * Note that endpoints are typically declared during construction of the resource. + * When construction is not over yet, this sequence may be incomplete. + * + * To get all endpoints, including transitive children, see [endpoints]. + */ val directEndpoints: Sequence get() = _endpoints.asSequence() @@ -66,15 +98,48 @@ private suspend fun SequenceScope.hierarchy(self: Resource) { yield(self) } +/** + * Returns the hierarchy of this resource: following the [parent][Resource.parent] chain. + * + * For example, if we declare a resource: + * ```kotlin + * object Api : RootResource("v1") { + * object Users : StaticResource("users") { + * object User : DynamicResource("user") + * } + * } + * ``` + * then the hierarchy of each of them is their path: + * - `Api`: `[Api]` + * - `Api.Users`: `[Api, Users]` + * - `Api.Users.User`: `[Api, Users, User]` + */ val Resource.hierarchy: Sequence get() { val self = this return sequence { hierarchy(self) } } +/** + * The complete URL of this resource, starting from the [RootResource], to this resource. + * + * For example, a [RootResource] may have a slug `"v1"`. + * + * A [StaticResource] usually has a slug like `"v1/users"`. + * + * A [DynamicResource] has a slug like `"v1/users/{user}"`. + * + * @see Resource.slug The segment of this specific resource. + */ val Resource.fullSlug: String get() = hierarchy.map { it.slug }.joinToString("/") +/** + * Returns all endpoints that are declared on this resource or any of its children. + * + * See [children][Resource.children] and [directEndpoints][Resource.directEndpoints] for more information + * on initialization order and cases where this sequence may be incomplete. + */ val Resource.endpoints: Sequence get() = directEndpoints + children.flatMap { it.endpoints } diff --git a/api/src/commonMain/kotlin/RootResource.kt b/api/src/commonMain/kotlin/RootResource.kt index b75462d..45c23b5 100644 --- a/api/src/commonMain/kotlin/RootResource.kt +++ b/api/src/commonMain/kotlin/RootResource.kt @@ -1,5 +1,23 @@ package opensavvy.spine.api +/** + * The root resource of an API. + * + * The root resource is a special kind of [StaticResource] that doesn't have a [parent]. + * + * It is expected that users of the library use this class to define the root of their API: + * ```kotlin + * object Api : RootResource("v1") { + * object Users : StaticResource("/users", parent = Api) + * object Posts : StaticResource("/posts", parent = Api) + * } + * ``` + * + * @constructor Creates a new [RootResource]. + * The passed [slug] should be used to differentiate between multiple APIs deployed on the same server. + * For example, `"v1"` and `"v2"`. + * To select the exact URL used by the server, client should use the [DefaultRequest plugin](https://ktor.io/docs/client-default-request.html) to specify a base URL. + */ abstract class RootResource( slug: String, ) : Resource(slug), Addressed { @@ -16,8 +34,13 @@ abstract class RootResource( get() = null override val path: Path - get() = Path(slug) + get() = Path(this@RootResource.slug) } +/** + * Constructs a [ResolvedResource] out of a [RootResource]. + * + * See [ResolvedResource] to learn more. + */ val R.resolved: ResolvedResource get() = ResolvedResource(this, this.path) diff --git a/api/src/commonMain/kotlin/StaticResource.kt b/api/src/commonMain/kotlin/StaticResource.kt index d5f52a2..87b5b99 100644 --- a/api/src/commonMain/kotlin/StaticResource.kt +++ b/api/src/commonMain/kotlin/StaticResource.kt @@ -1,5 +1,48 @@ package opensavvy.spine.api +/** + * A resource with a hard-coded segment: `v1/users`, `v1/posts/favorites`. + * + * To declare a resource of this type, create a singleton: + * ```kotlin + * // URL: v1 + * object Api : RootResource("v1") { + * + * // URL: v1/users + * object Users : StaticResource("users", parent = Api) { + * + * // Endpoint: GET v1/users + * val list by get() + * .response>() + * + * } + * + * // URL: v1/posts + * object Posts : StaticResource("posts", parent = Api) { + * + * // URL: v1/posts/favorites + * object Favorites : StaticResource("favorites", parent = Posts) { + * + * // Endpoint: GET v1/posts/favorites + * val all by get() + * .response>() + * + * } + * } + * } + * ``` + * + * Note that static resources can be children of a [DynamicResource]. For example, `v1/users/{user}/key` is a static + * resource `"key"` that is a child of the dynamic resource `"{user}"`, itself a child of the static resource `"users"`, + * itself a child of the root resource `"v1"`. + * + * @see AnyEndpoint.Builder Declaring endpoints in a resource. + * @param Parent The type of the direct parent of this resource. + * Because of restrictions of the Kotlin language, it must be specified explicitly even if it already appears in the line + * because it is passed to [parent]. + * @constructor Creates a new [StaticResource]. + * The passed [slug] should be a single word, which represents the hierarchy between this endpoint and its [parent]. + */ abstract class StaticResource( slug: String, final override val parent: Parent,