geniespec 0.1.1 · MIT

One YAML spec.
Type-correct APIs and clients everywhere.

Write your resources once. Genie turns them into a typed server and typed clients that always agree, so the correct API is also the easiest one to build.

$ uvx geniespec build --spec api.yaml --target python-server
api.yaml

        
bookshelf_api/v1/internal.py
# waiting for api.yaml…
bookshelf-api/v1/client.ts
// waiting for api.yaml…

Hi, I'm Krishan.

I've worked at a lot of companies, and I've built thousands of APIs. Back then we wrote every one of them by hand.

Now, with agents, I can write thousands in a day.

But things get messy quickly. When an API doesn't follow the core resource-oriented principles, every endpoint ends up with its own shape. That causes pain and confusion for everyone who calls it, and for everyone who has to change it later.

A clean, clear, resource-based API is so much easier to maintain. You can guess the route. You know what you'll get back.

That's why I built Genie. You describe your resources once, and Genie keeps every endpoint on the same rules, whether a person or an agent wrote it.

Inspired by AIP-121

The design rules live in the generator

Genie follows Google's AIP-121: Resource-oriented design. An API is made of named resources (nouns) and the hierarchy between them, with a small number of standard methods (verbs) and custom methods for whatever doesn't fit. In Genie those ideas are the shape of the spec: routes, names and types are derived from it, and the build rejects specs that break the rules.

01

Resources are nouns

Each resource has a single name, a collection name and a primary attribute called <resource>-id. Paths come from those names, so you never write one.

Book · Books · book-id → /books/{bookId}
02

Hierarchy, not ad hoc paths

parentResource: Shelf nests books under shelves. Routes, method names and ids all follow the hierarchy.

/shelves/{shelfId}/books/{shelfBookId}
03

Standard methods first

LIST, GET, CREATE, UPDATE and DELETE have fixed HTTP methods and routes. Most of an API should be these five.

GET · GET · POST · PATCH · DELETE
04

Custom methods when needed

Anything else is a named action on one resource or on the whole collection. AIP-136 puts the verb after a colon; Genie puts it in the path.

MOVE → POST …/books/{shelfBookId}/move
05

One shape per resource

LIST, GET, CREATE and UPDATE all return the same resource class, so a client never sees two versions of a book.

shelfBook: ShelfBookV1
06

Typed all the way down

Every field has a type, nullability and a required flag. Enums, oneOfs and files are types too, and the server rejects values that don't match.

status: ReadingStatus · isNullable: true

Names are derived, not declared

Every method becomes a route and typed code on both sides

Resource and action names decide the HTTP route, the method you implement on the server and the method you call on the client. Every target uses the same rule; here it is for the two live ones.

SpecRoutePython internalTypeScript client
LIST BookGET /bookslist_books_v1listBooks
GET BookGET /books/{bookId}get_book_v1getBook
CREATE BookPOST /bookscreate_book_v1createBook
UPDATE BookPATCH /books/{bookId}update_book_v1updateBook
SUMMARISE · streamPOST /books/{bookId}/summarisesummarise_book_v1 → AsyncIteratorsummariseBook(id, onChunk)
UPLOAD-COVER · FilePOST /books/{bookId}/upload-coverupload_cover_book_v1(image: UploadedFile)uploadCoverBook(id, { image })

How it works

From YAML to typed code in five stages

Each build runs the same pipeline. An invalid spec stops at the first stage that rejects it, so you never get half-generated code.

01

Syntactic check

The raw YAML is checked against a JSON Schema for names, patterns and value types, so action: FOO_BAR and stray keys fail early.

jsonschema · api_schema.yaml
02

Parse

The spec is loaded into pydantic models: resources, attributes, transitions, enums, oneOfs and exceptions.

KibaApi(**apiYaml)
03

Semantic check

Genie resolves routes and names, then rejects duplicate routes, operation-name clashes, reserved words, bad primary keys and invalid oneOfs.

SemanticValidationException
04

Render

A target-specific language definition decides types, names and serialisation, and Jinja templates write the files.

templates/<target>/*.j2
05

Diff-aware write

Only files whose content changed are rewritten, and files the spec no longer produces are removed. Your dev server only sees real changes.

(1 files changed)

Real output

Open the files it writes

These files were generated by geniespec 0.1.1 from the spec in the first tab. Only api.py is trimmed, where marked. On the server you implement one abstract class; the client is ready to call.


        

Language-agnostic core

Any language. Two are live today.

The spec says nothing about Python or TypeScript. Genie's core validates it and works out every resource, route, method name and type. A target then renders that model, and a target is only a language definition (type names, casing, reserved names) plus a folder of Jinja templates.

api.yamlresources · methods · types
corevalidate · routes · names · types
python-serverpydantic + starlette
typescript-client@kibalabs/core

Live

python-server

pydantic resource models, starlette routes and one abstract class to implement. Request parsing, validation, auth hooks and streaming are generated.

<service>_api/v1/

Live

typescript-client

A typed client with a method per operation, resource classes, enum types and oneOf parsers, built on @kibalabs/core's ServiceClient.

<service>-api/v1/

Bring your own

Templates all the way

The bundled templates are one kibalabs-style example. kiba-core, pydantic and starlette are choices the templates make, not the core, so a different stack or a new language is a new set of templates.

LanguageDefinition + templates/<target>/*.j2

Type system

One type system, rendered per target

Spec types are language-neutral and each target decides how to render them. These are the two live targets: nullable fields become | None in Python and | null in TypeScript, and dates are parsed and serialised for you.

python-servertypescript-client

String · Integer · Float · Boolean

str int float bool

string number boolean

Date · Datetime

datetime.date datetime.datetime

Date via dateFromString / dateToString

List

collections.abc.Sequence[T]

T[]

Mapping

collections.abc.Mapping[K, V]

Record<string, V>

Json

pydantic.JsonValue

JsonValue (recursive union)

For payloads you don't want to model, such as an LLM tool result or a third-party webhook body.

Enum

ReadingStatusV1 = typing.Literal['want-to-read', …]

type ReadingStatusV1 = 'want-to-read' | …

Named at the top level or inline with enumValues. The server rejects other values with a 400.

OneOf

Annotated[A | B, Field(discriminator='kind')]

A | B + xV1FromObject()

Each member is a resource with a single-value enum discriminator.

File

UploadedFile(filename, contentType, content)

File sent as multipart/form-data

POST only. The other fields go in the same form.

The details

The edge cases are covered too

Streams

responseType: stream gives an async generator on the server, sent as application/x-ndjson. The client gets a callback per item.

Partial updates that mean it

UPDATE fields are UpdateValue[T] = UNDEFINED, so "not sent" and "set to null" stay different from the client all the way to your code.

Renames fail type-checking

Internal methods take keyword-only parameters. Rename a parameter in your implementation and mypy reports an incompatible override.

Auth per transition

defaultAuthentication plus a per-transition authentication. Routes call your RouteAuthResolver with the policy name; none makes a route public.

Redirects and webhooks

responseType: redirect returns a location the server answers with a 302. source: query-parameters collects every query parameter for third-party callbacks.

Versioned by design

Each major version writes its own package, e.g. bookshelf_api/v1, and every class carries the suffix (BookV1), so two versions can be served side by side.

Watch mode

Edit the spec while the app is running

geniespec watch builds once, starts your command, rebuilds on every save and passes your command's exit code through. If the spec is invalid it keeps the last good output.

zsh

      

Errors name the spec field that caused them:

  • SemanticValidationException Primary attribute of Book must be book-id, found id
  • SemanticValidationException "default" is not a valid TypeScript name (attribute of Book)
  • SemanticValidationException upload: only POST transitions can have File fields
  • SemanticValidationException kind of Graph must be a non-nullable Enum with one value to be part of Content

Rub the lamp.

Add geniespec as a dev dependency, gitignore the output folder, and run it from your build scripts.

$ uvx geniespec watch --spec api.yaml --target typescript-client -- npm start