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..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 @@ -58,21 +58,25 @@ 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}" + + val interfaceClass = + 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)) val clientInitializer = if (hasPolymorphicTypes) { val generatedSerializersModule = MemberName(hierarchy.modelPackage, GENERATED_SERIALIZERS_MODULE) @@ -93,6 +97,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) @@ -140,13 +148,44 @@ internal object ClientGenerator { } context(NameRegistry()) { - classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) + classBuilder.addFunctions( + endpoints.map { + generateEndpointFunction( + it, + override = options.generateInterfaces, + includeBody = true, + includeKdoc = options.generateKdoc && !options.generateInterfaces, + ) + }, + ) } - return FileSpec + val classFile = FileSpec .builder(className) .addType(classBuilder.build()) .build() + + 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() + } + + return listOfNotNull(interfaceFile, classFile) } private fun buildApplyAuth( @@ -220,8 +259,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 +282,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 +299,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 +328,11 @@ internal object ClientGenerator { } } - funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + if (includeBody) { + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + } else { + 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 62b4ff4..de2d8c7 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -203,6 +203,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 }