Make your Ktor app fullstack with Spine¶
Spine is a library to declare Ktor endpoints in your commonMain code, which you can then reference easily in your server-side and client-side code.
In this article, we'll show you how to configure Spine in your existing Ktor application.
To get an overview of what Spine is, visit the home page.
Sharing code between client and server¶
There are two main ways to share code between client and server.
- If you know that your server and client are for different Kotlin platforms (for example, a JVM server and a JS client), you could create a single module that has both platforms registered, and share code in
commonMain. - If you may have platforms that have both clients and servers, it is better to create three modules: one for shared code, one for the server, one for the client.
Spine works equally well no matter the approach, but we recommend the second because it is more versatile and makes testing easier (as we will see later).
Therefore, you should have something like:
your-project/
backend/
build.gradle.kts
frontend/
build.gradle.kts
shared/
build.gradle.kts
build.gradle.kts
settings.gradle.kts
In this tutorial, we will create a simple /ping endpoint which responds "Pong" followed by the initial request body.
Your first Spine endpoint¶
For now, we will edit the shared module (called shared in our example). Start by adding the Spine API module to its dependencies:
plugins {
kotlin("jvm") version "…"
}
dependencies {
api("dev.opensavvy.spine:api:VERSION") //(1)!
}
Then, create the file src/main/kotlin/Api.kt.
All Spine endpoints are grouped in resources. A resource is responsible for the path of the endpoints.
Resources form a chain that starts at a RootResource. Each new resource may be a StaticResource or a DynamicResource, and always refers to its parent. In this example, we will only use a single resource.
package your.app.shared
import opensavvy.spine.api.*
object Ping : RootResource("ping") { //(1)!
val ping by put() //(2)!
.request<String>() //(3)!
.response<String>() //(4)!
}
- We declare a new
RootResourcewith the path/ping. All nested resources and endpoints will have children paths of/ping. - We declare an endpoint
PUT /ping(because we use the methodput()in the resource with path/ping). The name of the variable doesn't matter, but a good name can help readability. Note thebyinstead of=when declaring the endpoint. - We declare that this endpoint requires a request body of type
String. Under the hood, Ktor's usual content negotiation system is used with your existing configuration. - We declare that this endpoint will respond with a body of type
String. Under the hood, Ktor's usual content negotiation system is used with your existing configuration.
That's it, we declare the existence of our PUT /ping endpoint! Now, all that's left is implementing the server and client sides.
To learn more about the concepts from this section, see:
Server-side implementation¶
Start by adding the Spine dependency to your server-side module.
plugins {
kotlin("jvm") version "…"
}
dependencies {
implementation("dev.opensavvy.spine:server:VERSION") //(1)!
implementation(project(":shared")) //(2)!
}
- List of versions
- Ensure the backend module has access to the endpoints we just declared.
Then, create the file src/main/kotlin/Ping.kt.
plugins {
kotlin("multiplatform") version "…"
}
kotlin {
jvm()
linuxX64()
// add any other platform you want to target…
sourceSets.commonMain.dependencies {
api("dev.opensavvy.spine:server:VERSION") //(1)!
implementation(project(":shared")) //(2)!
}
}
- List of versions
- Ensure the backend module has access to the endpoints we just declared.
Then, create the file src/commonMain/kotlin/Ping.kt.
Usually, Ktor applications are separated as functions which handle different groups of resources. We'll do the same, and create a new file specifically for this API.
package your.app.server
import opensavvy.spine.api.*
import opensavvy.spine.server.*
import io.ktor.http.*
import io.ktor.server.routing.*
import your.app.shared.*
fun Route.ping() {
// Here, we would usually declare routes with 'get {}' or 'post {}'.
// However, we already declared the method in the common code,
// so we just refer to it.
route(Ping.ping) {
respond("Pong: $body")
}
}
In this example:
- Because the endpoint's HTTP method is already declared, we don't have to do it again, and can just refer to the endpoint.
- Spine adds the
bodyvariable that automatically contains the deserialized request body of the type declared in the endpoint (Stringin this example). - Spine adds the
respond()function that accepts and serializes the response body of the type declared in the endpoint (Stringin this example).
Don't worry—you can still access the variable call to do anything else you may want with Ktor.
Finally, we can register this endpoint by calling our ping() function in the routing {} section of your Ktor application:
To learn more about creating a Ktor application and configuring plugins, see the official Ktor tutorial: Spine doesn't impact the configuration.
To learn more about the concepts from this section, see:
Client-side implementation¶
Finally, we can call these methods on the frontend side. We'll start by declaring the dependency:
plugins {
kotlin("jvm") version "…"
}
dependencies {
implementation("dev.opensavvy.spine:client:VERSION") //(1)!
implementation(project(":shared")) //(2)!
}
- List of versions
- Ensure the frontend module has access to the endpoints we declared.
Then, create the file src/main/kotlin/Ping.kt.
plugins {
kotlin("multiplatform") version "…"
}
kotlin {
jvm()
js { browser() }
// add any other platform you want to target…
sourceSets.commonMain.dependencies {
api("dev.opensavvy.spine:client:VERSION") //(1)!
implementation(project(":shared")) //(2)!
}
}
- List of versions
- Ensure the frontend module has access to the endpoints we declared.
Then, create the file src/commonMain/kotlin/Ping.kt.
If it's your first Ktor client, also follow the official Ktor client tutorial.
On the client-side, Ktor is organized around the HttpClient class.
package your.app.client
import opensavvy.spine.api.*
import opensavvy.spine.client.*
import io.ktor.http.*
import io.ktor.client.*
import your.app.shared.*
suspend fun main() {
val client = HttpClient {
install(DefaultRequest) { //(1)!
url("https://your-app.com")
}
install(ContentNegotiation) {
json()
}
}
val pong = client.request(Ping / Ping.ping, "From the client!").body() //(2)!
println("Got: $pong")
}
- The Ktor default request plugin is convenient to declare the URL, since Spine only encodes the path of the request.
- Notice that the path is
Ping / Ping.pinginstead of justPing.pingthat we used on the server-side. This is to allow more complex URLs that contain path parameters. Learn more.
That's it! You created your first fullstack Ktor endpoint. Notice that the request function's second parameter serialized the request body as a String, as declared in the common code, and deserialized the response body automatically too.
To learn more about the concepts from this section, see:
- Learn more about the client-side path syntax (
Ping / Ping.ping) - Learn more about declaring the request and response bodies
- Or, read on and discover how to easily test your endpoint.
Testing your application¶
Testing client-server systems is traditionally complex because the client and server live in different processes. Traditional test frameworks, like JUnit, expect tests to run in a single process. You must either start the server and use the client for tests, but you need to remember to restart the server each time it is modified, or you risk testing against an old version.
Ktor offers a simpler system: the Ktor TestHost, which allows creating a fake server and a fake client in a single process. The TestHost uses all the mechanisms of a real server, including serialization, but skips the actual TCP socket.
The Ktor TestHost is a great fit for testing Spine endpoints because all the configuration for the endpoints is already in the common code. Typically, the process is as follows:
- The endpoint tests live in the
:backendmodule, because it typically supports fewer platforms than the other modules. - Add all the platforms supported by
:backendto the:frontendmodule, even if you don't expect to use them. This way, the:backendmodule can import the:frontendmodule for its tests, which allows testing both in a single place. For example, if you want to create a JVM server and a JS frontend, you should still add thejvm()platform in the:frontendproject; this way, the server can use the frontend's code, compiled for the JVM, to test itself.
Modify the configuration of the backend:
plugins {
kotlin("jvm") version "…"
}
dependencies {
// What we had before:
implementation("dev.opensavvy.spine:server:VERSION")
implementation(project(":shared"))
// New:
testImplementation(project(":frontend")) //(1)!
// Also, add a dependency on the Ktor TestHost
}
- To test both client and server, the server's tests depend on the client.
Then, create the file src/test/kotlin/PingTest.kt.
plugins {
kotlin("multiplatform") version "…"
}
kotlin {
jvm()
linuxX64()
// add any other platform you want to target…
// What we had before:
sourceSets.commonMain.dependencies {
api("dev.opensavvy.spine:server:VERSION")
implementation(project(":shared"))
}
// New:
sourceSets.commonTest.dependencies {
implementation(project(":frontend")) //(1)!
// Also, add a dependency on the Ktor TestHost
}
}
- To test both client and server, the server's tests depend on the client. For this to work, the
:frontendmust support at least all the platforms that the backend supports.
Then, create the file src/commonTest/kotlin/PingTest.kt.
In this example, we'll use the Prepared test framework using the TestBalloon engine and the Ktor compatibility module, which require additional configuration not shown here. However, you can follow the same steps with any other test framework.
package your.app.test
import opensavvy.spine.api.*
import opensavvy.spine.server.*
import opensavvy.spine.client.*
import io.ktor.http.*
import your.app.shared.*
import your.app.server.*
import your.app.client.*
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
private val server by preparedServer { //(2)!
install(ServerContentNegotiation) { //(3)!
json()
}
routing {
ping() //(4)!
}
}
private val client by server.preparedClient { //(5)!
install(ClientContentNegotation) { //(6)!
json()
}
}
val PingTest by preparedSuite { //(1)!
test("The /ping route should return 'Pong: xxx'") {
val initial = "FOO"
val expected = "Pong: FOO"
check(client().request(Ping / Ping.ping, initial).body() == expected) //(7)!
}
}
- The
preparedSuiteDSL is the entrypoint for tests declared with Prepared and TestBalloon. If you use another test framework, it will be different. - The
preparedServerDSL allows declaring the Ktor TestHost as a special test fixture. If you use another test framework, this is probably replaced by calling thetestApplication {}function within each test. - We always need at least
ContentNegotation. Notice the import alias, used because we need both server and client negotiation in this file. - We can configure the Ktor test host, just like a real server, with the
routing {}block. To simplify tests, however, we will only register the routes related to the test, instead of registering the entire API. - The
server.preparedClientDSL allows declaring the Ktor TestHost client as a special test fixture. If you use another test framework, this is probably replaced by calling thecreateClient {}function within thetestApplication {}block within each test. - We always need at least
ContentNegotation. Notice the import alias, used because we need both server and client negotiation in this file. - The Power Assert plugin allows creating a nice error message for any Kotlin call, without the need for assertion libraries. We recommend it!
Because this entire test runs in a single process, you don't have to worry about the server being out of date. Also, you can debug and step from client-side to server-side.
To learn more about the concepts from this section, see:
Congrats on getting this far, have fun with your new fullstack Ktor apps!