Idiomatic Crystal OpenAPI client generator — redesign (powering a real Qdrant + RAG stack)

Hi everyone :waving_hand:

Crystal’s crystal client generator in OpenAPI Generator has been a beta for a while, and its output never really felt like Crystal — a flat XApi class per tag, prefixed methods, _with_http_info twins, a global singleton client, and several constructs that didn’t even compile (or crashed at runtime) on real-world specs.

I’ve reworked it. PR #24070 — [Crystal] idiomatic API redesign is open and in review for 7.24.0. Sharing it here because feedback from actual Crystal users is exactly what would make it land well.

What the output looks like now

# Before — global singleton, prefixed methods, (value, status, headers) tuples
api = Petstore::PetApi.new
pets, status, headers = api.find_pets_by_status_with_http_info(["available"])

# After — per-instance client, namespaced sub-clients routed from the path
client = Petstore::Client.new(host: "petstore.swagger.io")
pets = client.pet.find_by_status(status: ["available"]).value

And on a real spec, sub-clients nest naturally from the path:

client = Qdrant::Api::Client.new(host: "localhost", scheme: "http")
result = client.collections.points.search(/* ... */).value

What changed

  • Namespaced sub-clients routed from the path (client.collections.points.search) instead of DcimApi#dcim_cable_terminations_list.
  • One generic request pathConnection#request(T) forall T; each operation is a short declarative call returning a typed Response(T). No _with_http_info duplicates (~38 lines of boilerplate per op → a handful).
  • Native multi-instance — every Client owns its own Connection/Configuration. No global state.
  • Leaner models — one declarative validates macro replaces the per-model EnumAttributeValidator hierarchy; ==/hash via stdlib def_equals_and_hash; a shared Serializable mixin.
  • Real-world fixes that the unit fixtures never caught (Crystal only type-checks code it actually reaches):
  • anyOf / oneOf → proper union wrappers that try each member (discriminator fast-path when present). The old oneOf wrapper included JSON::Serializable with no fields and crashed at runtime on deserialise.
  • Named enums → transparent alias X = String (the old class with build_from_hash couldn’t be built by JSON::Serializable).
  • allOf inheritance → children stop re-declaring inherited ivars (Crystal forbids re-annotating), forward via super(...), and emit use_json_discriminator for polymorphism. Model requires are topologically ordered.
  • Multipart file upload, cookie params, non-JSON / binary responses, and opt-in logging — previously broken or missing.
  • additionalProperties preserved across round-trips via JSON::Serializable::Unmapped.

It’s not a synthetic redesign — it ships in production

The generator powers a three-layer Qdrant stack written in Crystal:

  • qdrant-api.cr — the low-level Qdrant REST client, generated by this very generator from Qdrant’s full OpenAPI spec (~320 models, anyOf unions, named enums). The new samples/client/others/crystal-qdrant sample in the PR exercises the same path.
  • qdrant-client.cr — an idiomatic, RAG-oriented wrapper over it (anti-corruption layer — no generated type leaks into its public surface), tested in CI against a real Qdrant container.
  • mnemodoc — a local-first, self-hosted MCP server (hybrid semantic + keyword doc search for Claude Code / Cursor / …) that runs entirely on your own machine — your docs never leave it. Uses qdrant-client as its vector store.

Most of the oneOf/anyOf/named-enum/allOf bugs above were surfaced by generating qdrant-api against that full spec, not by the fixtures.

Status

  • petstore sample: crystal spec92 examples, 0 failures
  • crystal-qdrant sample: crystal spec394 examples, 0 failures
  • codegen unit tests: 38, 0 failures; both samples crystal tool format clean

If you generate Crystal clients from OpenAPI specs, I’d love for you to try the branch against your own spec and tell me where the generated API feels awkward — that’s the feedback that’ll make this good. PR is #24070. :folded_hands:

9 Likes