Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ data class OutputOptions(
val generateKdoc: Boolean = true,
val apiClassPrefix: String = "",
val apiClassSuffix: String = "Api",
val generateInterfaces: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,25 @@ internal object ClientGenerator {
context(_: Hierarchy, _: OutputOptions, _: ApiPackage, _: NameRegistry)
fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean): List<FileSpec> {
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<Endpoint>,
hasPolymorphicTypes: Boolean,
securitySchemes: List<SecurityScheme>,
specTitle: String,
): FileSpec {
val simpleName = "${options.apiClassPrefix}${tag.toPascalCase()}${options.apiClassSuffix}"
val className = ClassName(apiPackage, nameRegistry.register(simpleName))
): List<FileSpec> {
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)
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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 }
Expand All @@ -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<String>()
endpoint.summary?.let { kdocParts.add(it) }
endpoint.description?.let {
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypeSpec>() }.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<TypeSpec>() }.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<TypeSpec>() }.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ abstract class JustworksGenerateTask : DefaultTask() {
@get:Input
abstract val apiClassSuffix: Property<String>

/** Whether to generate an interface per API tag implemented by the client. */
@get:Input
abstract val generateInterfaces: Property<Boolean>

/** Output directory for generated Kotlin source files. */
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
Expand Down Expand Up @@ -78,6 +82,7 @@ abstract class JustworksGenerateTask : DefaultTask() {
generateKdoc = generateKdoc.get(),
apiClassPrefix = apiClassPrefix.get(),
apiClassSuffix = apiClassSuffix.get(),
generateInterfaces = generateInterfaces.get(),
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class JustworksPlugin : Plugin<Project> {
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,12 @@ abstract class JustworksSpecConfiguration
*/
abstract val apiClassSuffix: Property<String>

/**
* 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<Boolean>

override fun getName(): String = name
}