From a07d4114f23baf84791d524c339824e9a0e15e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 9 Jun 2026 17:22:52 +0200 Subject: [PATCH 1/3] feat(plugin): add generateInterfaces option Adds generateInterfaces (Boolean, default false) to JustworksSpecConfiguration. When enabled, an interface is generated per API tag holding all endpoint signatures, and the concrete client implements it. The interface keeps the configured name (e.g. PetsApi) and the client gets an `Impl` suffix (PetsApiImpl), enabling hand-written fakes in tests without a mocking library. Endpoint function generation now has explicit override/body/kdoc modes: interface members are abstract and carry the KDoc; impl members override and hold the body (and drop parameter defaults, which live on the interface). Default false keeps the single-class output unchanged. Closes #44 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 37 +++++-- .../justworks/core/gen/OutputOptions.kt | 1 + .../core/gen/client/ClientGenerator.kt | 99 ++++++++++++++++--- .../justworks/core/gen/ClientGeneratorTest.kt | 41 ++++++++ .../gradle/JustworksPluginFunctionalTest.kt | 22 +++++ .../justworks/gradle/JustworksGenerateTask.kt | 5 + .../justworks/gradle/JustworksPlugin.kt | 1 + .../gradle/JustworksSpecConfiguration.kt | 7 ++ 8 files changed, 188 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 81f96a6..9512a88 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,16 @@ extra configuration needed. ### Configuration options -| Property | Required | Default | Description | -|----------------|----------|----------------------|----------------------------------------| -| `specFile` | Yes | -- | Path to the OpenAPI spec (.yaml/.json) | -| `packageName` | Yes | -- | Base package for generated code | -| `apiPackage` | No | `$packageName.api` | Package for API client classes | -| `modelPackage` | No | `$packageName.model` | Package for model/data classes | -| `generateKdoc` | No | `true` | Emit KDoc comments in generated code | -| `apiClassPrefix` | No | `""` | Prefix for generated API client class names | -| `apiClassSuffix` | No | `"Api"` | Suffix for generated API client class names | +| Property | Required | Default | Description | +|----------------------|----------|----------------------|-----------------------------------------------------------------------| +| `specFile` | Yes | -- | Path to the OpenAPI spec (.yaml/.json) | +| `packageName` | Yes | -- | Base package for generated code | +| `apiPackage` | No | `$packageName.api` | Package for API client classes | +| `modelPackage` | No | `$packageName.model` | Package for model/data classes | +| `generateKdoc` | No | `true` | Emit KDoc comments in generated code | +| `apiClassPrefix` | No | `""` | Prefix for generated API client class names | +| `apiClassSuffix` | No | `"Api"` | Suffix for generated API client class names | +| `generateInterfaces` | No | `false` | Generate an interface per tag implemented by the client (for mocking) | ## Supported OpenAPI Features @@ -236,6 +237,24 @@ justworks { } ``` +### Interfaces for Testing + +Set `generateInterfaces = true` to emit an interface per tag that the client implements. The interface keeps the +configured name (e.g. `PetsApi`) and the concrete client gets an `Impl` suffix (`PetsApiImpl`). Depend on the interface +in your code and supply a hand-written fake in tests — no mocking library required. + +```kotlin +justworks { + specs { + register("petstore") { + specFile = file("api/petstore.yaml") + packageName = "com.example.petstore" + generateInterfaces = true // default: false -> PetsApi (interface) + PetsApiImpl (client) + } + } +} +``` + ### Ktor Engine The generated `HttpClient` is created without an explicit engine, so Ktor uses whichever engine is on the classpath. diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/OutputOptions.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/OutputOptions.kt index fe9d265..de6d46b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/OutputOptions.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/OutputOptions.kt @@ -10,4 +10,5 @@ data class OutputOptions( val generateKdoc: Boolean = true, val apiClassPrefix: String = "", val apiClassSuffix: String = "Api", + val generateInterfaces: Boolean = false, ) 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 4c5cf6f..e364fde 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 @@ -58,21 +58,30 @@ internal object ClientGenerator { context(_: Hierarchy, _: OutputOptions, _: ApiPackage, _: NameRegistry) fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } - return grouped.map { (tag, endpoints) -> - generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title) + return grouped.flatMap { (tag, endpoints) -> + generateClientFiles(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title) } } context(hierarchy: Hierarchy, options: OutputOptions, apiPackage: ApiPackage, nameRegistry: NameRegistry) - private fun generateClientFile( + private fun generateClientFiles( tag: String, endpoints: List, hasPolymorphicTypes: Boolean, securitySchemes: List, specTitle: String, - ): FileSpec { - val simpleName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}" - val className = ClassName(apiPackage, nameRegistry.register(simpleName)) + ): List { + val baseName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}" + + // When interfaces are enabled the public type keeps the base name and the + // concrete client gets an `Impl` suffix; otherwise the client uses the base name. + val interfaceClass = if (options.generateInterfaces) { + ClassName(apiPackage, nameRegistry.register(baseName)) + } else { + null + } + val classSimpleName = if (interfaceClass != null) "${baseName}Impl" else baseName + val className = ClassName(apiPackage, nameRegistry.register(classSimpleName)) val clientInitializer = if (hasPolymorphicTypes) { val generatedSerializersModule = MemberName(hierarchy.modelPackage, GENERATED_SERIALIZERS_MODULE) @@ -93,6 +102,10 @@ internal object ClientGenerator { .superclass(API_CLIENT_BASE) .addSuperclassConstructorParameter(BASE_URL) + if (interfaceClass != null) { + classBuilder.addSuperinterface(interfaceClass) + } + if (isSingleBearer) { // Single Bearer: use plain "token" param name for ergonomics constructorBuilder.addParameter(TOKEN, tokenType) @@ -139,14 +152,47 @@ internal object ClientGenerator { classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) } + val implementsInterface = interfaceClass != null context(NameRegistry()) { - classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) + classBuilder.addFunctions( + endpoints.map { + generateEndpointFunction( + it, + override = implementsInterface, + includeBody = true, + // KDoc lives on the interface when one is generated. + includeKdoc = options.generateKdoc && !implementsInterface, + ) + }, + ) } - return FileSpec + val classFile = FileSpec .builder(className) .addType(classBuilder.build()) .build() + + if (interfaceClass == null) return listOf(classFile) + + val interfaceBuilder = TypeSpec.interfaceBuilder(interfaceClass) + context(NameRegistry()) { + interfaceBuilder.addFunctions( + endpoints.map { + generateEndpointFunction( + it, + override = false, + includeBody = false, + includeKdoc = options.generateKdoc, + ) + }, + ) + } + val interfaceFile = FileSpec + .builder(interfaceClass) + .addType(interfaceBuilder.build()) + .build() + + return listOf(interfaceFile, classFile) } private fun buildApplyAuth( @@ -220,8 +266,21 @@ internal object ClientGenerator { return builder.build() } - context(_: Hierarchy, options: OutputOptions, methodRegistry: NameRegistry) - private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { + /** + * Builds a suspend function for an endpoint. + * + * @param override emit an `override` modifier (the client implements an interface) + * @param includeBody emit the request-building body; when false the function is abstract + * (used for interface members) + * @param includeKdoc emit KDoc on the function + */ + context(_: Hierarchy, _: OutputOptions, methodRegistry: NameRegistry) + private fun generateEndpointFunction( + endpoint: Endpoint, + override: Boolean, + includeBody: Boolean, + includeKdoc: Boolean, + ): FunSpec { val functionName = methodRegistry.register(endpoint.operationId.toCamelCase()) val returnBodyType = resolveReturnType(endpoint) val errorType = resolveErrorType(endpoint) @@ -230,6 +289,7 @@ internal object ClientGenerator { val funBuilder = FunSpec .builder(functionName) .addModifiers(KModifier.SUSPEND) + .apply { if (override) addModifiers(KModifier.OVERRIDE) } .returns(returnType) val params = endpoint.parameters.groupBy { it.location } @@ -246,13 +306,15 @@ internal object ClientGenerator { buildNullableParameter(param.schema, param.name, param.required) } - funBuilder.addParameters(pathParams + queryParams + headerParams) + val bodyParams = endpoint.requestBody?.let { buildBodyParams(it) }.orEmpty() + val allParams = pathParams + queryParams + headerParams + bodyParams - if (endpoint.requestBody != null) { - funBuilder.addParameters(buildBodyParams(endpoint.requestBody)) - } + // An overriding function may not repeat default values; defaults live on the interface. + funBuilder.addParameters( + if (override) allParams.map { it.toBuilder().defaultValue(null as CodeBlock?).build() } else allParams, + ) - if (options.generateKdoc) { + if (includeKdoc) { val kdocParts = mutableListOf() endpoint.summary?.let { kdocParts.add(it) } endpoint.description?.let { @@ -273,7 +335,12 @@ internal object ClientGenerator { } } - funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + if (includeBody) { + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + } else { + // Interface member: abstract so KotlinPoet omits the body. + funBuilder.addModifiers(KModifier.ABSTRACT) + } return funBuilder.build() } 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 dde029e..11ccbf3 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 @@ -145,6 +145,47 @@ class ClientGeneratorTest { ) } + // -- generateInterfaces -- + + @Test + fun `generateInterfaces false produces only the client class`() { + val files = generate(spec(endpoint(tags = listOf("Pets")))) + val typeNames = files.flatMap { it.members.filterIsInstance() }.map { it.name } + assertEquals(listOf("PetsApi"), typeNames) + } + + @Test + fun `generateInterfaces true produces interface and Impl class`() { + val files = generate( + spec(endpoint(operationId = "listPets", tags = listOf("Pets"))), + options = OutputOptions(generateInterfaces = true), + ) + val types = files.flatMap { it.members.filterIsInstance() }.associateBy { it.name } + + val iface = assertNotNull(types["PetsApi"], "Expected PetsApi interface") + assertEquals(TypeSpec.Kind.INTERFACE, iface.kind) + val ifaceFun = iface.funSpecs.first { it.name == "listPets" } + assertTrue(KModifier.SUSPEND in ifaceFun.modifiers, "Interface method should be suspend") + assertTrue(ifaceFun.body.isEmpty(), "Interface method should have no body") + + val impl = assertNotNull(types["PetsApiImpl"], "Expected PetsApiImpl class") + assertTrue(impl.superinterfaces.keys.any { it.toString().endsWith("PetsApi") }, "Impl should implement PetsApi") + val implFun = impl.funSpecs.first { it.name == "listPets" } + assertTrue(KModifier.OVERRIDE in implFun.modifiers, "Impl method should override") + assertFalse(implFun.body.isEmpty(), "Impl method should have a body") + } + + @Test + fun `generateInterfaces respects custom prefix and suffix`() { + val files = generate( + spec(endpoint(tags = listOf("Pets"))), + options = OutputOptions(apiClassSuffix = "Client", generateInterfaces = true), + ) + val typeNames = files.flatMap { it.members.filterIsInstance() }.mapNotNull { it.name }.toSet() + assertTrue("PetsClient" in typeNames, "Expected interface PetsClient; got $typeNames") + assertTrue("PetsClientImpl" in typeNames, "Expected class PetsClientImpl; got $typeNames") + } + // -- CLNT-02: Endpoint functions are suspend -- @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 c9d9bb9..46e00f3 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -209,6 +209,28 @@ class JustworksPluginFunctionalTest { assertTrue(clientFile.readText().contains("class MyPetsClient"), "Expected class MyPetsClient") } + @Test + fun `generateInterfaces produces an interface implemented by the client`() { + writeBuildFile("generateInterfaces = true") + + runner("compileKotlin").build() + + val apiDir = projectDir.resolve("build/generated/justworks/main/com/example/api") + val interfaceFile = apiDir.resolve("PetsApi.kt") + val implFile = apiDir.resolve("PetsApiImpl.kt") + assertTrue( + interfaceFile.exists(), + "Expected PetsApi.kt interface; files: ${apiDir.listFiles()?.map { it.name }}", + ) + assertTrue(implFile.exists(), "Expected PetsApiImpl.kt; files: ${apiDir.listFiles()?.map { it.name }}") + assertTrue(interfaceFile.readText().contains("interface PetsApi"), "Expected interface PetsApi") + assertTrue(implFile.readText().contains("class PetsApiImpl"), "Expected class PetsApiImpl") + assertTrue( + implFile.readText().contains(": PetsApi") || implFile.readText().contains("PetsApi"), + "Impl implements PetsApi", + ) + } + @Test fun `generateKdoc true by default emits KDoc in generated code`() { writeBuildFile() diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt index 1f3ef25..1a4dc16 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt @@ -51,6 +51,10 @@ abstract class JustworksGenerateTask : DefaultTask() { @get:Input abstract val apiClassSuffix: Property + /** Whether to generate an interface per API tag implemented by the client. */ + @get:Input + abstract val generateInterfaces: Property + /** Output directory for generated Kotlin source files. */ @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -78,6 +82,7 @@ abstract class JustworksGenerateTask : DefaultTask() { generateKdoc = generateKdoc.get(), apiClassPrefix = apiClassPrefix.get(), apiClassSuffix = apiClassSuffix.get(), + generateInterfaces = generateInterfaces.get(), ), ) diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt index 1f47faa..7a6c20f 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt @@ -58,6 +58,7 @@ class JustworksPlugin : Plugin { task.generateKdoc.set(spec.generateKdoc.orElse(true)) task.apiClassPrefix.set(spec.apiClassPrefix.orElse("")) task.apiClassSuffix.set(spec.apiClassSuffix.orElse("Api")) + task.generateInterfaces.set(spec.generateInterfaces.orElse(false)) task.outputDir.set(project.layout.buildDirectory.dir("generated/justworks/${spec.name}")) task.group = "code generation" task.description = "Generate Kotlin client from '${spec.name}' OpenAPI spec" diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt index 5ca290f..c562cc5 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt @@ -64,5 +64,12 @@ abstract class JustworksSpecConfiguration */ abstract val apiClassSuffix: Property + /** + * Whether to generate an interface per API tag that the client class implements. + * Defaults to `false`. When enabled, the interface keeps the configured name + * (e.g. `PetsApi`) and the concrete client gets an `Impl` suffix (`PetsApiImpl`). + */ + abstract val generateInterfaces: Property + override fun getName(): String = name } From 3ba5f50d372928e91aec41023bb5714b5a129d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 9 Jun 2026 17:39:34 +0200 Subject: [PATCH 2/3] refactor(core): simplify ClientGenerator logic for interface and class generation Optimize interface-related logic by removing redundant null checks and replacing them with `takeIf` for cleaner code. Streamline endpoint function generation with direct use of `options.generateInterfaces`. Simplify return logic by using `listOfNotNull`. --- .../core/gen/client/ClientGenerator.kt | 56 ++++++++----------- 1 file changed, 24 insertions(+), 32 deletions(-) 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 e364fde..2ea0334 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 @@ -73,14 +73,9 @@ internal object ClientGenerator { ): List { val baseName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}" - // When interfaces are enabled the public type keeps the base name and the - // concrete client gets an `Impl` suffix; otherwise the client uses the base name. - val interfaceClass = if (options.generateInterfaces) { - ClassName(apiPackage, nameRegistry.register(baseName)) - } else { - null - } - val classSimpleName = if (interfaceClass != null) "${baseName}Impl" else baseName + val interfaceClass = + ClassName(apiPackage, nameRegistry.register(baseName)).takeIf { options.generateInterfaces } + val classSimpleName = if (options.generateInterfaces) "${baseName}Impl" else baseName val className = ClassName(apiPackage, nameRegistry.register(classSimpleName)) val clientInitializer = if (hasPolymorphicTypes) { @@ -152,16 +147,14 @@ internal object ClientGenerator { classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) } - val implementsInterface = interfaceClass != null context(NameRegistry()) { classBuilder.addFunctions( endpoints.map { generateEndpointFunction( it, - override = implementsInterface, + override = options.generateInterfaces, includeBody = true, - // KDoc lives on the interface when one is generated. - includeKdoc = options.generateKdoc && !implementsInterface, + includeKdoc = options.generateKdoc && !options.generateInterfaces, ) }, ) @@ -172,27 +165,27 @@ internal object ClientGenerator { .addType(classBuilder.build()) .build() - if (interfaceClass == null) return listOf(classFile) - - val interfaceBuilder = TypeSpec.interfaceBuilder(interfaceClass) - context(NameRegistry()) { - interfaceBuilder.addFunctions( - endpoints.map { - generateEndpointFunction( - it, - override = false, - includeBody = false, - includeKdoc = options.generateKdoc, - ) - }, - ) + val interfaceFile = interfaceClass?.let { + val interfaceBuilder = TypeSpec.interfaceBuilder(interfaceClass) + context(NameRegistry()) { + interfaceBuilder.addFunctions( + endpoints.map { + generateEndpointFunction( + it, + override = false, + includeBody = false, + includeKdoc = options.generateKdoc, + ) + }, + ) + } + FileSpec + .builder(interfaceClass) + .addType(interfaceBuilder.build()) + .build() } - val interfaceFile = FileSpec - .builder(interfaceClass) - .addType(interfaceBuilder.build()) - .build() - return listOf(interfaceFile, classFile) + return listOfNotNull(interfaceFile, classFile) } private fun buildApplyAuth( @@ -338,7 +331,6 @@ internal object ClientGenerator { if (includeBody) { funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) } else { - // Interface member: abstract so KotlinPoet omits the body. funBuilder.addModifiers(KModifier.ABSTRACT) } From ae414d708da00fc4bcfdb723e16b690c0fda1416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 15:05:07 +0200 Subject: [PATCH 3/3] Merge feat/43 into feat/44; fix eager api-class name registration The interface name was registered via `register(baseName).takeIf { generateInterfaces }`, whose side effect ran even when interfaces were disabled, so the impl class then got a `2`-suffixed name. Register the interface name only when generateInterfaces is set. --- .../com/avsystem/justworks/core/gen/client/ClientGenerator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2ea0334..76cfe86 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 @@ -74,7 +74,7 @@ internal object ClientGenerator { val baseName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}" val interfaceClass = - ClassName(apiPackage, nameRegistry.register(baseName)).takeIf { options.generateInterfaces } + if (options.generateInterfaces) ClassName(apiPackage, nameRegistry.register(baseName)) else null val classSimpleName = if (options.generateInterfaces) "${baseName}Impl" else baseName val className = ClassName(apiPackage, nameRegistry.register(classSimpleName))