Skip to content

Commit ac813dd

Browse files
authored
Merge pull request #86 from AVSystem/feat/44-generate-interfaces
feat(plugin): add generateInterfaces option
2 parents 38d12af + 43bee8c commit ac813dd

8 files changed

Lines changed: 190 additions & 33 deletions

File tree

‎README.md‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,16 @@ extra configuration needed.
5252

5353
### Configuration options
5454

55-
| Property | Required | Default | Description |
56-
|----------------|----------|----------------------|----------------------------------------|
57-
| `specFile` | Yes | -- | Path to the OpenAPI spec (.yaml/.json) |
58-
| `packageName` | Yes | -- | Base package for generated code |
59-
| `apiPackage` | No | `$packageName.api` | Package for API client classes |
60-
| `modelPackage` | No | `$packageName.model` | Package for model/data classes |
61-
| `generateKdoc` | No | `true` | Emit KDoc comments in generated code |
62-
| `apiClassPrefix` | No | `""` | Prefix for generated API client class names |
63-
| `apiClassSuffix` | No | `"Api"` | Suffix for generated API client class names |
55+
| Property | Required | Default | Description |
56+
|----------------------|----------|----------------------|-----------------------------------------------------------------------|
57+
| `specFile` | Yes | -- | Path to the OpenAPI spec (.yaml/.json) |
58+
| `packageName` | Yes | -- | Base package for generated code |
59+
| `apiPackage` | No | `$packageName.api` | Package for API client classes |
60+
| `modelPackage` | No | `$packageName.model` | Package for model/data classes |
61+
| `generateKdoc` | No | `true` | Emit KDoc comments in generated code |
62+
| `apiClassPrefix` | No | `""` | Prefix for generated API client class names |
63+
| `apiClassSuffix` | No | `"Api"` | Suffix for generated API client class names |
64+
| `generateInterfaces` | No | `false` | Generate an interface per tag implemented by the client (for mocking) |
6465

6566
## What Gets Generated
6667

@@ -319,6 +320,24 @@ justworks {
319320
}
320321
```
321322

323+
### Interfaces for Testing
324+
325+
Set `generateInterfaces = true` to emit an interface per tag that the client implements. The interface keeps the
326+
configured name (e.g. `PetsApi`) and the concrete client gets an `Impl` suffix (`PetsApiImpl`). Depend on the interface
327+
in your code and supply a hand-written fake in tests — no mocking library required.
328+
329+
```kotlin
330+
justworks {
331+
specs {
332+
register("petstore") {
333+
specFile = file("api/petstore.yaml")
334+
packageName = "com.example.petstore"
335+
generateInterfaces = true // default: false -> PetsApi (interface) + PetsApiImpl (client)
336+
}
337+
}
338+
}
339+
```
340+
322341
### Ktor Engine
323342

324343
The generated `HttpClient` is created without an explicit engine, so Ktor uses whichever engine is on the classpath.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ data class OutputOptions(
1010
val generateKdoc: Boolean = true,
1111
val apiClassPrefix: String = "",
1212
val apiClassSuffix: String = "Api",
13+
val generateInterfaces: Boolean = false,
1314
)

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

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,25 @@ internal object ClientGenerator {
6767
context(_: Hierarchy, _: OutputOptions, _: ApiPackage, _: NameRegistry)
6868
fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean): List<FileSpec> {
6969
val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG }
70-
return grouped.map { (tag, endpoints) ->
71-
generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title)
70+
return grouped.flatMap { (tag, endpoints) ->
71+
generateClientFiles(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title)
7272
}
7373
}
7474

7575
context(hierarchy: Hierarchy, options: OutputOptions, apiPackage: ApiPackage, nameRegistry: NameRegistry)
76-
private fun generateClientFile(
76+
private fun generateClientFiles(
7777
tag: String,
7878
endpoints: List<Endpoint>,
7979
hasPolymorphicTypes: Boolean,
8080
securitySchemes: List<SecurityScheme>,
8181
specTitle: String,
82-
): FileSpec {
83-
val simpleName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}"
84-
val className = ClassName(apiPackage, nameRegistry.register(simpleName))
82+
): List<FileSpec> {
83+
val baseName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}"
84+
85+
val interfaceClass =
86+
if (options.generateInterfaces) ClassName(apiPackage, nameRegistry.register(baseName)) else null
87+
val classSimpleName = if (options.generateInterfaces) "${baseName}Impl" else baseName
88+
val className = ClassName(apiPackage, nameRegistry.register(classSimpleName))
8589

8690
val clientInitializer = CodeBlock.of("${CREATE_HTTP_CLIENT}()")
8791

@@ -97,6 +101,10 @@ internal object ClientGenerator {
97101
.superclass(API_CLIENT_BASE)
98102
.addSuperclassConstructorParameter(BASE_URL)
99103

104+
if (interfaceClass != null) {
105+
classBuilder.addSuperinterface(interfaceClass)
106+
}
107+
100108
if (hasPolymorphicTypes) {
101109
val generatedSerializersModule = MemberName(hierarchy.modelPackage, GENERATED_SERIALIZERS_MODULE)
102110
classBuilder.addSuperclassConstructorParameter(
@@ -153,21 +161,54 @@ internal object ClientGenerator {
153161
}
154162

155163
context(NameRegistry()) {
156-
classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) })
164+
classBuilder.addFunctions(
165+
endpoints.map {
166+
generateEndpointFunction(
167+
it,
168+
override = options.generateInterfaces,
169+
includeBody = true,
170+
includeKdoc = options.generateKdoc && !options.generateInterfaces,
171+
)
172+
},
173+
)
157174
}
158175

159-
val fileBuilder = FileSpec.builder(className).addType(classBuilder.build())
160-
if (endpoints.usesUuid()) {
161-
fileBuilder.addAnnotation(
162-
AnnotationSpec
163-
.builder(OPT_IN)
164-
.addMember("%T::class", EXPERIMENTAL_UUID_API)
165-
.build(),
166-
)
176+
val usesUuid = endpoints.usesUuid()
177+
178+
val classFileBuilder = FileSpec.builder(className).addType(classBuilder.build())
179+
if (usesUuid) {
180+
classFileBuilder.addAnnotation(buildUuidOptInAnnotation())
181+
}
182+
183+
val interfaceFile = interfaceClass?.let {
184+
val interfaceBuilder = TypeSpec.interfaceBuilder(interfaceClass)
185+
context(NameRegistry()) {
186+
interfaceBuilder.addFunctions(
187+
endpoints.map {
188+
generateEndpointFunction(
189+
it,
190+
override = false,
191+
includeBody = false,
192+
includeKdoc = options.generateKdoc,
193+
)
194+
},
195+
)
196+
}
197+
val interfaceFileBuilder = FileSpec.builder(interfaceClass).addType(interfaceBuilder.build())
198+
if (usesUuid) {
199+
interfaceFileBuilder.addAnnotation(buildUuidOptInAnnotation())
200+
}
201+
interfaceFileBuilder.build()
167202
}
168-
return fileBuilder.build()
203+
204+
return listOfNotNull(interfaceFile, classFileBuilder.build())
169205
}
170206

207+
private fun buildUuidOptInAnnotation(): AnnotationSpec = AnnotationSpec
208+
.builder(OPT_IN)
209+
.addMember("%T::class", EXPERIMENTAL_UUID_API)
210+
.build()
211+
171212
// A Uuid-typed path/query/header param, request body, or response schema anywhere in this tag
172213
// group means the generated function signatures reference kotlin.uuid.Uuid directly, which
173214
// requires this file to opt into ExperimentalUuidApi (mirrors ModelGenerator's per-model check).
@@ -251,8 +292,21 @@ internal object ClientGenerator {
251292
return builder.build()
252293
}
253294
254-
context(_: Hierarchy, options: OutputOptions, methodRegistry: NameRegistry)
255-
private fun generateEndpointFunction(endpoint: Endpoint): FunSpec {
295+
/**
296+
* Builds a suspend function for an endpoint.
297+
*
298+
* @param override emit an `override` modifier (the client implements an interface)
299+
* @param includeBody emit the request-building body; when false the function is abstract
300+
* (used for interface members)
301+
* @param includeKdoc emit KDoc on the function
302+
*/
303+
context(_: Hierarchy, _: OutputOptions, methodRegistry: NameRegistry)
304+
private fun generateEndpointFunction(
305+
endpoint: Endpoint,
306+
override: Boolean,
307+
includeBody: Boolean,
308+
includeKdoc: Boolean,
309+
): FunSpec {
256310
val functionName = methodRegistry.register(endpoint.operationId.toCamelCase())
257311
val returnBodyType = resolveReturnType(endpoint)
258312
val responseContentType = resolveSuccessResponse(endpoint)?.contentType
@@ -262,6 +316,7 @@ internal object ClientGenerator {
262316
val funBuilder = FunSpec
263317
.builder(functionName)
264318
.addModifiers(KModifier.SUSPEND)
319+
.apply { if (override) addModifiers(KModifier.OVERRIDE) }
265320
.returns(returnType)
266321
267322
val params = endpoint.parameters.groupBy { it.location }
@@ -278,13 +333,15 @@ internal object ClientGenerator {
278333
buildNullableParameter(param.schema, param.name, param.required)
279334
}
280335
281-
funBuilder.addParameters(pathParams + queryParams + headerParams)
336+
val bodyParams = endpoint.requestBody?.let { buildBodyParams(it) }.orEmpty()
337+
val allParams = pathParams + queryParams + headerParams + bodyParams
282338
283-
if (endpoint.requestBody != null) {
284-
funBuilder.addParameters(buildBodyParams(endpoint.requestBody))
285-
}
339+
// An overriding function may not repeat default values; defaults live on the interface.
340+
funBuilder.addParameters(
341+
if (override) allParams.map { it.toBuilder().defaultValue(null as CodeBlock?).build() } else allParams,
342+
)
286343
287-
if (options.generateKdoc) {
344+
if (includeKdoc) {
288345
val kdocParts = mutableListOf<String>()
289346
endpoint.summary?.let { kdocParts.add(it) }
290347
endpoint.description?.let {
@@ -305,7 +362,11 @@ internal object ClientGenerator {
305362
}
306363
}
307364
308-
funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType, responseContentType))
365+
if (includeBody) {
366+
funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType, responseContentType))
367+
} else {
368+
funBuilder.addModifiers(KModifier.ABSTRACT)
369+
}
309370
310371
return funBuilder.build()
311372
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,47 @@ class ClientGeneratorTest {
145145
)
146146
}
147147

148+
// -- generateInterfaces --
149+
150+
@Test
151+
fun `generateInterfaces false produces only the client class`() {
152+
val files = generate(spec(endpoint(tags = listOf("Pets"))))
153+
val typeNames = files.flatMap { it.members.filterIsInstance<TypeSpec>() }.map { it.name }
154+
assertEquals(listOf("PetsApi"), typeNames)
155+
}
156+
157+
@Test
158+
fun `generateInterfaces true produces interface and Impl class`() {
159+
val files = generate(
160+
spec(endpoint(operationId = "listPets", tags = listOf("Pets"))),
161+
options = OutputOptions(generateInterfaces = true),
162+
)
163+
val types = files.flatMap { it.members.filterIsInstance<TypeSpec>() }.associateBy { it.name }
164+
165+
val iface = assertNotNull(types["PetsApi"], "Expected PetsApi interface")
166+
assertEquals(TypeSpec.Kind.INTERFACE, iface.kind)
167+
val ifaceFun = iface.funSpecs.first { it.name == "listPets" }
168+
assertTrue(KModifier.SUSPEND in ifaceFun.modifiers, "Interface method should be suspend")
169+
assertTrue(ifaceFun.body.isEmpty(), "Interface method should have no body")
170+
171+
val impl = assertNotNull(types["PetsApiImpl"], "Expected PetsApiImpl class")
172+
assertTrue(impl.superinterfaces.keys.any { it.toString().endsWith("PetsApi") }, "Impl should implement PetsApi")
173+
val implFun = impl.funSpecs.first { it.name == "listPets" }
174+
assertTrue(KModifier.OVERRIDE in implFun.modifiers, "Impl method should override")
175+
assertFalse(implFun.body.isEmpty(), "Impl method should have a body")
176+
}
177+
178+
@Test
179+
fun `generateInterfaces respects custom prefix and suffix`() {
180+
val files = generate(
181+
spec(endpoint(tags = listOf("Pets"))),
182+
options = OutputOptions(apiClassSuffix = "Client", generateInterfaces = true),
183+
)
184+
val typeNames = files.flatMap { it.members.filterIsInstance<TypeSpec>() }.mapNotNull { it.name }.toSet()
185+
assertTrue("PetsClient" in typeNames, "Expected interface PetsClient; got $typeNames")
186+
assertTrue("PetsClientImpl" in typeNames, "Expected class PetsClientImpl; got $typeNames")
187+
}
188+
148189
// -- CLNT-02: Endpoint functions are suspend --
149190

150191
@Test

‎plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,28 @@ class JustworksPluginFunctionalTest {
323323
assertTrue(clientFile.readText().contains("class MyPetsClient"), "Expected class MyPetsClient")
324324
}
325325

326+
@Test
327+
fun `generateInterfaces produces an interface implemented by the client`() {
328+
writeBuildFile("generateInterfaces = true")
329+
330+
runner("compileKotlin").build()
331+
332+
val apiDir = projectDir.resolve("build/generated/justworks/main/com/example/api")
333+
val interfaceFile = apiDir.resolve("PetsApi.kt")
334+
val implFile = apiDir.resolve("PetsApiImpl.kt")
335+
assertTrue(
336+
interfaceFile.exists(),
337+
"Expected PetsApi.kt interface; files: ${apiDir.listFiles()?.map { it.name }}",
338+
)
339+
assertTrue(implFile.exists(), "Expected PetsApiImpl.kt; files: ${apiDir.listFiles()?.map { it.name }}")
340+
assertTrue(interfaceFile.readText().contains("interface PetsApi"), "Expected interface PetsApi")
341+
assertTrue(implFile.readText().contains("class PetsApiImpl"), "Expected class PetsApiImpl")
342+
assertTrue(
343+
implFile.readText().contains(": PetsApi") || implFile.readText().contains("PetsApi"),
344+
"Impl implements PetsApi",
345+
)
346+
}
347+
326348
@Test
327349
fun `generateKdoc true by default emits KDoc in generated code`() {
328350
writeBuildFile()

‎plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ abstract class JustworksGenerateTask : DefaultTask() {
5151
@get:Input
5252
abstract val apiClassSuffix: Property<String>
5353

54+
/** Whether to generate an interface per API tag implemented by the client. */
55+
@get:Input
56+
abstract val generateInterfaces: Property<Boolean>
57+
5458
/** Output directory for generated Kotlin source files. */
5559
@get:OutputDirectory
5660
abstract val outputDir: DirectoryProperty
@@ -78,6 +82,7 @@ abstract class JustworksGenerateTask : DefaultTask() {
7882
generateKdoc = generateKdoc.get(),
7983
apiClassPrefix = apiClassPrefix.get(),
8084
apiClassSuffix = apiClassSuffix.get(),
85+
generateInterfaces = generateInterfaces.get(),
8186
),
8287
)
8388

‎plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class JustworksPlugin : Plugin<Project> {
5858
task.generateKdoc.set(spec.generateKdoc.orElse(true))
5959
task.apiClassPrefix.set(spec.apiClassPrefix.orElse(""))
6060
task.apiClassSuffix.set(spec.apiClassSuffix.orElse("Api"))
61+
task.generateInterfaces.set(spec.generateInterfaces.orElse(false))
6162
task.outputDir.set(project.layout.buildDirectory.dir("generated/justworks/${spec.name}"))
6263
task.group = "code generation"
6364
task.description = "Generate Kotlin client from '${spec.name}' OpenAPI spec"

‎plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,12 @@ abstract class JustworksSpecConfiguration
6464
*/
6565
abstract val apiClassSuffix: Property<String>
6666

67+
/**
68+
* Whether to generate an interface per API tag that the client class implements.
69+
* Defaults to `false`. When enabled, the interface keeps the configured name
70+
* (e.g. `PetsApi`) and the concrete client gets an `Impl` suffix (`PetsApiImpl`).
71+
*/
72+
abstract val generateInterfaces: Property<Boolean>
73+
6774
override fun getName(): String = name
6875
}

0 commit comments

Comments
 (0)