Skip to content

Commit fb1d1ec

Browse files
junerverclaude
andcommitted
⚡️ [AI]: Refactor HTTP client with pluggable HttpEngine abstraction
Extract HTTP layer into HttpEngine interface for better testability and custom engine support (OkHttp, Ktor, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ea7941f commit fb1d1ec

7 files changed

Lines changed: 319 additions & 100 deletions

File tree

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/BaseAIOptions.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package xyz.junerver.compose.ai
33
import io.ktor.client.statement.HttpResponse
44
import kotlin.time.Duration
55
import kotlin.time.Duration.Companion.seconds
6+
import xyz.junerver.compose.ai.http.HttpEngine
67
import xyz.junerver.compose.ai.usechat.ChatProvider
78
import xyz.junerver.compose.ai.usechat.Providers
89

@@ -35,6 +36,7 @@ typealias OnErrorCallback = (error: Throwable) -> Unit
3536
* @property maxTokens Maximum number of tokens to generate
3637
* @property timeout Request timeout duration
3738
* @property headers Additional HTTP headers to send with requests
39+
* @property httpEngine Custom HTTP engine (null = use global default)
3840
* @property onResponse Callback when receiving an HTTP response
3941
* @property onError Callback when an error occurs
4042
*/
@@ -46,6 +48,7 @@ interface BaseAIOptions {
4648
var maxTokens: Int?
4749
var timeout: Duration
4850
var headers: Map<String, String>
51+
var httpEngine: HttpEngine?
4952
var onResponse: OnResponseCallback?
5053
var onError: OnErrorCallback?
5154

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package xyz.junerver.compose.ai.http
2+
3+
import kotlinx.coroutines.flow.Flow
4+
5+
/*
6+
Description: HTTP engine abstraction for AI module
7+
Author: Junerver
8+
Date: 2026/01/06
9+
Email: junerver@gmail.com
10+
Version: v1.0
11+
*/
12+
13+
/**
14+
* HTTP request parameters.
15+
*
16+
* @property url The full URL to request
17+
* @property headers HTTP headers to send
18+
* @property body Request body (JSON string)
19+
* @property timeout Request timeout in milliseconds
20+
*/
21+
data class HttpRequest(
22+
val url: String,
23+
val headers: Map<String, String> = emptyMap(),
24+
val body: String? = null,
25+
val timeout: Long = 60_000L,
26+
)
27+
28+
/**
29+
* HTTP response result.
30+
*
31+
* @property statusCode HTTP status code
32+
* @property body Response body as string
33+
*/
34+
data class HttpResult(
35+
val statusCode: Int,
36+
val body: String,
37+
)
38+
39+
/**
40+
* Server-Sent Events (SSE) event types.
41+
*/
42+
sealed class SseEvent {
43+
/**
44+
* A data line received from SSE stream.
45+
*/
46+
data class Data(val line: String) : SseEvent()
47+
48+
/**
49+
* Stream completed successfully.
50+
*/
51+
data object Complete : SseEvent()
52+
53+
/**
54+
* An error occurred during streaming.
55+
*/
56+
data class Error(val error: Throwable) : SseEvent()
57+
}
58+
59+
/**
60+
* HTTP engine abstraction interface.
61+
*
62+
* Allows users to provide custom HTTP implementations (e.g., OkHttp)
63+
* instead of the default Ktor implementation.
64+
*
65+
* Example custom implementation:
66+
* ```kotlin
67+
* class OkHttpEngine(private val client: OkHttpClient) : HttpEngine {
68+
* override suspend fun execute(request: HttpRequest): HttpResult {
69+
* // Use OkHttp to execute request
70+
* }
71+
* override suspend fun executeStream(request: HttpRequest): Flow<SseEvent> = flow {
72+
* // Use OkHttp SSE implementation
73+
* }
74+
* override fun close() {
75+
* client.dispatcher.executorService.shutdown()
76+
* }
77+
* }
78+
* ```
79+
*/
80+
interface HttpEngine {
81+
/**
82+
* Executes a standard HTTP request.
83+
*
84+
* @param request The HTTP request to execute
85+
* @return The HTTP response result
86+
*/
87+
suspend fun execute(request: HttpRequest): HttpResult
88+
89+
/**
90+
* Executes an SSE streaming request.
91+
*
92+
* The returned Flow emits [SseEvent.Data] for each line received,
93+
* [SseEvent.Complete] when the stream ends, and [SseEvent.Error] on errors.
94+
*
95+
* @param request The HTTP request to execute (with SSE headers)
96+
* @return A Flow of SSE events
97+
*/
98+
suspend fun executeStream(request: HttpRequest): Flow<SseEvent>
99+
100+
/**
101+
* Closes the HTTP engine and releases resources.
102+
*/
103+
fun close()
104+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package xyz.junerver.compose.ai.http
2+
3+
/*
4+
Description: Global HTTP engine configuration
5+
Author: Junerver
6+
Date: 2026/01/06
7+
Email: junerver@gmail.com
8+
Version: v1.0
9+
*/
10+
11+
/**
12+
* Global configuration for HTTP engine.
13+
*
14+
* Use this to set a custom HTTP engine globally for all AI hooks.
15+
*
16+
* Example - Replace with OkHttp:
17+
* ```kotlin
18+
* // In your Application initialization
19+
* HttpEngineConfig.defaultEngineFactory = { OkHttpEngine(myOkHttpClient) }
20+
* ```
21+
*
22+
* Example - Add custom interceptors:
23+
* ```kotlin
24+
* HttpEngineConfig.defaultEngineFactory = {
25+
* KtorHttpEngine(HttpClient {
26+
* install(HttpTimeout) { ... }
27+
* install(Logging) { ... }
28+
* })
29+
* }
30+
* ```
31+
*/
32+
object HttpEngineConfig {
33+
/**
34+
* Factory function to create the default HTTP engine.
35+
*
36+
* Override this to use a custom HTTP engine globally.
37+
* Default: Creates a new [KtorHttpEngine] instance.
38+
*/
39+
var defaultEngineFactory: () -> HttpEngine = { KtorHttpEngine() }
40+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package xyz.junerver.compose.ai.http
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.HttpTimeout
5+
import io.ktor.client.plugins.logging.LogLevel
6+
import io.ktor.client.plugins.logging.Logger
7+
import io.ktor.client.plugins.logging.Logging
8+
import io.ktor.client.plugins.logging.SIMPLE
9+
import io.ktor.client.plugins.timeout
10+
import io.ktor.client.request.header
11+
import io.ktor.client.request.post
12+
import io.ktor.client.request.preparePost
13+
import io.ktor.client.request.setBody
14+
import io.ktor.client.statement.bodyAsChannel
15+
import io.ktor.client.statement.bodyAsText
16+
import io.ktor.http.ContentType
17+
import io.ktor.http.HttpHeaders
18+
import io.ktor.http.contentType
19+
import io.ktor.http.isSuccess
20+
import io.ktor.http.withCharset
21+
import io.ktor.utils.io.charsets.Charsets
22+
import io.ktor.utils.io.readUTF8Line
23+
import kotlinx.coroutines.flow.Flow
24+
import kotlinx.coroutines.flow.flow
25+
26+
/*
27+
Description: Ktor-based default HTTP engine implementation
28+
Author: Junerver
29+
Date: 2026/01/06
30+
Email: junerver@gmail.com
31+
Version: v1.0
32+
*/
33+
34+
/**
35+
* Default HTTP engine implementation using Ktor.
36+
*
37+
* This is the default engine used when no custom engine is provided.
38+
* Uses Ktor's CIO engine on JVM/Android and Darwin engine on iOS.
39+
*
40+
* @param client Optional custom HttpClient instance. If not provided,
41+
* a default client with timeout and logging will be created.
42+
*/
43+
class KtorHttpEngine(
44+
private val client: HttpClient = createDefaultClient(),
45+
) : HttpEngine {
46+
companion object {
47+
/**
48+
* Creates a default HttpClient with basic configuration.
49+
*/
50+
fun createDefaultClient(): HttpClient = HttpClient {
51+
install(HttpTimeout)
52+
install(Logging) {
53+
logger = Logger.SIMPLE
54+
level = LogLevel.ALL
55+
}
56+
}
57+
}
58+
59+
override suspend fun execute(request: HttpRequest): HttpResult {
60+
val response = client.post(request.url) {
61+
timeout {
62+
requestTimeoutMillis = request.timeout
63+
connectTimeoutMillis = request.timeout
64+
socketTimeoutMillis = request.timeout
65+
}
66+
contentType(ContentType.Application.Json.withCharset(Charsets.UTF_8))
67+
request.headers.forEach { (key, value) ->
68+
header(key, value)
69+
}
70+
request.body?.let { setBody(it) }
71+
}
72+
73+
return HttpResult(
74+
statusCode = response.status.value,
75+
body = response.bodyAsText(),
76+
)
77+
}
78+
79+
override suspend fun executeStream(request: HttpRequest): Flow<SseEvent> = flow {
80+
try {
81+
client.preparePost(request.url) {
82+
// SSE streams need longer/no timeout
83+
timeout {
84+
requestTimeoutMillis = Long.MAX_VALUE
85+
socketTimeoutMillis = Long.MAX_VALUE
86+
}
87+
contentType(ContentType.Application.Json.withCharset(Charsets.UTF_8))
88+
header(HttpHeaders.Accept, "text/event-stream")
89+
header(HttpHeaders.CacheControl, "no-cache")
90+
header(HttpHeaders.Connection, "keep-alive")
91+
request.headers.forEach { (key, value) ->
92+
header(key, value)
93+
}
94+
request.body?.let { setBody(it) }
95+
}.execute { response ->
96+
if (!response.status.isSuccess()) {
97+
val errorBody = response.bodyAsChannel().readUTF8Line() ?: "Unknown error"
98+
emit(SseEvent.Error(Exception("HTTP ${response.status.value}: $errorBody")))
99+
return@execute
100+
}
101+
102+
val channel = response.bodyAsChannel()
103+
while (!channel.isClosedForRead) {
104+
val line = channel.readUTF8Line() ?: continue
105+
emit(SseEvent.Data(line))
106+
}
107+
emit(SseEvent.Complete)
108+
}
109+
} catch (e: Exception) {
110+
emit(SseEvent.Error(e))
111+
}
112+
}
113+
114+
override fun close() {
115+
client.close()
116+
}
117+
}

0 commit comments

Comments
 (0)