Skip to content

Commit edb0acf

Browse files
authored
Merge pull request #123 from AVSystem/feat/injectable-http-client
feat(api): enable injection of a preconfigured HttpClient for shared engine and plugin usage
2 parents ac813dd + 6beffd5 commit edb0acf

7 files changed

Lines changed: 366 additions & 33 deletions

File tree

‎README.md‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,20 +338,45 @@ justworks {
338338
}
339339
```
340340

341-
### Ktor Engine
341+
### Ktor Engine and HttpClient
342342

343-
The generated `HttpClient` is created without an explicit engine, so Ktor uses whichever engine is on the classpath.
344-
Switch engines by changing your dependency:
343+
By default, each generated client creates its own `HttpClient` without an explicit engine, so Ktor uses whichever
344+
engine is on the classpath, with that engine's default settings:
345345

346346
```kotlin
347347
dependencies {
348-
// Pick one:
349-
implementation("io.ktor:ktor-client-cio:3.1.1") // CIO (default, pure Kotlin)
350-
implementation("io.ktor:ktor-client-okhttp:3.1.1") // OkHttp
351-
implementation("io.ktor:ktor-client-apache:3.1.1") // Apache
348+
implementation("io.ktor:ktor-client-cio:3.1.1") // or ktor-client-apache5, ktor-client-java, ...
352349
}
353350
```
354351

352+
To control the engine, connection pool, timeouts or plugins, pass your own `HttpClient`. The generated client derives
353+
its client from yours with `HttpClient.config {}`, so every API client built from the same `HttpClient` shares one
354+
engine (and its connection pool) and the plugins you installed:
355+
356+
```kotlin
357+
val http = HttpClient(Apache5) {
358+
engine {
359+
connectTimeout = 2_000
360+
socketTimeout = 8_000
361+
configureConnectionManager {
362+
setMaxConnTotal(500)
363+
setMaxConnPerRoute(500)
364+
}
365+
}
366+
install(HttpTimeout) { requestTimeoutMillis = 10_000 }
367+
}
368+
369+
val pets = PetsApi("https://api.example.com", http) { tokenStore.getAccessToken() }
370+
val store = StoreApi("https://api.example.com", http) { tokenStore.getAccessToken() }
371+
```
372+
373+
- The generated client always installs its own `ContentNegotiation` with the generated `Json` and sets
374+
`expectSuccess = false`; response mapping relies on both. Don't install `ContentNegotiation` in the client you pass
375+
in: plugin configurations are merged, and your JSON converter could take over decoding without the generated
376+
`serializersModule`.
377+
- Closing a generated client does not close the `HttpClient` you passed in. Close it yourself once every API client
378+
built from it is done.
379+
355380
### JSON Configuration
356381

357382
The internal `Json` instance uses default settings (`ignoreUnknownKeys = false`, `isLenient = false`). If you need a
@@ -388,6 +413,8 @@ dependencies {
388413

389414
Each generated client extends `ApiClientBase` and creates its own pre-configured `HttpClient` internally.
390415
You only need to provide the base URL and authentication credentials (if the spec defines security schemes).
416+
To share an engine, connection pool or plugins across clients, pass your own `HttpClient` as the second argument —
417+
see [Ktor Engine and HttpClient](#ktor-engine-and-httpclient).
391418

392419
Class names are derived from OpenAPI tags as `<Tag>Api` (e.g., a `pets` tag produces `PetsApi`). Untagged endpoints go
393420
to `DefaultApi`.

‎core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.squareup.kotlinpoet.MemberName
88
// ============================================================================
99

1010
val HTTP_CLIENT = ClassName("io.ktor.client", "HttpClient")
11+
val HTTP_CLIENT_CONFIG = ClassName("io.ktor.client", "HttpClientConfig")
1112
val CONTENT_NEGOTIATION = ClassName("io.ktor.client.plugins.contentnegotiation", "ContentNegotiation")
1213
val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders")
1314
val ENCODE_URL_PATH_PART_FUN = MemberName("io.ktor.http", "encodeURLPathPart")
@@ -127,6 +128,7 @@ val UUID_SERIALIZER = ClassName("com.avsystem.justworks", "UuidSerializer")
127128
const val BASE_URL = "baseUrl"
128129
const val TOKEN = "token"
129130
const val CLIENT = "client"
131+
const val HTTP_CLIENT_PARAM = "httpClient"
130132
const val BODY = "body"
131133
const val JSON_PROPERTY = "json"
132134
const val APPLY_AUTH = "applyAuth"

‎core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.avsystem.justworks.core.gen.EXPERIMENTAL_UUID_API
1111
import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE
1212
import com.avsystem.justworks.core.gen.HEADERS_FUN
1313
import com.avsystem.justworks.core.gen.HTTP_CLIENT
14+
import com.avsystem.justworks.core.gen.HTTP_CLIENT_PARAM
1415
import com.avsystem.justworks.core.gen.HTTP_HEADERS
1516
import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER
1617
import com.avsystem.justworks.core.gen.HTTP_RESULT
@@ -87,14 +88,26 @@ internal object ClientGenerator {
8788
val classSimpleName = if (options.generateInterfaces) "${baseName}Impl" else baseName
8889
val className = ClassName(apiPackage, nameRegistry.register(classSimpleName))
8990

90-
val clientInitializer = CodeBlock.of("${CREATE_HTTP_CLIENT}()")
91+
val clientInitializer = CodeBlock.of("$CREATE_HTTP_CLIENT($HTTP_CLIENT_PARAM)")
9192

9293
val tokenType = LambdaTypeName.get(returnType = STRING)
9394
val isSingleBearer = securitySchemes.singleOrNull() is SecurityScheme.Bearer
9495

96+
val httpClientParam = ParameterSpec
97+
.builder(HTTP_CLIENT_PARAM, HTTP_CLIENT.copy(nullable = true))
98+
.defaultValue("null")
99+
if (options.generateKdoc) {
100+
httpClientParam.addKdoc(
101+
"client to derive this API client from, sharing its engine and plugins; `null` creates a " +
102+
"standalone client. Closing this API client does not close [%L].",
103+
HTTP_CLIENT_PARAM,
104+
)
105+
}
106+
95107
val constructorBuilder = FunSpec
96108
.constructorBuilder()
97109
.addParameter(BASE_URL, STRING)
110+
.addParameter(httpClientParam.build())
98111

99112
val classBuilder = TypeSpec
100113
.classBuilder(className)

‎core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import com.avsystem.justworks.core.gen.ENCODE_TO_JSON_ELEMENT_FUN
1919
import com.avsystem.justworks.core.gen.ENCODE_URL_PATH_PART_FUN
2020
import com.avsystem.justworks.core.gen.ENUM_CLASS
2121
import com.avsystem.justworks.core.gen.HTTP_CLIENT
22+
import com.avsystem.justworks.core.gen.HTTP_CLIENT_CONFIG
23+
import com.avsystem.justworks.core.gen.HTTP_CLIENT_PARAM
2224
import com.avsystem.justworks.core.gen.HTTP_ERROR
2325
import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER
2426
import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION
@@ -44,6 +46,7 @@ import com.squareup.kotlinpoet.NUMBER
4446
import com.squareup.kotlinpoet.ParameterSpec
4547
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
4648
import com.squareup.kotlinpoet.PropertySpec
49+
import com.squareup.kotlinpoet.STAR
4750
import com.squareup.kotlinpoet.STRING
4851
import com.squareup.kotlinpoet.TypeName
4952
import com.squareup.kotlinpoet.TypeSpec
@@ -322,15 +325,29 @@ internal object ApiClientBaseGenerator {
322325
.build()
323326
}
324327

325-
private fun buildCreateHttpClient(): FunSpec = FunSpec
326-
.builder(CREATE_HTTP_CLIENT)
327-
.addModifiers(KModifier.PROTECTED)
328-
.returns(HTTP_CLIENT)
329-
.beginControlFlow("return %T", HTTP_CLIENT)
330-
.beginControlFlow("install(%T)", CONTENT_NEGOTIATION)
331-
.addStatement("%M(%L)", JSON_FUN, JSON_PROPERTY)
332-
.endControlFlow()
333-
.addStatement("expectSuccess = false")
334-
.endControlFlow()
335-
.build()
328+
// A caller-supplied client is derived via `config {}`
329+
private fun buildCreateHttpClient(): FunSpec {
330+
val configType = LambdaTypeName.get(
331+
receiver = HTTP_CLIENT_CONFIG.parameterizedBy(STAR),
332+
returnType = UNIT,
333+
)
334+
val baseClient = ParameterSpec
335+
.builder(HTTP_CLIENT_PARAM, HTTP_CLIENT.copy(nullable = true))
336+
.defaultValue("null")
337+
.build()
338+
339+
return FunSpec
340+
.builder(CREATE_HTTP_CLIENT)
341+
.addModifiers(KModifier.PROTECTED)
342+
.addParameter(baseClient)
343+
.returns(HTTP_CLIENT)
344+
.beginControlFlow("val defaults: %T =", configType)
345+
.beginControlFlow("install(%T)", CONTENT_NEGOTIATION)
346+
.addStatement("%M(%L)", JSON_FUN, JSON_PROPERTY)
347+
.endControlFlow()
348+
.addStatement("expectSuccess = false")
349+
.endControlFlow()
350+
.addStatement("return %L?.config(defaults) ?: %T(defaults)", HTTP_CLIENT_PARAM, HTTP_CLIENT)
351+
.build()
352+
}
336353
}

‎core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,30 @@ class ApiClientBaseGeneratorTest {
9999
}
100100

101101
@Test
102-
fun `ApiClientBase has createHttpClient function with no parameters`() {
102+
fun `ApiClientBase has createHttpClient function taking an optional base client`() {
103103
val create = classSpec.funSpecs.first { it.name == "createHttpClient" }
104104
assertTrue(KModifier.PROTECTED in create.modifiers)
105-
assertTrue(
106-
create.parameters.isEmpty(),
107-
"createHttpClient should take no parameters; json is a constructor property now",
108-
)
105+
val param = create.parameters.single()
106+
assertEquals("httpClient", param.name)
107+
assertEquals("io.ktor.client.HttpClient?", param.type.toString())
108+
assertEquals("null", param.defaultValue.toString(), "Expected httpClient to default to null")
109109
val body = create.body.toString()
110110
assertTrue(body.contains("ContentNegotiation"), "Expected ContentNegotiation install")
111111
assertTrue(body.contains("json(json)"), "Expected the shared json property installed into ContentNegotiation")
112-
assertTrue(body.contains("expectSuccess"), "Expected expectSuccess = false")
112+
assertTrue(body.contains("expectSuccess = false"), "Expected expectSuccess = false")
113+
}
114+
115+
@Test
116+
fun `createHttpClient derives from the base client via config and falls back to a standalone client`() {
117+
val body = classFun("createHttpClient").body.toString()
118+
assertTrue(
119+
body.contains("httpClient?.config(defaults)"),
120+
"Expected a base client to be derived with config(), sharing its engine, got: $body",
121+
)
122+
assertTrue(
123+
body.contains("?: io.ktor.client.HttpClient(defaults)"),
124+
"Expected a standalone client when no base client is given, got: $body",
125+
)
113126
}
114127

115128
// -- json property: constructor-injected val --

‎core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt‎

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -433,14 +433,72 @@ class ClientGeneratorTest {
433433
assertEquals("kotlin.String", baseUrl.type.toString())
434434
}
435435

436-
// -- No security: constructor has only baseUrl --
436+
// -- No security: constructor has only baseUrl and httpClient --
437437

438438
@Test
439-
fun `no security schemes generates constructor with only baseUrl`() {
439+
fun `no security schemes generates constructor with only baseUrl and httpClient`() {
440440
val cls = clientClass(endpoint())
441441
val constructor = assertNotNull(cls.primaryConstructor)
442442
val paramNames = constructor.parameters.map { it.name }
443-
assertEquals(listOf("baseUrl"), paramNames)
443+
assertEquals(listOf("baseUrl", "httpClient"), paramNames)
444+
}
445+
446+
// -- Injectable HttpClient --
447+
448+
@Test
449+
fun `constructor takes a nullable httpClient defaulting to null`() {
450+
val cls = clientClass(endpoint())
451+
val httpClient = assertNotNull(cls.primaryConstructor).parameters.first { it.name == "httpClient" }
452+
assertEquals("io.ktor.client.HttpClient?", httpClient.type.toString())
453+
assertEquals("null", httpClient.defaultValue.toString())
454+
}
455+
456+
@Test
457+
fun `httpClient precedes auth params so a trailing token lambda still binds to the token`() {
458+
val bearer = clientClass(listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth")))
459+
assertEquals(
460+
listOf("baseUrl", "httpClient", "token"),
461+
assertNotNull(bearer.primaryConstructor).parameters.map { it.name },
462+
)
463+
464+
val basic = clientClass(listOf(endpoint()), listOf(SecurityScheme.Basic("BasicAuth")))
465+
assertEquals(
466+
listOf("baseUrl", "httpClient", "basicAuthTestUsername", "basicAuthTestPassword"),
467+
assertNotNull(basic.primaryConstructor).parameters.map { it.name },
468+
)
469+
}
470+
471+
@Test
472+
fun `client has a single constructor`() {
473+
val cls = clientClass(listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth")))
474+
assertTrue(cls.funSpecs.none { it.isConstructor }, "Expected no secondary constructors")
475+
}
476+
477+
@Test
478+
fun `client property is derived from the injected httpClient`() {
479+
val clientInitializer = clientClass(endpoint())
480+
.propertySpecs
481+
.first { it.name == "client" }
482+
.initializer
483+
.toString()
484+
assertEquals("createHttpClient(httpClient)", clientInitializer)
485+
}
486+
487+
@Test
488+
fun `httpClient constructor param has KDoc only when generateKdoc is enabled`() {
489+
fun httpClientKdoc(options: OutputOptions): String = generate(spec(endpoint()), options = options)
490+
.first()
491+
.members
492+
.filterIsInstance<TypeSpec>()
493+
.first()
494+
.let { assertNotNull(it.primaryConstructor) }
495+
.parameters
496+
.first { it.name == "httpClient" }
497+
.kdoc
498+
.toString()
499+
500+
assertTrue(httpClientKdoc(OutputOptions(generateKdoc = true)).contains("sharing its engine"))
501+
assertTrue(httpClientKdoc(OutputOptions(generateKdoc = false)).isEmpty())
444502
}
445503

446504
// -- Pitfall 3: Untagged endpoints go to DefaultClient --
@@ -774,7 +832,11 @@ class ClientGeneratorTest {
774832
.first { it.name == "client" }
775833
.initializer
776834
.toString()
777-
assertEquals("createHttpClient()", clientInitializer, "createHttpClient() no longer takes serializersModule")
835+
assertEquals(
836+
"createHttpClient(httpClient)",
837+
clientInitializer,
838+
"createHttpClient() no longer takes serializersModule",
839+
)
778840
}
779841

780842
// -- CONT-01: Multipart form-data code generation --
@@ -1189,7 +1251,10 @@ class ClientGeneratorTest {
11891251
.propertySpecs
11901252
.first { it.name == "client" }
11911253
val clientInitializer = clientProperty.initializer.toString()
1192-
assertTrue(clientInitializer.contains("createHttpClient()"), "Expected plain createHttpClient()")
1254+
assertTrue(
1255+
clientInitializer.contains("createHttpClient(httpClient)"),
1256+
"Expected plain createHttpClient(httpClient)",
1257+
)
11931258
assertTrue(!clientInitializer.contains("serializersModule"), "Expected no serializersModule")
11941259
}
11951260

@@ -1301,9 +1366,9 @@ class ClientGeneratorTest {
13011366
val constructor = assertNotNull(cls.primaryConstructor)
13021367
val paramNames = constructor.parameters.map { it.name }
13031368
assertEquals(
1304-
listOf("baseUrl"),
1369+
listOf("baseUrl", "httpClient"),
13051370
paramNames,
1306-
"Expected only baseUrl param when security is explicitly empty",
1371+
"Expected only baseUrl and httpClient params when security is explicitly empty",
13071372
)
13081373
}
13091374

@@ -1391,7 +1456,7 @@ class ClientGeneratorTest {
13911456
)
13921457
val constructor = assertNotNull(cls.primaryConstructor)
13931458
val paramNames = constructor.parameters.map { it.name }
1394-
assertEquals(listOf("baseUrl", "token"), paramNames, "Single Bearer should use plain token param")
1459+
assertEquals(listOf("baseUrl", "httpClient", "token"), paramNames, "Single Bearer should use plain token param")
13951460
}
13961461

13971462
@Test

0 commit comments

Comments
 (0)