kotlin_ktor_server_generator
Generates Ktor server routing for an OpenAPI spec: one @Serializable data
class per schema, and per tag a routing class plus a handler interface you implement.
The routing class takes a io.ktor.server.routing.Route in its constructor (so you control mount
path, prefix and interceptors) and an implementation of the generated handler interface. It never
picks an engine or creates the server itself, so it works unchanged on every platform Ktor's
server supports (JVM, Native).
Incoming requests are parsed and validated - path/query/header/cookie parameters and, for bodies whose
schema has constraints, the deserialized body - before the handler is called, so handler
implementations only ever see clean, typed, already-valid Kotlin values. All constraint-checking
logic lives once in the generated Validation.kt and is called from every operation/model instead
of being duplicated per handler.
Usage
openapi-yagen g -o out -g kotlin_ktor_server_generator openapi.yaml \
-v packageName=com.example.petstore
| Variable | Required | Description |
|---|---|---|
packageName | yes | Kotlin package for the generated classes (e.g. com.example.petstore) |
strict | no (default true) | true: an unsupported schema/operation aborts generation with an error. false: skip it with a printed warning and generate everything else - useful for large real-world specs (see "Known limitations" below). |
generate | no (default all) | all: models plus routes/handlers. models: only models/<Name>.kt. api: everything except models/<Name>.kt - see "Sharing models" below. |
publishOpenApiSpec | no (default false) | true: generates OpenApiSpecRoute.kt exposing an OpenApiSpecRoute class that serves the effective OpenAPI document (the one actually used to generate this code, after $ref resolution, --tags filtering, and conversion to this generator's OpenAPI 3.2) as JSON at openApiSpecPath. Ignored when generate is models. |
openApiSpecPath | no (default /openapi.json) | Only used when publishOpenApiSpec is true: the route path the spec is served at. |
Output layout
models/<Name>.kt one file per schema
apis/<Tag>Handler.kt interface you implement with your business logic
apis/<Tag>Routes.kt class that wires a Route to a <Tag>Handler
Validation.kt shared parameter-extraction/constraint-checking helpers, rendered once
Binary.kt BinaryBody/BinaryContent for raw request/response bodies, rendered once
ModelValidation.kt validate() extension for every object model, rendered once
OpenApiSpecRoute.kt only if publishOpenApiSpec=true - see "Publishing the OpenAPI spec"
Written flat, not nested under a packageName-derived directory - unlike Java, Kotlin's compiler
doesn't require a file's physical location to mirror its package declaration, and wherever -o
points already lives inside whatever package-derived source tree you're integrating into, so
another nested layer here would just be redundant. packageName is a base package: models/
is generated into packageName.models, apis/ into packageName.apis; Validation.kt and
ModelValidation.kt and Binary.kt stay at packageName itself.
Sharing models with the client generator
models/*.kt is byte-for-byte the same output whether it comes from this generator or from
kotlin_ktor_client_generator - both generate into
the same packageName.models sub-package and use the same template for every model kind.
ModelValidation.kt's .validate() extensions (needed by the generated routes) are
kept in their own file specifically so models/*.kt stays portable: unlike Validation.kt, the
model files themselves never import io.ktor.server.*, so they're safe to compile into a
multiplatform client target too.
In a Kotlin Multiplatform monorepo, generate the models once into a shared module both the
client and server modules depend on, and skip regenerating them on either side with -v generate=api:
# shared module - models only (either generator works; the client's is shown as the more obviously
# "portable" choice to standardize on)
openapi-yagen g -o shared -g kotlin_ktor_client_generator openapi.yaml \
-v packageName=com.example.petstore -v generate=models
# server module - routes/handlers/Validation.kt/ModelValidation.kt, no models/
openapi-yagen g -o server -g kotlin_ktor_server_generator openapi.yaml \
-v packageName=com.example.petstore -v generate=api
# client module - API classes/bundle/QueryUtils.kt, no models/
openapi-yagen g -o client -g kotlin_ktor_client_generator openapi.yaml \
-v packageName=com.example.petstore -v generate=api
All three must share the same packageName (and dateTimeType, if non-default) so the types
server/client reference resolve to the shared module's classes.
Integrating the generated code
Import statements omitted below for brevity - PetsApiHandler/PetsApiRoutes live in
packageName.apis, Pet/Pets in packageName.models.
class PetsService : PetsApiHandler {
override suspend fun listPets(limit: Int?): Pets = TODO()
override suspend fun createPets(body: Pet) { TODO() }
override suspend fun showPetById(petId: String): Pet = TODO()
}
fun Application.module() {
install(ContentNegotiation) { json() }
// Map validation failures (BadRequestException/MissingRequestParameterException, both
// thrown by Validation.kt - including a non-numeric value for an integer/number parameter,
// which Validation.kt itself wraps as a BadRequestException) to HTTP 400, and missing
// securityScheme credentials (MissingAuthenticationException, also thrown by Validation.kt's
// requireBearerToken/requireApiKey) to HTTP 401:
install(StatusPages) {
exception<BadRequestException> { call, e -> call.respondText(e.message ?: "Bad request", status = HttpStatusCode.BadRequest) }
exception<MissingAuthenticationException> { call, e -> call.respondText(e.message ?: "Unauthorized", status = HttpStatusCode.Unauthorized) }
}
routing {
PetsApiRoutes(this, PetsService())
}
}
Add these dependencies to the module the generated code lives in: io.ktor:ktor-server-core,
org.jetbrains.kotlinx:kotlinx-serialization-json, and org.jetbrains.kotlinx:kotlinx-datetime
(only needed if the spec uses date/date-time formats). Add io.ktor:ktor-server-status-pages
if you use the StatusPages mapping shown above (recommended). If the spec has raw (binary)
responses, also install PartialContent (io.ktor:ktor-server-partial-content, for Range
requests) and ConditionalHeaders (io.ktor:ktor-server-conditional-headers, for
If-Modified-Since) - see "Raw response bodies".
format: date-time properties/parameters generate kotlinx.datetime.Instant by default, which
needs kotlinx-datetime < 0.7.0 - that release moved Instant to be an alias for
kotlin.time.Instant (Kotlin's own stdlib Instant, stable since Kotlin 2.1). If your project is
on kotlinx-datetime >= 0.7.0, generate with -v dateTimeType=kotlin.time.Instant instead; pass
-v dateTimeType=String to skip the kotlinx-datetime dependency for date-time fields entirely
(format: date always generates kotlinx.datetime.LocalDate, unaffected by this setting) - a
malformed date/date-time value is already rejected by kotlinx.serialization's own decoding
(as a 400 via the same IllegalArgumentException -> BadRequestException path every enum/numeric
parse failure already goes through), no separate Validate() check needed. format: uuid stays a
plain String (no dependency-free stdlib UUID type) - a struct property (not a path/query/header
parameter) with it is shape-checked by Validate(): the canonical 8-4-4-4-12 hyphenated hex form.
An optional property's default schema keyword becomes a Kotlin constructor default parameter
literal (e.g. val priority: Int? = 1) instead of the usual = null - kotlinx.serialization
already applies a constructor default when the JSON key is absent, and does NOT apply it for an
explicit JSON null ({"priority": null} still decodes to priority = null, not 1), so no
custom (de)serializer is needed for this "absent vs. explicit null" distinction, unlike the Go
generator's equivalent. A default on a required property, or one whose value doesn't map to a
recognized literal shape (an object/array default), is ignored - the property keeps its ordinary
required/nullable handling.
Request body content types
Request-body content types are picked from whichever the spec declares in priority order
application/json > multipart/form-data > application/x-www-form-urlencoded > (a single
remaining media type, received as a String or a raw BinaryBody):
-
application/json(default):call.receive<Body>(), validated the same way as always - the handler method'sbody:parameter is the generated@Serializabledata class. -
application/x-www-form-urlencoded:call.receiveParameters(), then one field pulled/ converted/validated per property (Parameters.requireParamAs/paramAs/requireParamListAs/paramListAsinValidation.kt- the same conversion/error-handling machinery query parameters already use) into that same generated data class - the handler method's signature is unaffected by which of these two content types the spec declares. The schema must betype: objectwith only scalar/enum properties, or arrays of either (one repeatedname=key per element, OpenAPI's defaultstyle: form, explode: true- same convention an array-typed query parameter already follows); a nested object property, or an array of non-scalar items, is a generator error - see "Known limitations". -
multipart/form-data:call.receiveMultipart().collect()(aValidation.kthelper that drains the streamingMultiPartDataexactly once viaforEachPart, disposing each part as it's read, into a small by-name index), then one field pulled/converted/validated per property into that same generated data class, viaMultipartFields.requireFormFieldAs/formFieldAs(scalar/enum),requireFormFieldListAs/formFieldListAs(array), orrequireFormFileAs/formFileAs(atype: string, format: binaryproperty - mapped to a real KotlinByteArray, read from the part's raw bytes, not a text field). Same schema shape as urlencoded above. -
any single
text/*media type (text/plain,text/csv,text/html, ...):call.receive< String>(), the handler method'sbody:parameter is a plainString- Ktor receives aStringbody as-is, noContentNegotiationplugin needed. -
any single other remaining media type (
application/octet-stream,application/zip,application/pdf,image/png, ...): the handler method'sbody:parameter is aBinaryBody- see "Raw request bodies" below. This only applies to aformat: binaryschema used as the ENTIRE request body (not as one property of a multipart/urlencoded object) - the wire content-type alone decidesStringvs. raw here, same as it does at runtime for a real client/server.A requestBody declaring two or more media types outside the three fixed ones above is ambiguous (which one would the generated handler actually expect?) and is a generator error, same as any other unsupported content-type - see "Known limitations".
Raw request bodies
A raw body reaches the handler as a BinaryBody - the request body still unread, so each
handler implementation decides per operation whether to buffer it or stream it:
// Small payload: just take the bytes (optionally capped - past the cap, 413).
override suspend fun uploadPetAvatar(petId: String, body: BinaryBody) {
avatars.save(petId, body.bytes(maxBytes = 5 * 1024 * 1024))
}
// Large payload: stream it without buffering.
override suspend fun restoreBackup(body: BinaryBody) {
body.channel().copyTo(storage.openWriteChannel())
}
BinaryBody also exposes contentType (the request's media type, without parameters),
contentLength (null if unknown) and the request's headers. Read it at most once.
Before the handler runs, the request's Content-Type is checked against the declared media type
(which may be a wildcard like application/*); a mismatch throws Ktor's
UnsupportedMediaTypeException (415 with Ktor's default exception handling), and bytes(maxBytes)
throws PayloadTooLargeException (413). A body sent without any Content-Type is treated as
application/octet-stream (RFC 9110). An empty body for a required requestBody throws
BadRequestException (400); for an optional one, body is null.
Response content types
A success response's content type follows the same policy: application/json (default) is sent
via the ordinary call.respond(status, result); a single text/* media type is sent via
call.respondText(result, ContentType.parse("..."), status) with the handler returning a String,
and a single other media type is a BinaryContent (below). The exact declared media type is set as
the response's Content-Type header (e.g. text/csv, not a generic text/plain). More than one
remaining media type on a response is a generator error too.
Raw response bodies
// In memory:
return BinaryContent(pngBytes)
// Streamed (JVM example), with a filename and modification time:
return BinaryContent(file.length(), name = "backup.tar.gz", lastModified = GMTDate(file.lastModified())) {
file.readChannel()
}
name is sent as Content-Disposition: attachment; filename="...", contentType overrides the
declared media type (a declared wildcard such as application/* is otherwise sent as
application/octet-stream). With Ktor's PartialContent plugin installed, a Range request
against a 200 response whose length is known (always true for in-memory bytes) is answered with
206 Partial Content automatically - so a 206 response declared with the same media type as the
200 is not a separate response the handler returns. With ConditionalHeaders installed,
lastModified also answers If-Modified-Since with 304 Not Modified.
Multiple success responses
An operation declaring two or more 2xx responses (e.g. 200 for a synchronous result and 202
for an accepted background job) returns a sealed interface with one nested StatusNNN per status
code - the handler picks the status by which one it returns:
// Generated (apis package):
sealed interface CreateBackupResponse {
class Status200(val body: BinaryContent) : CreateBackupResponse // 200, application/zip
class Status202(val body: Operation) : CreateBackupResponse // 202, application/json
object Status204 : CreateBackupResponse // a response without content
}
// Implementation:
override suspend fun createBackup(sync: Boolean?): CreateBackupResponse =
if (sync == true) CreateBackupResponse.Status200(buildNow()) else CreateBackupResponse.Status202(startInBackground())
Each variant's body follows the same media type rules as a single response. An operation with a
single success response keeps the plain suspend fun op(...): T signature.
Publishing the OpenAPI spec
Set -v publishOpenApiSpec=true to have the generated server publish its own spec:
openapi-yagen g -o out -g kotlin_ktor_server_generator openapi.yaml \
-v packageName=com.example.petstore -v publishOpenApiSpec=true
This generates OpenApiSpecRoute.kt, exposing an OpenApiSpecRoute(route: Route) class you mount
alongside your other routes:
routing {
PetsRoutes(this, PetsService())
OpenApiSpecRoute(this)
}
Unlike the client-side -v generate=models/-v generate=api split above, this generator's output
targets both JVM and Kotlin/Native (see "Try it"), and there is no resource-loading API shared by
both - so, unlike some of this project's other server generators, the effective OpenAPI document
(after $ref resolution, --tags filtering, and conversion to this generator's OpenAPI 3.2) is
embedded directly as a Kotlin string constant in OpenApiSpecRoute.kt, not written as a separate
bundled resource file. It works unchanged on every target with no extra build-file wiring, at the
cost of needing a regeneration (not just a redeploy) to pick up a spec change.
Authentication
http/bearer, apiKey, oauth2, and openIdConnect security schemes are supported.
oauth2/openIdConnect are handled identically to http/bearer: per RFC 6750, an access token
travels as Authorization: Bearer <token> regardless of how it was obtained (authorization-code,
client-credentials, an OIDC provider, ...), and - same as bearer/apiKey - this generator never
validates a token's signature/scopes/audience itself; that's left entirely to the handler
implementation.
A secured operation's handler method gets one extra parameter per scheme referenced by its
security. For a single security requirement (security: [{a: [], b: []}], meaning every scheme
in it is required together), each is a plain non-nullable String, already extracted and validated
(Validation.kt's requireBearerToken/requireApiKey, throwing MissingAuthenticationException
- mapped to 401, see "Integrating the generated code") before the handler runs:
suspend fun deletePet(petId: String, bearerAuth: String)
A security requirement with two or more OR-alternatives (security: [{a: []}, {b: []}], meaning
either combination satisfies the request) is also supported: every scheme referenced by any
alternative becomes a nullable String? parameter instead (null unless the alternative it
belongs to is the one that matched), and the generated route tries each alternative in the spec's
declared order, using the first one whose every scheme is present:
suspend fun favoriteWidget(widgetId: String, oauth2Auth: String?, apiKeyAuth: String?)
A missing credential (single requirement) or no satisfied alternative (OR-alternatives) throws
MissingAuthenticationException before the handler is ever called.
Formatting generated sources
Templates (templates/*.kt.j2) emit correctly indented Kotlin directly, using Inja's indent()/
{% filter %} (see docs/templating.md), so a formatter isn't
required for readable output. -p/--post-process is still available for house-style polish
(import ordering, trailing commas, line-wrapping) - pipe through
ktfmt:
openapi-yagen g -o out -g kotlin_ktor_server_generator openapi.yaml \
-v packageName=com.example.petstore \
-p "kt:ktfmt --kotlinlang-style %file%"
kt: restricts the post-process step to .kt files (everything this generator emits); %file%
is substituted with each generated file's path. ktfmt formats in place, so no extra flag is
needed beyond a style choice (--kotlinlang-style, --google-style (default), or
--dropbox-style). Install ktfmt however you prefer (e.g. brew install ktfmt), or invoke the
jar directly instead of a ktfmt binary: -p "kt:java -jar ktfmt-<version>-with-dependencies.jar --kotlinlang-style %file%".
Note that -p starts the formatter once per generated file, and each ktfmt invocation pays
its own JVM startup cost - fine for a handful of files, but it adds up for a larger spec. For
those cases it's faster to generate first and format afterwards in a single batch call listing
every file at once:
openapi-yagen g -o out -g kotlin_ktor_server_generator openapi.yaml -v packageName=com.example.petstore
find out -name "*.kt" | xargs java -jar ktfmt-<version>-with-dependencies.jar --kotlinlang-style
oneOf/anyOf support
- With
discriminator.propertyNameand every variant a$refto a named schema: asealed interfaceplus one@Serializablesubtype per variant, dispatched byJsonClassDiscriminator. - Otherwise (undiscriminated, and/or inline variants): a "union" model instead - a
sealed interfacewith one value-wrapping variant per branch, deserialized by a generated, hand-rolledKSerializer(notJsonContentPolymorphicSerializer- itsdeserialize()isfinaland hands the chosen variant's own derived serializer the same unwrappedJsonElement, which doesn't match the flat wire shape a one-property wrapper class's own serializer expects) that dispatches on the JSON value's shape (object/array/string/number/boolean). This only works if the variants are pairwise distinguishable from the raw JSON alone: at most one variant per non-object shape, and for multiple object-shaped variants, each one needs a property (required or not) that no other object variant also declares. An unconstrained variant (a bare{}, matching any JSON value - a common "or literally anything else" idiom) is supported too, as a single trailing catch-all wrappingkotlinx.serialization.json.JsonElement, checked only after every other variant's shape fails to match; at most one such catch-all is allowed per oneOf/anyOf. A oneOf/anyOf that still can't be dispatched this way (e.g. two variants sharing the same non-object shape with nothing else to tell them apart, more than one catch-all, or a nested oneOf/anyOf/$ref variant) is a generator error (seestrictabove). - A path/query/header parameter's schema is a different position - there's no JSON structure
to dispatch on for a value that's always just a string on the wire. A oneOf/anyOf there skips
the "union" model entirely: if every variant is itself primitive/enum-shaped, the parameter's
Kotlin type is just
String, passed straight through unparsed (the API decides how to interpret it - e.g. "either the ID or the fingerprint"); a variant that's object/array-shaped is still a generator error, same as for a plain (non-union) parameter.
Known limitations (v1)
-
A multipart
format: binaryfile part is read fully into memory as aByteArray- only a raw (non-form) request body is streamed. -
Only explicitly numbered
2xxresponses become separate response variants; a range (2XX) ordefaultresponse is used only when no numbered2xxresponse exists. -
Request and response bodies support
application/json, a singletext/*media type (as aString), and a single other media type (asBinaryBody/BinaryContent); request bodies additionally supportapplication/x-www-form-urlencodedandmultipart/form-data(see "Request body content types" and "Response content types" above). A requestBody/response declaring two or more media types outside those fixed ones is a generator error (aborts generation under defaultstrict=true; skips just that operation with a printed warning under-v strict=false) - it is never silently dropped. -
Path/query/header/cookie parameters must resolve to a primitive scalar type (string/integer/ number/boolean), an enum, or a oneOf/anyOf whose every variant is itself primitive/enum-shaped (passed straight through as a plain, unparsed
String- see "oneOf/anyOf support" above) - an object or array in one of those positions is a generator error, except aquery-position array (one repeated key per element - see "Request body content types" above for the same convention applied to a urlencoded/multipart array field). An unrecognized enum value in a request is rejected with a 400, same as a malformed numeric parameter. -
Body validation covers
minimum/maximum/minLength/maxLength/pattern/format: uuidon direct properties (matching whatValidation.ktimplements), and recurses into nested object properties (via their own generatedvalidate()), array elements (object items viavalidate(), primitive items via the same constraint checks as a direct property), andMap-shaped (additionalProperties) property values (object values viavalidate()too - aMap's keys are always plain wire strings, nothing to validate there). -
Only
http/bearer,apiKey,oauth2, andopenIdConnectsecurity schemes are supported (amutualTLS/HTTP Basic scheme is a generator error); no token/scope validation is generated for any of them - just presence extraction, left to the handler implementation (see "Authentication" above). -
Generated files are not run through a formatter - see "Formatting generated sources" above.
Try it
From the generators/ directory, with openapi-yagen on PATH (see run_kotlin_server.sh,
sibling to the existing run.sh):
cd generators && ./run_kotlin_server.sh
generates into generators/out/kotlin-server from test/resources/petstore.yaml.
For a real generate-then-run check exercising every operation, positive and negative, see
test/ -
this generator's own self-contained test suite (see also
../README.md for the collection-wide convention). test/'s ./gradlew test
only runs the JVM target; ./gradlew compileKotlinLinuxX64 is what actually backs this README's
"works unchanged on every platform Ktor's server supports (JVM, Native)" claim - checked in this
repo's own CI (.github/workflows/build.yml's kotlin-native-compile-check job).