[Crystal] idiomatic api redesign#24070
Merged
wing328 merged 2 commits intoJun 23, 2026
Merged
Conversation
a1ffa90 to
6b425ef
Compare
bbff1dc to
44add18
Compare
wing328
reviewed
Jun 22, 2026
Member
|
thanks for the PR please PM me via Slack when you've time: https://join.slack.com/t/openapi-generator/shared_invite/zt-36ucx4ybl-jYrN6euoYn6zxXNZdldoZA |
… leaner models Overhaul the beta `crystal` client generator to emit idiomatic, DRY, multi-instance Crystal. API layer: - Namespaced sub-clients: `client.dcim.cable_terminations.list` (path-based routing via a CrystalApiRouting helper + addOperationToGroup) instead of a flat `DcimApi` with prefixed methods. - A single generic `Connection#request(T) forall T` choke point (crest transport); operations are short declarative calls returning a typed `Response(T)` (no `_with_http_info` twins). - Native multi-instance via a `Client` facade owning a per-instance `Connection`/`Configuration` (no global singleton). Operation header params wired through; array query params encoded as `key=a&key=b` via a configurable Crest params encoder. Models: - Trim ignored `@[JSON::Field]` args; `valid?` delegates to `list_invalid_properties`. - Shared `Serializable` mixin for `to_h`/`to_body`/`to_s`/`eql?`; `==`/`hash` via the stdlib `def_equals_and_hash` macro. - One declarative `validates(name, type, nilable, **rules)` macro replaces the per-model EnumAttributeValidator hierarchy and the duplicated min/max/length/pattern/items + enum checks. ~-39% model LOC on a large real-world spec; eager (rescuable) validation now actually fires. Generated specs are meaningful (JSON round-trip / required enforcement / facade reachability) instead of empty `skip` stubs. Also fixes latent bugs: numeric enums quoted as strings, validating setters shadowed by property setters, BigDecimal JSON, ::File-in-model, unresolved Array(Array), stale RecursiveHash references, blank shard.yml authors, and a maxItems/minItems paren typo. petstore `crystal spec` and the codegen unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
44add18 to
16c8c70
Compare
…verage) Generated from the Qdrant REST API 4.4.10 spec (~320 models incl. anyOf unions and named enums) with moduleName=Qdrant::Api and apiNamespace="" (api classes nest directly under the module). Serves as a real, large integration gate: compiles and `crystal spec` runs green. - bin/configs/crystal-qdrant.yaml - modules/openapi-generator/src/test/resources/3_0/crystal/qdrant.json (embedded spec) - samples/client/others/crystal-qdrant (generated client) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16c8c70 to
c2b1123
Compare
Contributor
Author
|
Thank you! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[crystal] Idiomatic redesign of the Crystal client generator
This reworks the beta
crystalclient generator to emit idiomatic, DRY, multi‑instance Crystalinstead of the current flat, Ruby‑ported output, and hardens it so generated clients compile and
work against real‑world specs out of the box.
Since the generator is beta, this changes the generated API shape (no compatibility flag); the
samples and codegen unit tests are updated accordingly.
Real-world usage
This isn't a synthetic redesign — the new output already ships in production. The generator powers a
three-layer Qdrant stack in Crystal:
qdrant-api.cr— the low-level Qdrant REST client,generated from Qdrant's OpenAPI spec by this very generator (per-instance
Client.new(host:),namespaced sub-clients
client.collections.points.search(...), typedResponse(T)). Thesamples/client/others/crystal-qdrantsample in this PR exercises the same generation path.qdrant-client.cr— an idiomatic, RAG-orientedwrapper over
qdrant-api(anti-corruption layer; no generated type leaks into its public surface),tested in CI against a real Qdrant container.
semantic + keyword documentation search for Claude Code, Cursor, …) that runs entirely on your own
machine — your docs never leave it — and uses
qdrant-clientas its vector store.Several of the bugs fixed here — the runtime
oneOf/anyOfcrashes, named-enum deserialisation, andallOfinheritance — were surfaced by generatingqdrant-apiagainst the full ~320-model Qdrant spec,not by the test fixtures.
Why
The current output is verbose and not idiomatic Crystal (a flat
XApiclass per tag with prefixedmethods, ~38 lines of boilerplate per operation, a global singleton client, models with duplicated
machinery), and several constructs simply did not compile or silently misbehaved on real specs.
Many of those bugs are invisible to the generated specs because Crystal only type‑checks code that
is actually reached, and unit specs don't perform network calls — they were found by forcing
type‑checking / runtime of operation bodies.
What changes
API layer
client.dcim.cable_terminations.listinstead ofDcimApi#dcim_cable_terminations_list.Connection#request(T) forall T(crest transport); each operationis a short declarative call returning a typed
Response(T)(no_with_http_infotwins).Clientfacade owns a per‑instanceConnection/Configuration.collectionFormatcsv/ssv/pipes/multi), header, cookie, body,and multipart file upload; array query encoding is configurable (Crest params encoder).
apiNamespaceoption (default"Api"): sub‑namespace the api resource classes nest under, tokeep them distinct from same‑named models. Set to
""to nest them directly undermoduleName(e.g.
moduleName=Foo::Api,apiNamespace=""→Foo::Api::Pet), avoiding aFoo::Api::Api::Petclash when the module name already ends with
Api.Configuration#logging(off by default — no log output unless opted in)Configuration#logger, wired to crest. A higher‑level wrapper can exposeconfig.logging = true.ApiErrorfor non‑2xx, with crest headers converted toHTTP::Headers.Models
@[JSON::Field]args;valid?delegates tolist_invalid_properties;==/hashvia the stdlib
def_equals_and_hash; sharedSerializablemixin forto_h/to_body/to_s/eql?.validatesmacro replaces the per‑modelEnumAttributeValidatorhierarchy andthe duplicated min/max/length/pattern/items + enum checks; eager (rescuable) validation now fires.
alias X = <underlying type>(consistent with inline enums) instead of a classwith
build_from_hashthatJSON::Serializablecouldn't deserialise.anyOf/oneOf→ a wrapper that (de)serialises by trying each member (and, when adiscriminatoris present, dispatches on it first). The previous oneOf wrapperincludedJSON::Serializablewith no fields and crashed at runtime on deserialisation.allOfinheritance: children no longer re‑declare inherited properties (Crystal forbidsre‑annotating a superclass ivar) and forward inherited args via
super(...); modelrequires aretopologically ordered so a superclass is required before its subclasses. A
discriminatoremitsuse_json_discriminator/use_yaml_discriminatorfor polymorphic deserialisation.JSON::Serializable::Unmapped.Client,Connection, …): a model named likeone is renamed (
Client→ModelClient).Generated specs are meaningful (JSON round‑trip / required enforcement / facade reachability)
instead of empty
skipstubs.Before / After
Call site
Generated operation (~38 lines of boilerplate → a declarative call)
Model validation (per-property validator class + a shadowed setter → one macro line)
Named enums (a class JSON::Serializable couldn't build → a transparent alias)
anyOf (an empty class using the non-existent
const_get→ a union wrapper)oneOf (the wrapper
included JSON::Serializable with no fields → crashed at runtime on deserialise)allOf inheritance (re-declaring inherited props won't compile → own props +
super+ discriminator)File upload / cookie params / non-JSON responses / logging (previously broken or missing)
Samples
samples/client/petstore/crystalis generated from the project's broadpetstore-with-fake-endpoints-models-for-testing.yamlfixture (enums,oneOf,allOfinheritancesamples/client/others/crystal-qdrantis generated from the Qdrant REST API 4.4.10 spec (~320models incl.
anyOfunions and named enums) withmoduleName=Qdrant::Api,apiNamespace=""— areal, large integration gate. Both compile and
crystal specruns green.Known limitations
readOnly/writeOnlyare not enforced.JSON::Serializabledoesn't split (de)serialisationper property, so honouring direction would require custom per‑field converters; properties still
round‑trip both ways. (Validation nicety, not a functional blocker.)
deepObject/matrix/labelare not supported (onlyform/simplewithcollectionFormatcsv/ssv/pipes/multi). These styles are rare in practice.referenced‑enum defaults are intentionally skipped (their rendering isn't guaranteed valid Crystal).
schemas without a discriminator may be ambiguous.
Validation
crystal spec: 92 examples, 0 failures.crystal spec: 394 examples, 0 failures.CrystalClientCodegenTest+CrystalApiRoutingTest): 38, 0 failures.crystal tool formatclean.PR checklist
./mvnw clean package, regenerated the affected samples (bin/configs/crystal*.yaml) and generator docs; all changed files committed.🤖 Generated with Claude Code