diff --git a/api/src/commonMain/kotlin/Endpoint.kt b/api/src/commonMain/kotlin/Endpoint.kt index 13949a3..5a22de3 100644 --- a/api/src/commonMain/kotlin/Endpoint.kt +++ b/api/src/commonMain/kotlin/Endpoint.kt @@ -44,6 +44,12 @@ sealed interface AnyEndpoint { * If no query parameters are used by this endpoint, this function returns [Parameters.Empty]. */ val buildParameters: (ParameterStorage) -> Parameters + + sealed interface Builder { + fun request(kClass: KClass): Builder + fun response(kClass: KClass): Builder + fun

parameters(build: (ParameterStorage) -> P): Builder + } } /** @@ -55,7 +61,7 @@ sealed interface AnyEndpoint { * To avoid breakage, use [AnyEndpoint] instead in your code (however, you will lose access to the exact types used). */ @Deprecated( - message = "The Endpoint class may go through source-incompatible changes in the future, even in minor releases. Read its documentation to learn more.", + message = "The Endpoint class may go through source-incompatible changes in the future, even in minor releases. Use AnyEndpoint instead.", level = DeprecationLevel.HIDDEN, ) class Endpoint internal constructor( @@ -73,29 +79,34 @@ class Endpoint internal constructor( // region Builder - internal fun asBuilder(onCreate: (AnyEndpoint) -> Unit) = Builder(this, onCreate) + @Suppress("DEPRECATION_ERROR") + internal fun asBuilder(onCreate: (AnyEndpoint) -> Unit) = EndpointBuilder(this, onCreate) + @Deprecated( + message = "The EndpointBuilder class may go through source-incompatible changes in the future, even in minor releases. Use AnyEndpointBuilder instead.", + level = DeprecationLevel.HIDDEN, + ) @Suppress("DEPRECATION_ERROR") - class Builder internal constructor( + class EndpointBuilder internal constructor( private val endpoint: Endpoint, private val onCreate: (AnyEndpoint) -> Unit, - ) { + ) : AnyEndpoint.Builder { - fun request(kClass: KClass) = Builder( + override fun request(kClass: KClass) = EndpointBuilder( Endpoint(endpoint.resource, endpoint.method, endpoint.path, kClass, endpoint.responseType, endpoint.buildParameters), onCreate ) inline fun request() = request(T::class) - fun response(kClass: KClass) = Builder( + override fun response(kClass: KClass) = EndpointBuilder( Endpoint(endpoint.resource, endpoint.method, endpoint.path, endpoint.requestType, kClass, endpoint.buildParameters), onCreate ) inline fun response() = response(T::class) - fun

parameters(build: (ParameterStorage) -> P) = Builder( + override fun

parameters(build: (ParameterStorage) -> P) = EndpointBuilder( Endpoint(endpoint.resource, endpoint.method, endpoint.path, endpoint.requestType, endpoint.responseType, build), onCreate ) -- 2.51.2 From b1c950eef36ed6973a20cd0f5f0070b40d4c9450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 27 Oct 2024 14:51:29 +0100 Subject: [PATCH 2/5] feat(api): Improve type-safety for Resource.parent Before, it would be nullable for all implementations of Resource. It is now non-nullable for StaticResource and DynamicResource, but guaranteed null for RootResource. --- api/src/commonMain/kotlin/DynamicResource.kt | 4 ++-- api/src/commonMain/kotlin/Resource.kt | 17 ++++++++++++++--- api/src/commonMain/kotlin/RootResource.kt | 8 +++++++- api/src/commonMain/kotlin/StaticResource.kt | 4 ++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/api/src/commonMain/kotlin/DynamicResource.kt b/api/src/commonMain/kotlin/DynamicResource.kt index 3a3c034..91bbb6c 100644 --- a/api/src/commonMain/kotlin/DynamicResource.kt +++ b/api/src/commonMain/kotlin/DynamicResource.kt @@ -2,8 +2,8 @@ package opensavvy.spine.api abstract class DynamicResource( slug: String, - parent: Parent, -) : Resource("{$slug}", parent) { + final override val parent: Parent, +) : Resource("{$slug}") { class Identified> internal constructor( val id: Path.Segment, diff --git a/api/src/commonMain/kotlin/Resource.kt b/api/src/commonMain/kotlin/Resource.kt index 1030d9f..89cd51c 100644 --- a/api/src/commonMain/kotlin/Resource.kt +++ b/api/src/commonMain/kotlin/Resource.kt @@ -4,17 +4,28 @@ import io.ktor.http.* sealed class Resource( val slug: String, - val parent: Resource?, ) { + /** + * The parent resource of this resource. + * + * Note that a [RootResource] has a `null` parent. + * In all other cases, this attribute is non-`null`. + * + * **Implementation note.** + * This attribute must be immutable and should always return the exact same instance. + */ + abstract val parent: Resource? + private val _children = ArrayList() private val _endpoints = ArrayList() init { - if (parent != null) { + // Mark the parent + parent?.also { // We do not access the object directly, so it is safe @Suppress("LeakingThis") - parent._children += this + it._children += this } for (parent in hierarchy.filterNot { it == this }) { diff --git a/api/src/commonMain/kotlin/RootResource.kt b/api/src/commonMain/kotlin/RootResource.kt index 60931e4..b75462d 100644 --- a/api/src/commonMain/kotlin/RootResource.kt +++ b/api/src/commonMain/kotlin/RootResource.kt @@ -2,13 +2,19 @@ package opensavvy.spine.api abstract class RootResource( slug: String, -) : Resource(slug, parent = null), Addressed { +) : Resource(slug), Addressed { init { // Static resources' slug must be a valid path segment, since they appear as-is in the URL Path.Segment(slug) } + /** + * The parent of this resource. Since [RootResource] cannot have a parent, always returns `null`. + */ + final override val parent: Nothing? + get() = null + override val path: Path get() = Path(slug) } diff --git a/api/src/commonMain/kotlin/StaticResource.kt b/api/src/commonMain/kotlin/StaticResource.kt index 5de8b90..d5f52a2 100644 --- a/api/src/commonMain/kotlin/StaticResource.kt +++ b/api/src/commonMain/kotlin/StaticResource.kt @@ -2,8 +2,8 @@ package opensavvy.spine.api abstract class StaticResource( slug: String, - parent: Parent, -) : Resource(slug, parent) { + final override val parent: Parent, +) : Resource(slug) { init { // Static resources' slug must be a valid path segment, since they appear as-is in the URL -- 2.51.2 From 96155b66e05d2136d76c837f87aa1083d0c96539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 27 Oct 2024 15:52:02 +0100 Subject: [PATCH 3/5] docs(api): Document resources --- api/src/commonMain/kotlin/DynamicResource.kt | 74 ++++++++++++++++++- api/src/commonMain/kotlin/ResolvedResource.kt | 2 +- api/src/commonMain/kotlin/Resource.kt | 67 ++++++++++++++++- api/src/commonMain/kotlin/RootResource.kt | 25 ++++++- api/src/commonMain/kotlin/StaticResource.kt | 43 +++++++++++ 5 files changed, 205 insertions(+), 6 deletions(-) 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, -- 2.51.2 From 33091ee02dc2470809e9cc6dad3e23016927bd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 27 Oct 2024 20:16:00 +0100 Subject: [PATCH 4/5] docs(api): Document endpoints --- api/src/commonMain/kotlin/Endpoint.kt | 180 +++++++++++++++++++++-- api/src/commonMain/kotlin/Resource.kt | 196 ++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 8 deletions(-) diff --git a/api/src/commonMain/kotlin/Endpoint.kt b/api/src/commonMain/kotlin/Endpoint.kt index 5a22de3..6b755cf 100644 --- a/api/src/commonMain/kotlin/Endpoint.kt +++ b/api/src/commonMain/kotlin/Endpoint.kt @@ -5,13 +5,98 @@ import kotlin.reflect.KClass import kotlin.reflect.KProperty /** - * A callable path in a [resource] for a given [method]. - * - * The only implementation of this interface is [Endpoint]. - * However, [Endpoint] regularly undergoes backwards-incompatible changes. - * For this reason, we discourage using [Endpoint] directly, and instead recommend using this interface instead. + * A specific HTTP [method] in a [resource]. * * All instances of this interface must be immutable. + * + * ## Example + * + * Endpoints are declared in the body of a [resource][Resource], by calling one of the HTTP method names. + * + * ```kotlin + * object User : DynamicResource("user", parent = Users) { + * + * // GET …/{user} + * val get by get() + * .response() + * + * // POST …/{user} + * val create by post() + * .request() + * .response() + * + * // PATCH …/{user} + * val edit by patch() + * .request() + * .response() + * + * // DELETE …/{user} + * val delete by delete() + * + * } + * ``` + * + * Configuration options on an endpoint during its creation are listed in [AnyEndpoint.Builder]. + * + * ## The trick + * + * To represent endpoints in a type-safe manner, they must declare their information as type parameters. + * For example, the [requestType] and [responseType] must appear in type parameters. + * + * However, adding a type parameter to a class in Kotlin is a source-incompatible change: all usages of a type must + * explicitly say which type parameters are used. + * + * Because endpoints are the core of this library, and we expect to add more information to endpoints in the future, + * we know that we will want to add new type parameters, which would break source compatibility for all users! + * + * To avoid this, the only part of the public API is `AnyEndpoint`, which does not declare the type parameters at all. + * `AnyEndpoint` is therefore safe to use in user code. However, it lacks typing information (though the exact types + * are still available as reflection entities in [requestType] and [responseType]). `AnyEndpoint` is a sealed interface + * with a single implementation that is hidden in the library, `Endpoint`. + * + * When we declare an endpoint, we do not specify the type explicitly: + * ```kotlin + * val list by get() + * .response>() + * ``` + * + * Because we did not declare a type, Kotlin infers it to the _real type returned by the function, even though it is hidden_. + * In fact, it infers the type to be: + * ```kotlin + * val list: Endpoint by get() + * .response>() + * ``` + * As you can see, all type parameters are indeed declared. You can see this by enabling [inlay hints in IntelliJ](https://www.jetbrains.com/help/idea/inlay-hints.html). + * + * However, if you try to write the type yourself, you will see that it will not compile, because `Endpoint` cannot be accessed. + * This is an intended protection: you can create a value of type `Endpoint`, but you cannot write the type + * `Endpoint` in your code, because the type `Endpoint` will change in source-incompatible ways in the future. + * Writing a value of type `Endpoint` without the type appearance in your code is safe, so it is allowed. + * + * The type you are allowed to use is `AnyEndpoint`, this interface: + * ```kotlin + * val list: AnyEndpoint by get() + * .response>() + * ``` + * However, this interface doesn't have type parameters, so this removes all type safety. As a consequence, if you do this, + * none of the other functions of this library will compile for this endpoint, as it cannot be used safely. + * + * This interface is still useful because you may want to create operations that act on any endpoint without caring about + * the type of a specific one. For example, you may create a function that accepts an endpoint and prints information + * about it: + * ```kotlin + * fun AnyEndpoint.print() { + * println("$method $fullSlug") + * println(" - Input: $requestType") + * println(" - Output: $responseType") + * } + * ``` + * + * As a rule of thumb: + * - Endpoints declaration should not have an explicit type declaration, and should instead rely on the inferred type. + * - If you want to process endpoints, use `AnyEndpoint`. + * - If you really absolutely must use type parameters, you can force access to `Endpoint` via a suppression. + * Note, however, that this guarantees that your code will stop compiling in future versions of this library. */ sealed interface AnyEndpoint { val resource: Resource @@ -43,12 +128,86 @@ sealed interface AnyEndpoint { * * If no query parameters are used by this endpoint, this function returns [Parameters.Empty]. */ - val buildParameters: (ParameterStorage) -> Parameters + val buildParameters: ParameterConstructor + /** + * The super-type for the endpoint declaration syntax. + * + * See [AnyEndpoint] to learn more about the syntax. + * + * This interface uses the same trick as [AnyEndpoint] to avoid source-incompatible breaking changes. + * See its documentation for details. + */ sealed interface Builder { + + /** + * Declares the request body type. + * + * When a client makes a request to the server, the client will need to pass an instance of this type. + * + * Under the hood, this method uses [Ktor's content negotiation](https://ktor.io/docs/serialization.html) features. + * Therefore, all types that would be valid with content negotiation can be used with this library. + * Note that you may need to perform some configuration on the Ktor side before using some types, see the + * official documentation for instructions. + * + * ### Example + * + * ```kotlin + * val list by post() + * .request() + * .response() + * ``` + * + * If this method is called multiple times, only the last call is retained. + * + * @see response + */ fun request(kClass: KClass): Builder + + /** + * Declares the response body type. + * + * When a client makes a request to the server, the server will respond with an instance of this type. + * + * Under the hood, this method uses [Ktor's content negotiation](https://ktor.io/docs/serialization.html) features. + * Therefore, all types that would be valid with content negotiation can be used with this library. + * Note that you may need to perform some configuration on the Ktor side before using some types, see the + * official documentation for instructions. + * + * ### Example + * + * ```kotlin + * val list by post() + * .request() + * .response() + * ``` + * + * If this method is called multiple times, only the last call is retained. + * + * @see request + */ fun response(kClass: KClass): Builder - fun

parameters(build: (ParameterStorage) -> P): Builder + + /** + * Declares query parameters that the client will need to provide to the server. + * + * To learn more about representing parameters, see [Parameters]. + * + * ### Example + * + * ```kotlin + * // Create a type to hold the parameters + * class UserListParams(data: ParameterStorage) : Parameters(data) { + * var onlyActive: Boolean by parameter(default = false) + * var createdAfter: Instant? by parameter() + * } + * + * val list by get() + * .parameters(::UserListParams) + * .response() + * ``` + */ + fun

parameters(build: ParameterConstructor

): Builder } } @@ -70,7 +229,7 @@ class Endpoint internal constructor( override val path: Path.Segment?, override val requestType: KClass, override val responseType: KClass, - override val buildParameters: (ParameterStorage) -> Params, + override val buildParameters: ParameterConstructor, ) : AnyEndpoint { operator fun getValue(thisRef: Any?, property: KProperty<*>) = this @@ -122,6 +281,11 @@ class Endpoint internal constructor( // endregion } +/** + * The complete URL for this endpoint, starting at its [RootResource]. + * + * @see AnyEndpoint.path + */ val AnyEndpoint.fullSlug: String get() = if (path == null) resource.fullSlug diff --git a/api/src/commonMain/kotlin/Resource.kt b/api/src/commonMain/kotlin/Resource.kt index 843c706..c19d441 100644 --- a/api/src/commonMain/kotlin/Resource.kt +++ b/api/src/commonMain/kotlin/Resource.kt @@ -81,11 +81,207 @@ sealed class Resource( buildParameters = { Parameters.Empty }, ).asBuilder { _endpoints += it } + /** + * Creates a [`GET`][HttpMethod.Get] HTTP endpoint in this resource. + * + * `GET` endpoints are used to access information. They should not modify the state of any resources. + * + * ### Properties + * + * - SHOULD NOT declare a [request][AnyEndpoint.Builder.request] body + * - should declare a [response][AnyEndpoint.Builder.response] body + * - should be [safe](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP) + * - should be [idempotent](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent) + * - should be [cacheable](https://developer.mozilla.org/en-US/docs/Glossary/Cacheable) + * + * ### Example + * + * ```kotlin + * object User : DynamicResource("user", parent = Users) { + * + * // GET …/{user} + * val get by get() + * .response() + * + * // GET …/{user}/favorites + * val favorites by get("favorites") + * .response() + * + * } + * ``` + * + * To learn more about what can be customized on an endpoint, see [AnyEndpoint.Builder]. + * + * Learn more about [`GET` (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET). + */ protected fun get(path: String? = null) = endpoint(HttpMethod.Get, path) + + /** + * Creates a [`POST`][HttpMethod.Post] HTTP endpoint in this resource. + * + * `POST` endpoints create new entities. + * + * Each new request must create a new entity, even if it is identical to a + * prior request. If your endpoint only creates a new entity on the very first request, use [put] instead. + * + * ### Properties + * + * - should declare a [request][AnyEndpoint.Builder.request] body + * - should declare a [response][AnyEndpoint.Builder.response] body + * + * Additionally, `POST` endpoints: + * - are not [safe](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP), as they modify the server's state + * - are not [idempotent](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent), as the same request executed + * twice will create two entities + * - are not [cacheable](https://developer.mozilla.org/en-US/docs/Glossary/Cacheable), as a new entity must be created each time + * + * ### Example + * + * ```kotlin + * object User : DynamicResource("user", parent = Users) { + * + * // POST …/{user} + * val create by post() + * .request() + * .response() + * + * } + * ``` + * + * To learn more about what can be customized on an endpoint, see [AnyEndpoint.Builder]. + * + * Learn more about [`POST` (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST). + */ protected fun post(path: String? = null) = endpoint(HttpMethod.Post, path) + + /** + * Creates a [`PUT`][HttpMethod.Put] HTTP endpoint in this resource. + * + * `PUT` endpoints set the state of an entity. + * + * If the entity does not yet exist, it is created (upsert behavior). + * + * If the same request is executed multiple times, and the state of the entity hasn't changed in the meantime, + * the second request should not do anything. + * To instead create a new entity each time, use [post]. + * + * ### Properties + * + * - should declare a [request][AnyEndpoint.Builder.request] body + * - should declare a [response][AnyEndpoint.Builder.response] body + * + * Additionally, `PUT` endpoints: + * - are not [safe](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP), as they modify the server's state + * - are [idempotent](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent), as the same request executed + * twice will only create one entity + * - are not [cacheable](https://developer.mozilla.org/en-US/docs/Glossary/Cacheable) + * + * ### Example + * + * ```kotlin + * object User : DynamicResource("user", parent = Users) { + * + * // PUT …/{user} + * val update by put() + * .response() + * + * // PUT …/{user}/favorites + * val addFavorite by put("favorites") + * .request() + * .response() + * + * } + * ``` + * + * To learn more about what can be customized on an endpoint, see [AnyEndpoint.Builder]. + * + * Learn more about [`PUT` (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT). + */ protected fun put(path: String? = null) = endpoint(HttpMethod.Put, path) + + /** + * Creates a [`PATCH`][HttpMethod.Patch] HTTP endpoint in this resource. + * + * `PATCH` endpoints partially set the state of an entity. + * + * Typically, a `PATCH` endpoint takes as input the same data as the matching `GET` outputs, + * but with all fields optional, interpreting missing fields as "keep the existing value". + * + * ### Properties + * + * - should declare a [request][AnyEndpoint.Builder.request] body + * - may or may not declare a [response][AnyEndpoint.Builder.response] body + * + * Additionally, `PATCH` endpoints: + * - are not [safe](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP), as they modify the server's state + * - are not [idempotent](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent) + * - are not [cacheable](https://developer.mozilla.org/en-US/docs/Glossary/Cacheable) + * + * ### Example + * + * ```kotlin + * object User : DynamicResource("user", parent = Users) { + * + * // PATCH …/{user} + * val update by patch() + * .response() + * + * // PATCH …/{user}/favorites + * val favorites by patch("favorites") + * .request() + * .response() + * + * } + * ``` + * + * To learn more about what can be customized on an endpoint, see [AnyEndpoint.Builder]. + * + * Learn more about [`PATCH` (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH). + */ protected fun patch(path: String? = null) = endpoint(HttpMethod.Patch, path) + + /** + * Creates a [`DELETE`][HttpMethod.Delete] HTTP endpoint in this resource. + * + * `DELETE` endpoints delete an entity. + * + * ### Properties + * + * - may or may not declare a [request][AnyEndpoint.Builder.request] body + * - may or may not declare a [response][AnyEndpoint.Builder.response] body + * + * Additionally, `DELETE` endpoints: + * - are not [safe](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP), as they modify the server's state + * - are [idempotent](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent) + * - are not [cacheable](https://developer.mozilla.org/en-US/docs/Glossary/Cacheable) + * + * ### Example + * + * ```kotlin + * object User : DynamicResource("user", parent = Users) { + * + * // DELETE …/{user} + * val delete by delete() + * + * // DELETE …/{user}/favorites + * val favorites by delete("favorites") + * + * } + * ``` + * + * To learn more about what can be customized on an endpoint, see [AnyEndpoint.Builder]. + * + * Learn more about [`DELETE` (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE). + */ protected fun delete(path: String? = null) = endpoint(HttpMethod.Delete, path) + + /** + * Creates a [`HEAD`][HttpMethod.Head] HTTP endpoint in this resource. + * + * To learn more about what can be customized on an endpoint, see [AnyEndpoint.Builder]. + * + * Learn more about [`HEAD` (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD). + */ protected fun head(path: String? = null) = endpoint(HttpMethod.Head, path) } -- 2.51.2 From 9a932c42e7e1eb5926b554625bb766ba366fdb3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 11 Jan 2025 21:58:11 +0100 Subject: [PATCH 5/5] docs: Module header improvements --- api/README.md | 65 ++++++++++++++++++++++++++++++++++++++++---- api/build.gradle.kts | 6 +++- build.gradle.kts | 2 ++ client/README.md | 9 ++++++ 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 client/README.md diff --git a/api/README.md b/api/README.md index 01e3a6e..6958ba1 100644 --- a/api/README.md +++ b/api/README.md @@ -1,10 +1,10 @@ -# Module Multiplatform API schema declaration +# Module Multiplatform Ktor schema declaration Describe your Ktor API in code shared between the client and the server. - - - + + + When creating fullstack projects, using both Ktor as a client and a server, we need to make sure we are calling the same endpoints on both sides, with the same expected DTOs, etc. @@ -16,7 +16,60 @@ Spine is a library to declare a schema of our API in pure Kotlin. Once it is dec We define that: -- [a **resource**](opensavvy.spine.typed.Resource) is an imaginary data collection, -- [an **endpoint**](opensavvy.spine.typed.Endpoint) is a single operation that acts on a given resource. +- [a **resource**](opensavvy.spine.api.Resource) is an imaginary data collection, +- [an **endpoint**](opensavvy.spine.api.AnyEndpoint) is a single operation that acts on a given resource. In HTTP terms, a resource is a URI, and an endpoint is a record of a URI, an HTTP method, a specific request body type… + +Typically, resources are declared as singletons: +```kotlin +// Declare our root endpoint: /v1 +object Api : RootResource("v1") { + + // Declare a nested resource: /v1/users + object Users : StaticResource("/users", parent = Api) { + + // GET /v1/users + // which returns a list of UserDto + val list by get() + .response>() + + // Declare a nested resource: /v1/users/{user} + object User : DynamicResource("user", parent = Users) { + + // GET /v1/users/{user} + // which returns a UserDto + val get by get() + .response() + + // POST /v1/users/{user} + // which accepts a UserCreationDto and returns a UserDto + val create by post() + .request() + .response() + + // PUT /v1/users/{user}/friend + val addFriend by put("friend") + + // DELETE /v1/users/{user}/friend + val removeFriend by delete("friend") + } + } +} +``` + +We can then refer to any endpoint easily. For example, `Api.Users.User.removeFriend` is the `DELETE /v1/users/{user}/friend` endpoint. + +## Learn more + +**Resources** describe a grouping of endpoints under a single URL: +- [`RootResource`][opensavvy.spine.api.RootResource] is the root of a URL. +- [`StaticResource`][opensavvy.spine.api.StaticResource] is a hard-coded segment in a URL, for example `/users` or `/posts`. +- [`DynamicResource`][opensavvy.spine.api.DynamicResource] is a wildcard segment in a URL, which could be replaced by a user's ID, for example. + +**Endpoints** describe a specific HTTP method along with its expected input and output types, parameters, etc. +- [`AnyEndpoint`][opensavvy.spine.api.AnyEndpoint] allows introspecting information about an endpoint. +- [`AnyEndpoint.Builder`][opensavvy.spine.api.AnyEndpoint.Builder] allows declaring information about an endpoint. +- [`Parameters`][opensavvy.spine.api.Parameters] represent query parameters. + +To learn how to use the APIs on the client or on the server, see the documentation of the `client` and `server` modules. diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 41b5691..c5a9690 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { } library { - name.set("Multiplatform API schema declaration") + name.set("Multiplatform Ktor schema declaration") description.set("Declare your Ktor API in code shared between your clients and servers") homeUrl.set("https://gitlab.com/opensavvy/groundwork/spine") @@ -34,3 +34,7 @@ library { url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") } } + +dokka.dokkaSourceSets.configureEach { + skipDeprecated.set(false) +} diff --git a/build.gradle.kts b/build.gradle.kts index 00157d0..819d63c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,7 +23,9 @@ dependencies { // List the 'library' projects dokka(projects.api) dokka(projects.server) + dokka(projects.serverArrow) dokka(projects.client) + dokka(projects.clientArrow) } // region Check the users of the project didn't forget to rename the group diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..e57b5fc --- /dev/null +++ b/client/README.md @@ -0,0 +1,9 @@ +# Module Client-side typesafe Spine schema usage + +Call a Ktor API described with type-safety in common code. + + + + + +To declare a Ktor API in common code, see the `api` module.