
Compile-time generation of matching REST server and typed client from interface-based resource declarations; routes-as-types, typed Either business failures, OpenAPI output, and pluggable serialization.
= Nomi :toc: macro :toclevels: 2 :idprefix: :idseparator: - :icons: font :source-highlighter: highlight.js :version: 0.1.0
[.lead] Compile-time REST for Kotlin Multiplatform — declare your API once, get both sides.
Nomi turns plain Kotlin interfaces into a working REST API.
You declare resources — nested interfaces that are your routes — and services — interfaces whose suspend functions are your endpoints.
A KSP processor generates a ktor server implementation from them; another generates a typed ktor client.
Both come from the exact same declarations, so they cannot drift apart.
@Endpoint.Resource interface Articles { @Endpoint.Resource interface New : Articles }
@Endpoint.Resource interface Article { val articleId: Uuid }
@ServerEndpointService("articles") class ServerArticlesService : ArticlesService { override suspend fun Articles.get(page: Int?): Either<List?, NoFail> = TODO() override suspend fun Articles.New.create(content: ArticleContent): Either<Uuid, ArticleCreationFailure> = TODO() override suspend fun Article.get(): Either<ArticleContent, NoFail> = TODO() }
@ClientEndpointServiceFactory(ArticlesService::class, "articles") object APIServices
No reflection, no runtime route table, no hand-written URLs on either side. An interface, two generated implementations, and a compiler that rejects a client and server that disagree about the API.
toc::[]
== Why Nomi
One declaration, both sides. The resource and service interfaces in commonMain are the only place the API is written down.
The server's routes and the client's implementation are both generated from them, so a server and a client built against the same declaration can never disagree about a path, a method, or a type.
A route is a type, not a string. Nesting @Endpoint.Resource interfaces is the path structure — Article.Comments is a route because it is declared inside Article.
A path parameter is a val on the interface that owns it, not a placeholder you match against a string later.
Business failures are in the return type. Every endpoint returns Either<V, E>.
The server answers a failure with Either.error(...); the client receives it back as CallStatus.CallFailure(error), typed as the very same E.
No exception crosses the wire for a business outcome — only for things that are actually exceptional.
Everything is checked before anything is generated. A processor validates every declaration it reads — including ones from a dependency module — and generates nothing at all for a round in which anything it needs is invalid. There is no partial output and no runtime "route not found".
The API documents itself. Set withOpenApiSpecs and the generated routes describe themselves, so ktor serves an OpenAPI document straight from the routing tree.
Serialization stays yours. Nomi defines the shape of a call — method, path, query parameters, status code, whether a body is present — and nothing about the format of that body.
It hands the value to ktor's ContentNegotiation and lets it serialize.
JSON, CBOR, ProtoBuf, or anything else ktor can negotiate — Nomi never picks.
Multiplatform client, JVM server. Resources and services live in a commonMain shared between every target.
The generated client runs on all of them; the generated server runs on ktor-server, which is JVM-only.
== Setup
Nomi ships six artifacts:
[cols="2,3",options="header"] |=== | Artifact | Purpose
| org.kodein.rest.nomi:nomi-runtime-shared
| Either, Error/NoFail, Stringer, and the @Endpoint.* annotations.
A normal dependency.
| org.kodein.rest.nomi:nomi-runtime-client
| ServiceCaller, CallStatus, and the helpers the generated client calls.
A normal dependency.
| org.kodein.rest.nomi:nomi-runtime-server
| httpError, routingCall, and the helpers the generated routes call (ktor-server, JVM only).
| org.kodein.rest.nomi:nomi-processor-shared
| Needed by any module that declares a @Endpoint.Resource.
| org.kodein.rest.nomi:nomi-processor-server
| Turns a @ServerEndpointService class into ktor routes.
| org.kodein.rest.nomi:nomi-processor-client
| Turns a @ClientEndpointServiceFactory object into a ServiceCaller.
|===
=== The shared module
Resources and services are declared in commonMain.
Run nomi-processor-shared once, over the common metadata source set, and feed its output back into commonMain:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
plugins { kotlin("multiplatform") id("com.google.devtools.ksp") // Add the KSP plugin - it hosts the Nomi processors }
kotlin { jvm() iosArm64() // ...your targets
sourceSets {
commonMain {
// Add the KSP-generated common sources to commonMain. KSP already registers this
// directory as an output, but IntelliJ only indexes it as source code if it is also
// declared here explicitly.
kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/metadata/commonMain/kotlin"))
dependencies {
// The Nomi shared runtime: annotations, Either, Stringer. A normal dependency.
implementation("org.kodein.rest.nomi:nomi-runtime-shared:{version}")
}
}
}
}
dependencies { // Run the Nomi shared processor over the common metadata source set, once, so a single set // of resource implementations is generated for commonMain rather than one per target. add("kspCommonMainMetadata", "org.kodein.rest.nomi:nomi-processor-shared:{version}") }
=== The server module
A JVM module depending on the shared module, implementing its services:
plugins { kotlin("jvm") id("com.google.devtools.ksp") // Add the KSP plugin - it hosts the Nomi processors }
=== The client module
A multiplatform module depending on the shared module, wired exactly like the shared module itself — its own kotlin.srcDir and tasks.matching block — but with the client runtime and processor:
kotlin { sourceSets { commonMain { kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/metadata/commonMain/kotlin")) dependencies { implementation(project(":shared")) implementation("org.kodein.rest.nomi:nomi-runtime-client:{version}") } } } }
=== Kotlin version
Every generated factory and ServiceCaller.call use Kotlin context parameters.
This repository builds against Kotlin 2.4.20 with no extra compiler flag, so any Kotlin version where context parameters are available without an opt-in flag works to consume Nomi.
== Documentation
The full documentation is on https://kosi-libs.org[kosi-libs.org], and its sources are in doc/.
== Project layout
[cols="1,3",options="header"] |=== | Module | Contents
| runtime/nomi-runtime-shared
| Either, Error/NoFail, Stringer, EndpointFile, Multipart, @Endpoint.* annotations.
Multiplatform, explicitApi().
| runtime/nomi-runtime-client
| ServiceCaller, CallStatus, @ClientEndpointServiceFactory.
Multiplatform, explicitApi().
| runtime/nomi-runtime-server
| httpError, routingCall, @ServerEndpointService.
JVM-only (ktor-server), explicitApi().
| processor/nomi-processor-common
| Shared analysis and verification — not a runnable processor.
| processor/nomi-processor-shared
| Needed by any module that declares a @Endpoint.Resource.
| processor/nomi-processor-server
| Turns a @ServerEndpointService class into ktor routes.
| processor/nomi-processor-client
| Turns a @ClientEndpointServiceFactory object into a ServiceCaller.
| demo/*
| The articles-and-comments demo — see the demo/ directory, a full articles-and-comments API.
|===
=== Building
Processor tests compile fixture sources in-memory through the real processor with https://github.com/ZacSweers/kotlin-compile-testing[kotlin-compile-testing] and assert on the generated output.
== License
Nomi is released under the MIT License.
= Nomi :toc: macro :toclevels: 2 :idprefix: :idseparator: - :icons: font :source-highlighter: highlight.js :version: 0.1.0
[.lead] Compile-time REST for Kotlin Multiplatform — declare your API once, get both sides.
Nomi turns plain Kotlin interfaces into a working REST API.
You declare resources — nested interfaces that are your routes — and services — interfaces whose suspend functions are your endpoints.
A KSP processor generates a ktor server implementation from them; another generates a typed ktor client.
Both come from the exact same declarations, so they cannot drift apart.
@Endpoint.Resource interface Articles { @Endpoint.Resource interface New : Articles }
@Endpoint.Resource interface Article { val articleId: Uuid }
@ServerEndpointService("articles") class ServerArticlesService : ArticlesService { override suspend fun Articles.get(page: Int?): Either<List?, NoFail> = TODO() override suspend fun Articles.New.create(content: ArticleContent): Either<Uuid, ArticleCreationFailure> = TODO() override suspend fun Article.get(): Either<ArticleContent, NoFail> = TODO() }
@ClientEndpointServiceFactory(ArticlesService::class, "articles") object APIServices
No reflection, no runtime route table, no hand-written URLs on either side. An interface, two generated implementations, and a compiler that rejects a client and server that disagree about the API.
toc::[]
== Why Nomi
One declaration, both sides. The resource and service interfaces in commonMain are the only place the API is written down.
The server's routes and the client's implementation are both generated from them, so a server and a client built against the same declaration can never disagree about a path, a method, or a type.
A route is a type, not a string. Nesting @Endpoint.Resource interfaces is the path structure — Article.Comments is a route because it is declared inside Article.
A path parameter is a val on the interface that owns it, not a placeholder you match against a string later.
Business failures are in the return type. Every endpoint returns Either<V, E>.
The server answers a failure with Either.error(...); the client receives it back as CallStatus.CallFailure(error), typed as the very same E.
No exception crosses the wire for a business outcome — only for things that are actually exceptional.
Everything is checked before anything is generated. A processor validates every declaration it reads — including ones from a dependency module — and generates nothing at all for a round in which anything it needs is invalid. There is no partial output and no runtime "route not found".
The API documents itself. Set withOpenApiSpecs and the generated routes describe themselves, so ktor serves an OpenAPI document straight from the routing tree.
Serialization stays yours. Nomi defines the shape of a call — method, path, query parameters, status code, whether a body is present — and nothing about the format of that body.
It hands the value to ktor's ContentNegotiation and lets it serialize.
JSON, CBOR, ProtoBuf, or anything else ktor can negotiate — Nomi never picks.
Multiplatform client, JVM server. Resources and services live in a commonMain shared between every target.
The generated client runs on all of them; the generated server runs on ktor-server, which is JVM-only.
== Setup
Nomi ships six artifacts:
[cols="2,3",options="header"] |=== | Artifact | Purpose
| org.kodein.rest.nomi:nomi-runtime-shared
| Either, Error/NoFail, Stringer, and the @Endpoint.* annotations.
A normal dependency.
| org.kodein.rest.nomi:nomi-runtime-client
| ServiceCaller, CallStatus, and the helpers the generated client calls.
A normal dependency.
| org.kodein.rest.nomi:nomi-runtime-server
| httpError, routingCall, and the helpers the generated routes call (ktor-server, JVM only).
| org.kodein.rest.nomi:nomi-processor-shared
| Needed by any module that declares a @Endpoint.Resource.
| org.kodein.rest.nomi:nomi-processor-server
| Turns a @ServerEndpointService class into ktor routes.
| org.kodein.rest.nomi:nomi-processor-client
| Turns a @ClientEndpointServiceFactory object into a ServiceCaller.
|===
=== The shared module
Resources and services are declared in commonMain.
Run nomi-processor-shared once, over the common metadata source set, and feed its output back into commonMain:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
plugins { kotlin("multiplatform") id("com.google.devtools.ksp") // Add the KSP plugin - it hosts the Nomi processors }
kotlin { jvm() iosArm64() // ...your targets
sourceSets {
commonMain {
// Add the KSP-generated common sources to commonMain. KSP already registers this
// directory as an output, but IntelliJ only indexes it as source code if it is also
// declared here explicitly.
kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/metadata/commonMain/kotlin"))
dependencies {
// The Nomi shared runtime: annotations, Either, Stringer. A normal dependency.
implementation("org.kodein.rest.nomi:nomi-runtime-shared:{version}")
}
}
}
}
dependencies { // Run the Nomi shared processor over the common metadata source set, once, so a single set // of resource implementations is generated for commonMain rather than one per target. add("kspCommonMainMetadata", "org.kodein.rest.nomi:nomi-processor-shared:{version}") }
=== The server module
A JVM module depending on the shared module, implementing its services:
plugins { kotlin("jvm") id("com.google.devtools.ksp") // Add the KSP plugin - it hosts the Nomi processors }
=== The client module
A multiplatform module depending on the shared module, wired exactly like the shared module itself — its own kotlin.srcDir and tasks.matching block — but with the client runtime and processor:
kotlin { sourceSets { commonMain { kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/metadata/commonMain/kotlin")) dependencies { implementation(project(":shared")) implementation("org.kodein.rest.nomi:nomi-runtime-client:{version}") } } } }
=== Kotlin version
Every generated factory and ServiceCaller.call use Kotlin context parameters.
This repository builds against Kotlin 2.4.20 with no extra compiler flag, so any Kotlin version where context parameters are available without an opt-in flag works to consume Nomi.
== Documentation
The full documentation is on https://kosi-libs.org[kosi-libs.org], and its sources are in doc/.
== Project layout
[cols="1,3",options="header"] |=== | Module | Contents
| runtime/nomi-runtime-shared
| Either, Error/NoFail, Stringer, EndpointFile, Multipart, @Endpoint.* annotations.
Multiplatform, explicitApi().
| runtime/nomi-runtime-client
| ServiceCaller, CallStatus, @ClientEndpointServiceFactory.
Multiplatform, explicitApi().
| runtime/nomi-runtime-server
| httpError, routingCall, @ServerEndpointService.
JVM-only (ktor-server), explicitApi().
| processor/nomi-processor-common
| Shared analysis and verification — not a runnable processor.
| processor/nomi-processor-shared
| Needed by any module that declares a @Endpoint.Resource.
| processor/nomi-processor-server
| Turns a @ServerEndpointService class into ktor routes.
| processor/nomi-processor-client
| Turns a @ClientEndpointServiceFactory object into a ServiceCaller.
| demo/*
| The articles-and-comments demo — see the demo/ directory, a full articles-and-comments API.
|===
=== Building
Processor tests compile fixture sources in-memory through the real processor with https://github.com/ZacSweers/kotlin-compile-testing[kotlin-compile-testing] and assert on the generated output.
== License
Nomi is released under the MIT License.