diff --git a/server/README.md b/server/README.md index 157e3f3..17ae9fd 100644 --- a/server/README.md +++ b/server/README.md @@ -5,3 +5,42 @@ Implement a Ktor API described with type-safety in common code. + +Spine allows us to [declare Ktor endpoints in code shared between the client and server](https://spine.opensavvy.dev/setup.html). + +## Declaring a fullstack endpoint + +First, create a module that will contain the endpoint definition (see the `api` module), with the following API: + +```kotlin +object Api : RootResource("v1") { + object Users : StaticResource("users", Api) { + val create by post() + .request() + .response() + } +} +``` + +Create a new module, with a dependency on `dev.opensavvy.spine:server`. Instantiate a Ktor server by following [our tutorial](https://spine.opensavvy.dev/setup.html#server-side-implementation). You can now declare the endpoint in [your existing `routing` block](https://ktor.io/docs/server-routing.html): + +```kotlin +routing { + route(Api.Users.create) { + // The variable 'body' is automatically created with the correct type + println("Creating a user: ${body.username}") + + // The 'respond' method expects the correct response type + respond(User(username = body.username)) + } +} +``` + +## Learn more + +- [`route`][opensavvy.spine.server.route] declares an endpoint to Ktor. +- [`body`][opensavvy.spine.server.TypedResponseScope.body] deserializes the request body type-safely. +- [`parameters`][opensavvy.spine.server.TypedResponseScope.parameters] provides access to query parameters. +- [`idOf`][opensavvy.spine.server.TypedResponseScope.idOf] provides access to path parameters. +- [`respond`][opensavvy.spine.server.respond] serializes the response type-safely. +- [`fail`][opensavvy.spine.server.fail] responds type-safely with one of the declared failures. diff --git a/server/src/commonMain/kotlin/Server.kt b/server/src/commonMain/kotlin/Server.kt index 722a29a..b9fd62a 100644 --- a/server/src/commonMain/kotlin/Server.kt +++ b/server/src/commonMain/kotlin/Server.kt @@ -7,6 +7,23 @@ import io.ktor.server.routing.* import io.ktor.utils.io.* import opensavvy.spine.api.* +/** + * Declares a Ktor handler matching a Spine [endpoint][opensavvy.spine.api.AnyEndpoint]. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.logIn) { + * val (user, token) = authService.verifyLogIn(body.username, body.password) + * call.response.cookies.append("token", token) + * respond(user) + * } + * } + * ``` + * + * For the full list of available methods, see [TypedResponseScope]. + */ @KtorDsl inline fun Route.route( endpoint: Endpoint, diff --git a/server/src/commonMain/kotlin/TypedResponseScope.kt b/server/src/commonMain/kotlin/TypedResponseScope.kt index 8a3f995..13e953d 100644 --- a/server/src/commonMain/kotlin/TypedResponseScope.kt +++ b/server/src/commonMain/kotlin/TypedResponseScope.kt @@ -14,14 +14,72 @@ import opensavvy.spine.api.FailureSpec.Or import opensavvy.spine.api.Parameters import kotlin.jvm.JvmName +/** + * The various methods available within the handler of a Ktor endpoint. + * + * Full Ktor information is available via [call], as is standard in Ktor endpoints. + */ @KtorDsl interface TypedResponseScope { + + /** + * The standard Ktor [ApplicationCall] instance, which is used to access cookies + * or any other information directly from Ktor. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.logIn) { + * // …perform log in… + * + * call.response.cookies.append("my-auth", "123") + * respond(Unit) + * } + * } + * ``` + */ val call: ApplicationCall + /** + * The declared Spine [endpoint][opensavvy.spine.api.AnyEndpoint] which was called by the user. + */ val endpoint: Endpoint + /** + * The request body sent by the client. + * + * Spine automatically deserializes this value based on the [request][opensavvy.spine.api.AnyEndpoint.Builder.request] type declared in the endpoint. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.logIn) { + * println("User ${body.username} wants to log in…") + * // … + * } + * } + * ``` + */ val body: In + /** + * The parameters sent by the client. + * + * Spine automatically deserializes this value based on the [parameters][opensavvy.spine.api.AnyEndpoint.Builder.parameters] type declared in the endpoint. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.list) { + * println("Include archived users? ${parameters.includeArchived}") + * // … + * } + * } + * ``` + */ val parameters: Params /** @@ -49,16 +107,79 @@ interface TypedResponseScope TypedResponseScope<*, Out, *, *>.respond(body: Out, code: HttpStatusCode = if (body == Unit) HttpStatusCode.NoContent else HttpStatusCode.OK) { call.respond(status = code, message = body) } +/** + * Responds with the given [body]. + * + * This method is identical to [ApplicationCall.respond] but verifies that the [body] type matches the one declared in the endpoint. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.logIn) { + * val user = authService.verifyLogIn(body.username, body.password) + * respond(user) + * } + * } + * ``` + * + * ### Parameters + * + * - `body`: a value of the response type declared in the endpoint. + * If the endpoint declared a response type of [Unit], or declared no response at all, this parameter is optional. + * - `code`: the HTTP status code to respond with. + * Defaults to [HttpStatusCode.NoContent] if the response type is [Unit] or if no response type is declared. + * Defaults to [HttpStatusCode.OK] for any other value. + */ @KtorDsl suspend fun TypedResponseScope<*, Unit, *, *>.respond(code: HttpStatusCode = HttpStatusCode.NoContent) { respond(Unit, code) } +/** + * Fails the endpoint call with one of the declared [failures][opensavvy.spine.api.AnyEndpoint.Builder.failure]. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.logIn) { + * if (body.password.isBlank()) { + * fail(InvalidPassword) + * } + * } + * } + * ``` + */ @KtorDsl @JvmName("fail1") suspend inline fun TypedResponseScope<*, *, Or<*, FailureSpec.ByCode>, *>.fail(failure: F): Nothing { @@ -67,6 +188,21 @@ suspend inline fun TypedResponseScope<*, *, Or<*, FailureSpec. throw SpineShortCircuitException() } +/** + * Fails the endpoint call with one of the declared [failures][opensavvy.spine.api.AnyEndpoint.Builder.failure]. + * + * ### Example + * + * ```kotlin + * routing { + * route(Api.Users.logIn) { + * if (body.password.isBlank()) { + * fail(InvalidPassword) + * } + * } + * } + * ``` + */ @KtorDsl @JvmName("fail2") suspend inline fun TypedResponseScope<*, *, Or>, Nothing>, *>.fail(failure: F): Nothing { @@ -75,6 +211,21 @@ suspend inline fun TypedResponseScope<*, *, Or TypedResponseScope<*, *, Or>, Nothing>, Nothing>, *>.fail(failure: F): Nothing { @@ -83,6 +234,21 @@ suspend inline fun TypedResponseScope<*, *, Or TypedResponseScope<*, *, Or>, Nothing>, Nothing>, Nothing>, Nothing>.fail(failure: F): Nothing { @@ -91,6 +257,21 @@ suspend inline fun TypedResponseScope<*, *, Or TypedResponseScope<*, *, Or>, Nothing>, Nothing>, Nothing>, Nothing>, Nothing>.fail(failure: F): Nothing { @@ -99,6 +280,21 @@ suspend inline fun TypedResponseScope<*, *, Or TypedResponseScope<*, *, Or>, Nothing>, Nothing>, Nothing>, Nothing>, Nothing>, Nothing>.fail(failure: F): Nothing {