- JDK 17+
- Gradle 9+
# MANDATORY after every code change:
./gradlew formatKotlin && ./gradlew check
# Run specific tests:
./gradlew :generator:test
./gradlew :generator:test --tests "*.ParsingTests"
# E2E testing (separate projects):
./gradlew publishToMavenLocal
cd e2e && ./gradlew build
# Split-by-client E2E:
cd e2e-split && ./gradlew initApiClientSubproject -PopenApiFile=src/main/openapi/sample.json -PsplitByClient=true -PbasePackage=org.litote.sample -PsplitGranularity=BY_TAG_AND_OPERATION -PsharedModelGranularity=SHARED_PER_GROUP -PsubprojectRootDirectory=client
cd e2e-split && ./gradlew build
# Debug:
./gradlew build --infoFollow official kotlin conventions
All commits merged into main must be signed. The repository enforces this via branch protection rules.
See Signing commits
All PR titles must follow the Conventional Commits specification.
This is enforced automatically via amannn/action-semantic-pull-request in ci.yml.
PR branches should mirror the PR title using the same Conventional Commits type as prefix:
<type>/<short-kebab-description>
# Examples:
feat/yaml-content-type-support
fix/enum-null-value-parsing
chore/update-ktor-version
docs/contributing-branch-naming
refactor/split-renderer-module
Use the same types as for PR titles (feat, fix, perf, chore, docs, test, refactor, ci).
The description should be short, lowercase, and hyphen-separated.
| Type | Version bump | When to use |
|---|---|---|
feat: |
Minor (0.3.0 → 0.4.0) |
New user-facing feature |
fix: |
Patch (0.3.0 → 0.3.1) |
Bug fix |
perf: |
Patch | Performance improvement |
feat!: / BREAKING CHANGE: |
Major (0.3.0 → 1.0.0) |
Breaking API change |
chore:, docs:, test:, refactor:, ci: |
No release | Internal changes |
Note: While the major version is
0,feat:commits bump the minor version (not major). This is controlled bybump-minor-pre-major: trueinrelease-please-config.json.
Each Gradle module defines its own root package. Following the Kotlin recommendation, the module's root package is the common root package and is omitted from the directory path — all source files live directly under src/main/kotlin/.
| Module | Root package | Source files location |
|---|---|---|
generator:domain |
org.litote.openapi.ktor.client.generator.domain |
src/main/kotlin/ |
generator:port |
org.litote.openapi.ktor.client.generator.port |
src/main/kotlin/ |
generator:adapter-renderer |
org.litote.openapi.ktor.client.generator.adapter.renderer |
src/main/kotlin/ |
gradle-plugin |
org.litote.openapi.ktor.client.generator.plugin |
src/main/kotlin/ |
shared |
org.litote.openapi.ktor.client.generator.shared |
src/main/kotlin/ |
Sub-packages within a module are reflected as subdirectories only if the module itself contains files from multiple packages.
Choose good names for classes
- do not suffix names with
ImplorImplBase, orUtil - use
Apiprefix for interfaces - use
Clientprefix for client classes - use
Configurationprefix for configuration classes - use
Specprefix for domain types - use
Buildersuffix for builders - use
Convertersuffix for converters - use
Generatorsuffix for generators - use
Parsersuffix for parsers - use
Renderersuffix for renderers - for Util classes prefer
Files.kttoFileUtils.kt
If you use Spec suffix for domain types, use it for all domain types
shared/ → Shared abstractions (utilities)
generator/ → Composition root — wires all sub-modules together
generator:domain → Pure domain model (zero external dependencies)
generator:port → Port interfaces (inward & outward contracts)
generator:config → Public API types (ApiGeneratorConfiguration, ApiGeneratorModule, GenerationResult)
generator:application → Orchestration services (no adapter imports)
generator:adapter-writer → File system writer adapter
generator:adapter-parser → OpenAPI specification parser adapter
generator:adapter-renderer → Kotlin/KotlinPoet code renderer adapter
gradle-plugin/ → Gradle integration (GeneratorPlugin, tasks)
module/unknown-enum-value/ → Handles unmapped enum values
module/logging-sl4j/ → SLF4J logging in generated clients
module/logging-kotlin/ → kotlin-logging (oshai) logging in generated clients
convention/ → Build convention plugins
e2e/ → End-to-end tests (separate Gradle project)
e2e-split/ → End-to-end KMP and project generation tests (separate Gradle project)
The generator module follows a hexagonal architecture (ports & adapters) enforced by
Gradle sub-module boundaries.
graph TD
shared[":shared"]
domain["generator:domain"]
port["generator:port"]
config["generator:config"]
app["generator:application"]
writer["generator:adapter-writer"]
parser["generator:adapter-parser"]
renderer["generator:adapter-renderer"]
root["generator (root)"]
domain --> shared
port --> domain
config --> domain
config --> port
app --> domain
app --> port
writer --> domain
writer --> port
parser --> domain
parser --> port
parser --> config
renderer --> domain
renderer --> port
renderer --> config
renderer --> writer
root --> config
root --> app
root --> parser
root --> renderer
root --> writer
Boundary Rules:
- Do NOT move classes between modules
- Do NOT introduce cross-module circular dependencies
- Respect the Gradle dependency graph above — violations cause compile errors
| Sub-module | Root package | Key classes |
|---|---|---|
generator:domain |
*.domain |
GenerationSpec, ClientSpec, ModelSpec, all domain *Spec types |
generator:port |
*.port |
ApiSpecificationParser, ApiClientRenderer, ApiModelRenderer, ApiConfigurationRenderer, ApiFileSystemWriter, Api*GeneratorConfig interfaces |
generator:config |
*.generator |
ApiGeneratorConfiguration, ApiGeneratorModule, GenerationResult, SplitGranularity, SharedModelGranularity |
generator:application |
*.application |
GenerateCodeService, GenerationSpecPartitioner |
generator:adapter-writer |
*.adapter.writer |
KotlinPoetFileWriter |
generator:adapter-parser |
*.adapter.parser |
OpenApiSpecificationParser, ApiModel, TypeNameConverter, helpers |
generator:adapter-renderer |
*.adapter.renderer |
ApiClientGenerator, ApiModelGenerator, ApiClientConfigurationGenerator, builders, helpers |
generator (root) |
*.generator |
ApiGenerator.kt — the only file that imports all layers |
generator:domaincompiles with zero KotlinPoet / OpenAPI bindings / Ktor / I/O dependenciesgenerator:portdepends only ongenerator:domain— noApiGeneratorConfigurationin port interfacesgenerator:applicationcannot see adapter classes (not in its dependency graph)generator:adapter-parsercannot see renderer code (nogenerator:adapter-rendererdep)generator:adapter-renderercannot see parser code (nogenerator:adapter-parserdep)- The composition root
generatoris the only module that can wire all layers together
ApiSpecificationParser.parse(operationFilter) receives only a domain-typed filter.
The full ApiGeneratorConfiguration is injected into OpenApiSpecificationParser at
construction time in the composition root, keeping the port free of config types.
ApiGenerator.kt (root)
└─► OpenApiSpecificationParser(configuration) ← adapter-parser
.parse(configuration.operationFilter) ← port method (no ApiGeneratorConfiguration here)
| Component | Responsibility |
|---|---|
ApiClientConfigurationGenerator |
Generates ClientConfiguration.kt and (if YAML) YamlContentConverter.kt |
YamlContentConverterGenerator |
Generates YamlContentConverter.kt — bridges YAML ↔ JSON via SnakeYAML |
OperationBuilder |
Builds per-operation methods with correct contentType() headers and multipart form encoding |
When a schema uses allOf containing $ref entries, the parser resolves the referenced schemas
and merges their properties into the current schema (property flattening):
"TextStatus": {
"allOf": [
{ "$ref": "#/components/schemas/BaseStatus" },
{ "type": "object", "required": ["status"], "properties": { "status": { "type": "string" } } }
]
}Generates a TextStatus data class that includes both its own properties and all properties
from BaseStatus. If TextStatus is also a sealed class subtype (via a oneOf request body),
it still extends the sealed parent — Kotlin single-inheritance is respected via flattening rather
than class inheritance from the $ref target.
A schema referenced exclusively via allOf in other schemas (never as a standalone type, property, oneOf member,
or request/response body) is generated as a Kotlin interface instead of a data class.
This preserves the composition relationship while allowing implementing classes to also extend a sealed class from oneOf.
- Detection:
ApiModel.allOfOnlySchemas(set difference of allOf refs minus direct refs) - Domain:
ModelSpec.InterfaceSpec - Renderer:
ApiModelGenerator.buildInterfaceClass - Implementing classes gain
interfaceParentNames, and inherited properties getisOverride = true
Example result for BaseStatus (allOf-only) and TextStatus:
public interface BaseStatus { val language: String?; val sensitive: Boolean? }
@Serializable
public data class TextStatus(
override val language: String? = null,
override val sensitive: Boolean? = false,
val status: String,
) : CreateStatusRequest(), BaseStatusWhen an operation response body contains an inline oneOf with 2+ $ref entries, the generator synthesises
a virtual sealed class {OperationId}Response:
- The sealed class is created, and each
$refsubtype extends it - A
JsonContentPolymorphicSerializercompanion object is generated to select the correct subtype at runtime by inspecting which required JSON properties are present
ApiModel.responseSealedParents scans all operations and collects response bodies with inline oneOf with 2+ refs.
The map key is the synthesised sealed class name (e.g. createStatus → CreateStatusResponse).
The companion selectDeserializer checks subtypes in descending order of required property count.
The first subtype whose entire required list is present in the JSON keys is selected; the last subtype is the fallback:
@Serializable(with = CreateStatusResponse.Companion::class)
sealed class CreateStatusResponse {
companion object : JsonContentPolymorphicSerializer<CreateStatusResponse>(CreateStatusResponse::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<CreateStatusResponse> {
val keys = (element as? JsonObject)?.keys ?: emptySet()
return when {
listOf("account", "content", ...).all { it in keys } -> Status.serializer()
else -> ScheduledStatus.serializer()
}
}
}
}SubtypeHint(subtypeName, requiredSerialNames)— carries required property names for each subtypeModelSpec.SealedClassSpec.subtypeHints: List<SubtypeHint>?— when non-null, triggers companion generation
When an OpenAPI spec contains application/yaml or application/x-yaml content types (in request bodies or responses),
the generator automatically:
- Sets
ClientConfigurationSpec.hasYamlContentType = true(detected inOpenApiSpecificationParser) - Generates
YamlContentConverter.ktin the client package (YamlContentConverterGenerator) - Registers the converter in
ContentNegotiationfor both YAML content types (ApiClientConfigurationGenerator) - Sets
contentType(ContentType("application", "yaml"))on operations with YAML request bodies (OperationBuilder)
The YamlContentConverter bridges YAML ↔ kotlinx.serialization via SnakeYAML:
- Deserialize: YAML bytes → SnakeYAML
Map/List→JsonElement→kotlinx.serialization - Serialize:
kotlinx.serializationJSON string → SnakeYAML → YAML bytes
Users must add org.yaml:snakeyaml to their project dependencies when YAML endpoints are used.
ALWAYS use version catalog for dependencies version management.
# 1. Update version catalog to latest available versions
./gradlew versionCatalogUpdate
# 2. Regenerate dependency verification metadata (MANDATORY after any dependency change)
./gradlew updateVerificationMetadata
⚠️ Always runupdateVerificationMetadataafter any dependency upgrade. Skipping this step causesDependency verification failederrors in IntelliJ and for other contributors.
The updateVerificationMetadata task rewrites gradle/verification-metadata.xml with fresh SHA-256
checksums for all resolved artifacts. It preserves the trusted-artifacts rules (sources JARs,
javadoc JARs, .pom, .module files) which are IDE-only and exempt from checksum verification.
Three GitHub Actions workflows are defined under .github/workflows/:
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
Pull request → main |
PR title (Conventional Commits), runs tests + Jacoco + SonarCloud PR analysis + quality gate |
ci.yml |
Push → main |
Same as above (branch analysis) + deploys SNAPSHOT to Maven Central |
release-please.yml |
Push → main |
Runs release-please: creates/updates the Release PR (CHANGELOG + manifest bump), then creates the GitHub Release + tag when the Release PR is merged. On release created, chains to the publish job |
release-please.yml (publish job) |
After release-please creates a release (or workflow_dispatch) |
Checks out tag, sets VERSION_NAME locally, runs full QA, publishes to Maven Central + Gradle Plugin Portal (no commit) |
feat/fix commit merged → main
↓
release-please.yml analyses commits
↓
Creates / updates the "Release PR"
(CHANGELOG.md update + manifest bump)
↓
Release PR merged
↓
release-please creates tag vX.Y.Z + GitHub Release
↓
publish job triggers (release_created=true)
→ Checks out tag, sets VERSION_NAME=X.Y.Z locally
→ Full QA → Maven Central → Gradle Plugin Portal
(no commit — version lives only in the tag)
- Artifact signing (Maven Central): uses in-memory GPG signing in CI via
ORG_GRADLE_PROJECT_signingInMemoryKey*env vars. Locally, usesgpg --use-agent(useGpgCmd()). Convention:convention/src/main/kotlin/kotlin-convention.gradle.kts.
GitHub Secrets are always uppercased by GitHub. The workflow YAML maps each secret to the exact env var name expected by Gradle/vanniktech.
| GitHub Secret (what you type) | Env var injected by workflow | Used by |
|---|---|---|
SONAR_TOKEN |
SONAR_TOKEN |
ci, snapshot, release |
MAVEN_CENTRAL_USERNAME |
ORG_GRADLE_PROJECT_mavenCentralUsername |
snapshot, release |
MAVEN_CENTRAL_PASSWORD |
ORG_GRADLE_PROJECT_mavenCentralPassword |
snapshot, release |
SIGNING_IN_MEMORY_KEY |
ORG_GRADLE_PROJECT_signingInMemoryKey |
snapshot, release (key 1 — release@litote.org) |
SIGNING_IN_MEMORY_KEY_ID |
ORG_GRADLE_PROJECT_signingInMemoryKeyId |
snapshot, release |
SIGNING_IN_MEMORY_KEY_PASSWORD |
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword |
snapshot, release |
GRADLE_PUBLISH_KEY |
-Pgradle.publish.key |
release |
GRADLE_PUBLISH_SECRET |
-Pgradle.publish.secret |
release |
RELEASE_PLEASE_APP_ID |
GitHub App ID | release-please |
RELEASE_PLEASE_APP_PRIVATE_KEY |
GitHub App private key | release-please |
release-please requires a GitHub App so that the publish job can be triggered reliably (GitHub's loop prevention blocks GITHUB_TOKEN-created events from triggering workflows).
- Create a GitHub App with permissions: Contents (read & write), Pull requests (read & write)
- Install it on the repository
- Add
RELEASE_PLEASE_APP_IDandRELEASE_PLEASE_APP_PRIVATE_KEYas repository secrets
Releases are fully automated. Merge PRs with Conventional Commits titles to main.
release-please accumulates commits and creates a Release PR. Merge the Release PR to trigger the release.