Skip to content

Commit be79e34

Browse files
authored
Write docs for writing Ktor HTTP layer (#278)
1 parent 80aaa01 commit be79e34

File tree

2 files changed

+374
-3
lines changed

2 files changed

+374
-3
lines changed

integrations/gcp/README.MD

+368
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
# Building Kotlin Multiplatform network layer
2+
3+
Building a Kotlin Multiplatform network libraries we need several different pieces,
4+
this document will cover all pieces and how to setup and build from start-to-finish.
5+
6+
## Setting up Gradle
7+
8+
### Kotlin Multiplatform
9+
10+
The most simple solution would be to copy an existing http module, and modify the Gradle setup as desired.
11+
But let's cover the different important pieces. First we need to set up the Gradle plugins to configure both
12+
_Kotlin Multiplatform_, and _KotlinX Serialization_ for content negotiation.
13+
14+
```kotlin
15+
id("org.jetbrains.kotlin.multiplatform") version "1.9.0"
16+
```
17+
18+
In the Xef project we've already defined these dependencies in the [Version Catalog](),
19+
so you get a typed DSL inside Gradle to set this up with automatic versioning.
20+
21+
```kotlin
22+
id(libs.plugins.kotlin.multiplatform.get().pluginId)
23+
```
24+
25+
The Kotlin Multiplatform plugin sets up Gradle so that we can rely on the `kotlin` DSL,
26+
and set up the targets for the desired platforms.
27+
28+
For Xef we set up following targets:
29+
30+
```kotlin
31+
kotlin {
32+
jvm()
33+
js(IR) {
34+
browser()
35+
nodejs()
36+
}
37+
38+
linuxX64()
39+
macosX64()
40+
macosArm64()
41+
mingwX64()
42+
}
43+
```
44+
45+
This creates different _sourceSets_, which are linked to certain targets. This is done in an hierarchy.
46+
`commonMain` is at the top of the hierarchy,
47+
all code defined here is available from platform specific sourceSets and platforms.
48+
When building multiplatform http clients, we're going to define everything in `commonMain` so
49+
we're going to ignore the other ones for now.
50+
51+
We can now write code in `src/commonMain/kotlin` as you normally would for Java in `src/main/java`,
52+
but remember you only have access to _common_ Kotlin code. So now JDK specific packages are available.
53+
54+
### KotlinX Serialization
55+
56+
So now we've configured Kotlin, we should set up KotlinX Serialization, and it's compiler plugin.
57+
This is needed so we can send `JSON`, and different formats over the network.
58+
59+
```kotlin
60+
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
61+
```
62+
63+
In the Xef project we've already defined these dependencies in the [Version Catalog](),
64+
so you get a typed DSL inside Gradle to set this up with automatic versioning.
65+
66+
```kotlin
67+
id(libs.plugins.kotlinx.serialization.get().pluginId)
68+
```
69+
70+
The `serialization` plugin sets up KotlinX Serialization such that we get access to `@Serializable`,
71+
and the Kotlin Serialization Compiler plugin is correctly configured.
72+
73+
### Dependencies
74+
75+
Finally, we need to set up some dependencies for our project.
76+
We'll start with setting up some _common_ dependencies, we do so again within `kotlin` DSL.
77+
78+
First let's add a dependency on `xef-core`, such that we can implement the `Chat`, `ChatWithFunction`, etc. interfaces
79+
based on our integration.
80+
81+
```kotlin
82+
kotlin {
83+
sourceSets {
84+
val commonMain by getting {
85+
dependencies {
86+
api(projects.xefCore)
87+
}
88+
}
89+
}
90+
}
91+
```
92+
93+
Finally, we also need to set up Ktor. For most HTTP integration we need 3 _common_ dependencies.
94+
95+
```kotlin
96+
implementation("io.ktor:ktor-client-core:2.3.2")
97+
implementation("io.ktor:ktor-client-content-negotiation:2.3.2")
98+
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.2")
99+
```
100+
101+
There are bundles in the Xef project, so we can more easily depend on them in a typed way.
102+
103+
```kotlin
104+
kotlin {
105+
sourceSets {
106+
val commonMain by getting {
107+
dependencies {
108+
api(projects.xefCore)
109+
implementation(libs.bundles.ktor.client)
110+
}
111+
}
112+
}
113+
}
114+
```
115+
116+
Now we'll have access to all the Ktor classes we need to build our Http integration, which we'll cover below.
117+
This however only sets up the _common_ APIs, and no actual http engines for the configured platforms.
118+
So if we'd try to run any code, we'll end up with a runtime error since there is no actual HTTP engine to run the code
119+
with: `"Failed to find HttpClientEngineContainer. Consider adding [HttpClientEngine] implementation in dependencies."`.
120+
121+
So we need to configure the engines, we're immediately going to reference the version catalog DSL here.
122+
Almost all targets are relying on the `CIO` engine, which is the _Coroutines_ engine except for Javascript and Windows.
123+
There are plenty of other options we can choose, more
124+
info [here](https://ktor.io/docs/http-client-engines.html#minimal-version).
125+
126+
```kotlin
127+
kotlin {
128+
sourceSets {
129+
...
130+
131+
val jvmMain by getting {
132+
dependencies {
133+
implementation(libs.logback)
134+
api(libs.ktor.client.cio)
135+
}
136+
}
137+
138+
val jsMain by getting {
139+
dependencies {
140+
api(libs.ktor.client.js)
141+
}
142+
}
143+
144+
val linuxX64Main by getting {
145+
dependencies {
146+
api(libs.ktor.client.cio)
147+
}
148+
}
149+
150+
val macosX64Main by getting {
151+
dependencies {
152+
api(libs.ktor.client.cio)
153+
}
154+
}
155+
156+
val macosArm64Main by getting {
157+
dependencies {
158+
api(libs.ktor.client.cio)
159+
}
160+
}
161+
162+
val mingwX64Main by getting {
163+
dependencies {
164+
api(libs.ktor.client.winhttp)
165+
}
166+
}
167+
}
168+
}
169+
```
170+
171+
Now that we've completely finished setting up Gradle for our Kotlin Multiplatform library,
172+
all that is left is writing the actual code!
173+
174+
## Writing your first Multiplatform Http library
175+
176+
### Configuring Ktor's HttpClient
177+
178+
Writing a http layer using Ktor is quite simple, everything works through `HttpClient`.
179+
The first thing we need to do is configure the `HttpClient` to work with _Content Negotiation_,
180+
such that we can send `JSON` or other formats over the network.
181+
182+
```kotlin
183+
HttpClient {
184+
install(ContentNegotiation) {
185+
json()
186+
}
187+
}
188+
```
189+
190+
We can pass a custom KotlinX Serialization `Json` instance, such that we can configure it to our needs.
191+
We typically want to use `encodeDefaults = false` such that default `null` arguments are not included in the `JSON`,
192+
and `isLenient = true` and `ignoreUnknownKeys = true` such that serialization is more _lenient_ and robust against
193+
changes.
194+
195+
```kotlin
196+
Json {
197+
encodeDefaults = false
198+
isLenient = true
199+
ignoreUnknownKeys = true
200+
}
201+
```
202+
203+
### Ktor's Httpclient AutoCloseable
204+
205+
Like most `HttpClient`'s the Ktor client holds a lot of internal state,
206+
such as `CoroutineScope`, downstream engines such as `Netty` or `CIO` and schedulers.
207+
So the `HttpClient` implements a `Closeable` interface, on which we need to call `close` when we're finished using
208+
the `HttpClient`, and requests that are still in progress will at that point also be cancelled
209+
with `CancellationException`.
210+
211+
The simplest way is to use the `use` DSL, as follows:
212+
213+
```kotlin
214+
HttpClient().use { client ->
215+
// use client
216+
}
217+
```
218+
219+
but we typically want to rely on this `HttpClient` from within a `class`, and thus we want to wrap it and propagate
220+
the `Closeable` requirement. Most convenient we do this by implementing `AutoCloseable` from Kotlin Standard Library in
221+
our own class, and delegating to the `HttpClient#close` method.
222+
223+
```kotlin
224+
class GcpClient(/* constructor parameters */) : AutoCloseable {
225+
private val http: HttpClient = HttpClient {
226+
// configure client
227+
}
228+
229+
override fun close() {
230+
http.close()
231+
}
232+
}
233+
```
234+
235+
Now that we've correctly wrapped our `HttpClient`, we're finally read to start making our calls.
236+
237+
## Ktor http calls
238+
239+
The `HttpClient` expose the typical HTTP methods we expect as methods,
240+
together with a builder which we can use to configure the `HttpRequest`.
241+
242+
```kotlin
243+
http.post(
244+
"https://$apiEndpoint/v1/projects/$projectId/locations/us-central1/publishers/google/models/$modelId:predict"
245+
) {
246+
header("Authorization", "Bearer $token")
247+
contentType(ContentType.Application.Json)
248+
setBody(body)
249+
}
250+
```
251+
252+
Here we call the `post` http method, and pass the URL we want to send the request to.
253+
We configure the `Authorization` header, more on that later, and the `contentType` and we set a _body_.
254+
255+
The `body` in our case is of _content type_ `Json`, which will be automatically serialized from our KotlinX
256+
Serialization compatible class.
257+
258+
So for the example of GCP we want to send following `JSON`:
259+
260+
```json
261+
{
262+
"instances": [
263+
{
264+
"messages": [
265+
{
266+
"author": "user",
267+
"content": "How can I reverse a list in python?"
268+
}
269+
]
270+
}
271+
],
272+
"parameters": {
273+
"temperature": 0.3,
274+
"maxOutputTokens": 200,
275+
"topK": 40,
276+
"topP": 0.8
277+
}
278+
}
279+
```
280+
281+
Which translates to following Kotlin hierarchy:
282+
283+
```kotlin
284+
@Serializable
285+
private data class Prompt(val instances: List<Instance>, val parameters: Parameters? = null)
286+
287+
@Serializable
288+
private data class Instance(
289+
val context: String? = null,
290+
val examples: List<Example>? = null,
291+
val messages: List<Message>,
292+
)
293+
294+
@Serializable
295+
data class Example(val input: String, val output: String)
296+
297+
@Serializable
298+
private data class Message(val author: String, val content: String)
299+
300+
@Serializable
301+
private class Parameters(
302+
val temperature: Double? = null,
303+
val maxOutputTokens: Int? = null,
304+
val topK: Int? = null,
305+
val topP: Double? = null
306+
)
307+
```
308+
309+
With this defined, we can simply construct our data and pass it to `setBody`,
310+
and we'll receive an `HttpResponse` as result of the _suspend_ `post` call.
311+
312+
```kotlin
313+
val body =
314+
Prompt(
315+
listOf(Instance(messages = listOf(Message(author = "user", content = prompt)))),
316+
Parameters(temperature, maxOutputTokens, topK, topP)
317+
)
318+
319+
val response: HttpResponse =
320+
http.post(...) {
321+
...
322+
setBody(body)
323+
}
324+
```
325+
326+
All that's left now is to _deserialize_ the `HttpResponse`, and we do this in the same way as above.
327+
We define a set of Kotlin classes that correspond to the structure of the `JSON`,
328+
and we can deserialize it by calling `body<MyClass>()` on the `HttpResponse`.
329+
Before doing so we typically want to check the `HttpStatusCode`.
330+
331+
```kotlin
332+
if (response.status.isSuccess()) response.body<MyClass>()
333+
else throw GcpClientException(response.status, response.bodyAsText())
334+
```
335+
336+
### Authorization
337+
338+
In the example above we've showed simple authorization using a token,
339+
but in some cases we need more advanced authorization support.
340+
341+
Ktor has a wide support of different authorization support out of the box,
342+
including OAuth2 with refresh tokens.
343+
344+
There is a detailed guide on the [Ktor website](https://ktor.io/docs/auth.html),
345+
this is provided through the `ktor-client-auth` module.
346+
347+
### Retry & Timeouts
348+
349+
Often we also want to have some retry mechanism, and timeout support for our networking.
350+
This can be easily configured on the `HttpClient`, and customised for every individual request as needed.
351+
352+
```kotlin
353+
HttpClient {
354+
install(HttpTimeout) {
355+
requestTimeoutMillis = 60_000 // 60 seconds
356+
connectTimeoutMillis = 60_000 // 60 seconds
357+
socketTimeoutMillis = 300_000 // 5 minutes
358+
}
359+
install(HttpRequestRetry) { // optional, default settings
360+
retryOnExceptionOrServerErrors(3)
361+
exponentialDelay()
362+
}
363+
}
364+
```
365+
366+
## Implementing Core's Chat interface
367+
368+
TODO

integrations/gcp/src/commonMain/kotlin/com/xebia/functional/xef/gcp/GcpClient.kt

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ package com.xebia.functional.xef.gcp
33
import com.xebia.functional.xef.AIError
44
import io.ktor.client.HttpClient
55
import io.ktor.client.call.body
6-
import io.ktor.client.plugins.HttpRequestRetry
7-
import io.ktor.client.plugins.HttpTimeout
6+
import io.ktor.client.plugins.*
87
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
98
import io.ktor.client.request.header
109
import io.ktor.client.request.post
@@ -26,13 +25,17 @@ class GcpClient(
2625
private val token: String
2726
) : AutoCloseable {
2827
private val http: HttpClient = HttpClient {
29-
install(HttpTimeout)
28+
install(HttpTimeout) {
29+
requestTimeoutMillis = 60_000
30+
connectTimeoutMillis = 60_000
31+
}
3032
install(HttpRequestRetry)
3133
install(ContentNegotiation) {
3234
json(
3335
Json {
3436
encodeDefaults = false
3537
isLenient = true
38+
ignoreUnknownKeys = true
3639
}
3740
)
3841
}

0 commit comments

Comments
 (0)