diff --git a/README.md b/README.md index e1c05c4..97408e4 100644 --- a/README.md +++ b/README.md @@ -338,20 +338,45 @@ justworks { } ``` -### Ktor Engine +### Ktor Engine and HttpClient -The generated `HttpClient` is created without an explicit engine, so Ktor uses whichever engine is on the classpath. -Switch engines by changing your dependency: +By default, each generated client creates its own `HttpClient` without an explicit engine, so Ktor uses whichever +engine is on the classpath, with that engine's default settings: ```kotlin dependencies { - // Pick one: - implementation("io.ktor:ktor-client-cio:3.1.1") // CIO (default, pure Kotlin) - implementation("io.ktor:ktor-client-okhttp:3.1.1") // OkHttp - implementation("io.ktor:ktor-client-apache:3.1.1") // Apache + implementation("io.ktor:ktor-client-cio:3.1.1") // or ktor-client-apache5, ktor-client-java, ... } ``` +To control the engine, connection pool, timeouts or plugins, pass your own `HttpClient`. The generated client derives +its client from yours with `HttpClient.config {}`, so every API client built from the same `HttpClient` shares one +engine (and its connection pool) and the plugins you installed: + +```kotlin +val http = HttpClient(Apache5) { + engine { + connectTimeout = 2_000 + socketTimeout = 8_000 + configureConnectionManager { + setMaxConnTotal(500) + setMaxConnPerRoute(500) + } + } + install(HttpTimeout) { requestTimeoutMillis = 10_000 } +} + +val pets = PetsApi("https://api.example.com", http) { tokenStore.getAccessToken() } +val store = StoreApi("https://api.example.com", http) { tokenStore.getAccessToken() } +``` + +- The generated client always installs its own `ContentNegotiation` with the generated `Json` and sets + `expectSuccess = false`; response mapping relies on both. Don't install `ContentNegotiation` in the client you pass + in: plugin configurations are merged, and your JSON converter could take over decoding without the generated + `serializersModule`. +- Closing a generated client does not close the `HttpClient` you passed in. Close it yourself once every API client + built from it is done. + ### JSON Configuration The internal `Json` instance uses default settings (`ignoreUnknownKeys = false`, `isLenient = false`). If you need a @@ -388,6 +413,8 @@ dependencies { Each generated client extends `ApiClientBase` and creates its own pre-configured `HttpClient` internally. You only need to provide the base URL and authentication credentials (if the spec defines security schemes). +To share an engine, connection pool or plugins across clients, pass your own `HttpClient` as the second argument — +see [Ktor Engine and HttpClient](#ktor-engine-and-httpclient). Class names are derived from OpenAPI tags as `Api` (e.g., a `pets` tag produces `PetsApi`). Untagged endpoints go to `DefaultApi`. diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index a83f221..787e34f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -8,6 +8,7 @@ import com.squareup.kotlinpoet.MemberName // ============================================================================ val HTTP_CLIENT = ClassName("io.ktor.client", "HttpClient") +val HTTP_CLIENT_CONFIG = ClassName("io.ktor.client", "HttpClientConfig") val CONTENT_NEGOTIATION = ClassName("io.ktor.client.plugins.contentnegotiation", "ContentNegotiation") val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders") val ENCODE_URL_PATH_PART_FUN = MemberName("io.ktor.http", "encodeURLPathPart") @@ -127,6 +128,7 @@ val UUID_SERIALIZER = ClassName("com.avsystem.justworks", "UuidSerializer") const val BASE_URL = "baseUrl" const val TOKEN = "token" const val CLIENT = "client" +const val HTTP_CLIENT_PARAM = "httpClient" const val BODY = "body" const val JSON_PROPERTY = "json" const val APPLY_AUTH = "applyAuth" diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index aaca3cf..e16587b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -11,6 +11,7 @@ import com.avsystem.justworks.core.gen.EXPERIMENTAL_UUID_API import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_CLIENT_PARAM import com.avsystem.justworks.core.gen.HTTP_HEADERS import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_RESULT @@ -87,14 +88,26 @@ internal object ClientGenerator { val classSimpleName = if (options.generateInterfaces) "${baseName}Impl" else baseName val className = ClassName(apiPackage, nameRegistry.register(classSimpleName)) - val clientInitializer = CodeBlock.of("${CREATE_HTTP_CLIENT}()") + val clientInitializer = CodeBlock.of("$CREATE_HTTP_CLIENT($HTTP_CLIENT_PARAM)") val tokenType = LambdaTypeName.get(returnType = STRING) val isSingleBearer = securitySchemes.singleOrNull() is SecurityScheme.Bearer + val httpClientParam = ParameterSpec + .builder(HTTP_CLIENT_PARAM, HTTP_CLIENT.copy(nullable = true)) + .defaultValue("null") + if (options.generateKdoc) { + httpClientParam.addKdoc( + "client to derive this API client from, sharing its engine and plugins; `null` creates a " + + "standalone client. Closing this API client does not close [%L].", + HTTP_CLIENT_PARAM, + ) + } + val constructorBuilder = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) + .addParameter(httpClientParam.build()) val classBuilder = TypeSpec .classBuilder(className) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 3cef6a9..0a30b1f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -19,6 +19,8 @@ import com.avsystem.justworks.core.gen.ENCODE_TO_JSON_ELEMENT_FUN import com.avsystem.justworks.core.gen.ENCODE_URL_PATH_PART_FUN import com.avsystem.justworks.core.gen.ENUM_CLASS import com.avsystem.justworks.core.gen.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_CLIENT_CONFIG +import com.avsystem.justworks.core.gen.HTTP_CLIENT_PARAM import com.avsystem.justworks.core.gen.HTTP_ERROR import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION @@ -44,6 +46,7 @@ import com.squareup.kotlinpoet.NUMBER import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STAR import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec @@ -322,15 +325,29 @@ internal object ApiClientBaseGenerator { .build() } - private fun buildCreateHttpClient(): FunSpec = FunSpec - .builder(CREATE_HTTP_CLIENT) - .addModifiers(KModifier.PROTECTED) - .returns(HTTP_CLIENT) - .beginControlFlow("return %T", HTTP_CLIENT) - .beginControlFlow("install(%T)", CONTENT_NEGOTIATION) - .addStatement("%M(%L)", JSON_FUN, JSON_PROPERTY) - .endControlFlow() - .addStatement("expectSuccess = false") - .endControlFlow() - .build() + // A caller-supplied client is derived via `config {}` + private fun buildCreateHttpClient(): FunSpec { + val configType = LambdaTypeName.get( + receiver = HTTP_CLIENT_CONFIG.parameterizedBy(STAR), + returnType = UNIT, + ) + val baseClient = ParameterSpec + .builder(HTTP_CLIENT_PARAM, HTTP_CLIENT.copy(nullable = true)) + .defaultValue("null") + .build() + + return FunSpec + .builder(CREATE_HTTP_CLIENT) + .addModifiers(KModifier.PROTECTED) + .addParameter(baseClient) + .returns(HTTP_CLIENT) + .beginControlFlow("val defaults: %T =", configType) + .beginControlFlow("install(%T)", CONTENT_NEGOTIATION) + .addStatement("%M(%L)", JSON_FUN, JSON_PROPERTY) + .endControlFlow() + .addStatement("expectSuccess = false") + .endControlFlow() + .addStatement("return %L?.config(defaults) ?: %T(defaults)", HTTP_CLIENT_PARAM, HTTP_CLIENT) + .build() + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index ea69002..7499820 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt @@ -99,17 +99,30 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has createHttpClient function with no parameters`() { + fun `ApiClientBase has createHttpClient function taking an optional base client`() { val create = classSpec.funSpecs.first { it.name == "createHttpClient" } assertTrue(KModifier.PROTECTED in create.modifiers) - assertTrue( - create.parameters.isEmpty(), - "createHttpClient should take no parameters; json is a constructor property now", - ) + val param = create.parameters.single() + assertEquals("httpClient", param.name) + assertEquals("io.ktor.client.HttpClient?", param.type.toString()) + assertEquals("null", param.defaultValue.toString(), "Expected httpClient to default to null") val body = create.body.toString() assertTrue(body.contains("ContentNegotiation"), "Expected ContentNegotiation install") assertTrue(body.contains("json(json)"), "Expected the shared json property installed into ContentNegotiation") - assertTrue(body.contains("expectSuccess"), "Expected expectSuccess = false") + assertTrue(body.contains("expectSuccess = false"), "Expected expectSuccess = false") + } + + @Test + fun `createHttpClient derives from the base client via config and falls back to a standalone client`() { + val body = classFun("createHttpClient").body.toString() + assertTrue( + body.contains("httpClient?.config(defaults)"), + "Expected a base client to be derived with config(), sharing its engine, got: $body", + ) + assertTrue( + body.contains("?: io.ktor.client.HttpClient(defaults)"), + "Expected a standalone client when no base client is given, got: $body", + ) } // -- json property: constructor-injected val -- diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 20cfd5d..7fb5891 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -433,14 +433,72 @@ class ClientGeneratorTest { assertEquals("kotlin.String", baseUrl.type.toString()) } - // -- No security: constructor has only baseUrl -- + // -- No security: constructor has only baseUrl and httpClient -- @Test - fun `no security schemes generates constructor with only baseUrl`() { + fun `no security schemes generates constructor with only baseUrl and httpClient`() { val cls = clientClass(endpoint()) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertEquals(listOf("baseUrl"), paramNames) + assertEquals(listOf("baseUrl", "httpClient"), paramNames) + } + + // -- Injectable HttpClient -- + + @Test + fun `constructor takes a nullable httpClient defaulting to null`() { + val cls = clientClass(endpoint()) + val httpClient = assertNotNull(cls.primaryConstructor).parameters.first { it.name == "httpClient" } + assertEquals("io.ktor.client.HttpClient?", httpClient.type.toString()) + assertEquals("null", httpClient.defaultValue.toString()) + } + + @Test + fun `httpClient precedes auth params so a trailing token lambda still binds to the token`() { + val bearer = clientClass(listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth"))) + assertEquals( + listOf("baseUrl", "httpClient", "token"), + assertNotNull(bearer.primaryConstructor).parameters.map { it.name }, + ) + + val basic = clientClass(listOf(endpoint()), listOf(SecurityScheme.Basic("BasicAuth"))) + assertEquals( + listOf("baseUrl", "httpClient", "basicAuthTestUsername", "basicAuthTestPassword"), + assertNotNull(basic.primaryConstructor).parameters.map { it.name }, + ) + } + + @Test + fun `client has a single constructor`() { + val cls = clientClass(listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth"))) + assertTrue(cls.funSpecs.none { it.isConstructor }, "Expected no secondary constructors") + } + + @Test + fun `client property is derived from the injected httpClient`() { + val clientInitializer = clientClass(endpoint()) + .propertySpecs + .first { it.name == "client" } + .initializer + .toString() + assertEquals("createHttpClient(httpClient)", clientInitializer) + } + + @Test + fun `httpClient constructor param has KDoc only when generateKdoc is enabled`() { + fun httpClientKdoc(options: OutputOptions): String = generate(spec(endpoint()), options = options) + .first() + .members + .filterIsInstance() + .first() + .let { assertNotNull(it.primaryConstructor) } + .parameters + .first { it.name == "httpClient" } + .kdoc + .toString() + + assertTrue(httpClientKdoc(OutputOptions(generateKdoc = true)).contains("sharing its engine")) + assertTrue(httpClientKdoc(OutputOptions(generateKdoc = false)).isEmpty()) } // -- Pitfall 3: Untagged endpoints go to DefaultClient -- @@ -774,7 +832,11 @@ class ClientGeneratorTest { .first { it.name == "client" } .initializer .toString() - assertEquals("createHttpClient()", clientInitializer, "createHttpClient() no longer takes serializersModule") + assertEquals( + "createHttpClient(httpClient)", + clientInitializer, + "createHttpClient() no longer takes serializersModule", + ) } // -- CONT-01: Multipart form-data code generation -- @@ -1189,7 +1251,10 @@ class ClientGeneratorTest { .propertySpecs .first { it.name == "client" } val clientInitializer = clientProperty.initializer.toString() - assertTrue(clientInitializer.contains("createHttpClient()"), "Expected plain createHttpClient()") + assertTrue( + clientInitializer.contains("createHttpClient(httpClient)"), + "Expected plain createHttpClient(httpClient)", + ) assertTrue(!clientInitializer.contains("serializersModule"), "Expected no serializersModule") } @@ -1301,9 +1366,9 @@ class ClientGeneratorTest { val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertEquals( - listOf("baseUrl"), + listOf("baseUrl", "httpClient"), paramNames, - "Expected only baseUrl param when security is explicitly empty", + "Expected only baseUrl and httpClient params when security is explicitly empty", ) } @@ -1391,7 +1456,7 @@ class ClientGeneratorTest { ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertEquals(listOf("baseUrl", "token"), paramNames, "Single Bearer should use plain token param") + assertEquals(listOf("baseUrl", "httpClient", "token"), paramNames, "Single Bearer should use plain token param") } @Test diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index f1b32c5..d5ecb1c 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -1646,4 +1646,200 @@ class JustworksPluginFunctionalTest { assertTrue(getTokenFn.contains(".toResult()"), "getToken (JSON string) should use toResult(), got: $getTokenFn") assertTrue(getRawFn.contains(".toRawResult()"), "getRaw (text/plain) should use toRawResult(), got: $getRawFn") } + + @Test + fun `generated clients accept a preconfigured HttpClient and share its engine and plugins`() { + writeFile( + "api/petstore.yaml", + """ + openapi: '3.0.0' + info: + title: Petstore + version: '1.0' + security: + - bearerAuth: [] + paths: + /pets: + get: + operationId: listPets + tags: + - pets + responses: + '200': + description: A list of pets + content: + application/json: + schema: + type: array + items: + ${'$'}ref: '#/components/schemas/Pet' + /pets/{petId}: + get: + operationId: getPet + tags: + - pets + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: A pet + content: + application/json: + schema: + ${'$'}ref: '#/components/schemas/Pet' + '404': + description: Not found + components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + testImplementation(kotlin("test-junit")) + testImplementation("io.ktor:ktor-client-mock:3.1.1") + // Without httpClient the API client builds a standalone one, which needs an engine on the classpath. + testImplementation("io.ktor:ktor-client-cio:3.1.1") + } + + justworks { + specs { + register("main") { + specFile = file("api/petstore.yaml") + packageName = "com.example" + } + } + } + """.trimIndent(), + ) + + writeFile( + "src/test/kotlin/InjectedHttpClientTest.kt", + """ + import com.avsystem.justworks.HttpError + import com.avsystem.justworks.HttpSuccess + import com.example.api.PetsApi + import com.example.model.Pet + import io.ktor.client.HttpClient + import io.ktor.client.engine.mock.MockEngine + import io.ktor.client.engine.mock.MockRequestHandleScope + import io.ktor.client.engine.mock.respond + import io.ktor.client.plugins.defaultRequest + import io.ktor.client.request.HttpRequestData + import io.ktor.client.request.HttpResponseData + import io.ktor.client.request.get + import io.ktor.http.HttpHeaders + import io.ktor.http.HttpStatusCode + import io.ktor.http.headersOf + import kotlinx.coroutines.runBlocking + import kotlin.test.Test + import kotlin.test.assertEquals + + class InjectedHttpClientTest { + private val handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData = { request -> + when (request.url.encodedPath) { + "/pets" -> respond( + ""${'"'}[{"id":1,"name":"Rex"}]""${'"'}, + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond("", HttpStatusCode.NotFound) + } + } + + @Test + fun `trailing-lambda and named call sites compile without passing httpClient`() { + PetsApi("http://test") { "t" }.close() + PetsApi(baseUrl = "http://test", token = { "t" }).close() + PetsApi("http://test", null) { "t" }.close() + } + + @Test + fun `requests go through the injected client's engine and plugins`() = runBlocking { + val engine = MockEngine(handler) + val base = HttpClient(engine) { + defaultRequest { headers.append("X-Injected", "yes") } + } + val result = PetsApi("http://test", base) { "secret" }.listPets() + + assertEquals(HttpSuccess(200, listOf(Pet(id = 1, name = "Rex"))), result) + val request = engine.requestHistory.single() + assertEquals("yes", request.headers["X-Injected"]) + assertEquals("Bearer secret", request.headers[HttpHeaders.Authorization]) + } + + @Test + fun `generated expectSuccess = false wins over the injected client's setting`() = runBlocking { + val base = HttpClient(MockEngine(handler)) { expectSuccess = true } + val result = PetsApi("http://test", base) { "t" }.getPet(42) + + assertEquals(404, (result as HttpError<*>).code) + } + + @Test + fun `closing a generated client leaves the injected client and its siblings usable`() = runBlocking { + // Engine owned by the base client, as with HttpClient(Apache5) { ... } in production. + val base = HttpClient(MockEngine) { engine { addHandler(handler) } } + val first = PetsApi("http://test", base) { "t" } + val second = PetsApi("http://test", base) { "t" } + + first.close() + + assertEquals(200, (second.listPets() as HttpSuccess<*>).code) + assertEquals(HttpStatusCode.OK, base.get("http://test/pets").status) + } + } + """.trimIndent(), + ) + + val result = runner("test").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "justworksGenerateMain should succeed", + ) + assertEquals( + TaskOutcome.SUCCESS, + result.task(":test")?.outcome, + "generated clients should compile in every constructor form and share the injected client", + ) + } }