Skip to content

[Ruby] Add new ruby-idiomatic client generator (opt-in idiomatic redesign)#24220

Open
n-rodriguez wants to merge 1 commit into
OpenAPITools:masterfrom
n-rodriguez:ruby-idiomatic
Open

[Ruby] Add new ruby-idiomatic client generator (opt-in idiomatic redesign)#24220
n-rodriguez wants to merge 1 commit into
OpenAPITools:masterfrom
n-rodriguez:ruby-idiomatic

Conversation

@n-rodriguez

@n-rodriguez n-rodriguez commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a new opt-in Ruby client generator, ruby-idiomatic, that emits modern, DRY, multi-instance Ruby. It mirrors the recent Crystal idiomatic redesign (#24070) but as a separate generator — the existing ruby generator, its templates, and its samples are left byte-for-byte unchanged, so there is zero blast radius for its install base. Users opt in with -g ruby-idiomatic and migrate when they choose.

What it produces

  • Namespaced sub-clientsclient.dcim.cable_terminations.list instead of a flat DcimApi with prefixed methods (path→route helper + a hybrid module nesting with a configurable apiNamespace wrapper).
  • Single Connection#call choke-point (Faraday) — short declarative operations, keyword args everywhere, no _with_http_info twins.
  • Delegating Response (.data/.status/.headers) and one rich ApiError (all Faraday errors wrapped).
  • Native multi-instance — a Client facade owns a per-instance Configuration/Connection; no global singleton.
  • Configuration#use seam to register custom Faraday middleware (request signing à la OVH / AWS SigV4 that a static OpenAPI security scheme cannot model) without subclassing.
  • Models on a declarative attribute DSL + shared Serializable/Validations mixins (replaces the EnumAttributeValidator hierarchy and per-model duplication); recursive from_hash deserializes nested models/arrays/maps/unions/enums into typed objects; additionalProperties preserved; anyOf/oneOf via a Polymorphism helper.
  • Zeitwerk class loading (lazy, no generated require/autoload list); Ruby 3.0 floor; frozen_string_literal everywhere; FeatureSet declared honestly (JSON only, no XML, allOf/anyOf/oneOf/Union, apiKey/basic/bearer).
  • Clean gem scaffolding: modern gemspec, LICENSE, dev deps (rake, rspec, rubocop*, simplecov) + binstubs, .rspec, clean spec_helper, GitHub Actions CI, and a rich README (incl. the request-signing middleware example). The generated gem passes rubocop clean out of the box (no -A needed).

Testing / evidence

  • Codegen unit tests: RubyIdiomaticClientCodegenTest + RubyApiRoutingTest (19 tests) — green.
  • Samples: petstore (16 examples, 0 failures) and a real-world Qdrant client under samples/client/others/ruby-idiomatic-qdrant (577 examples, 0 failures; exercises anyOf / named enums). Both pass rubocop with no offenses.
  • On the Qdrant spec, generated model LOC is ~76% lower than the stock ruby generator.
  • Existing ruby* samples are untouched.

PR checklist

  • Read the contribution guidelines.
  • Ran ./mvnw clean package, regenerated this generator's samples (./bin/generate-samples.sh bin/configs/ruby-idiomatic*.yaml) and its docs. Only the new ruby-idiomatic generator's outputs change; no other generator output is affected.
  • @mention the Ruby technical committee for review.

cc @wing328 — new opt-in Ruby generator (idiomatic redesign), following up on the Crystal work in #24070.


Summary by cubic

Adds an opt-in Ruby client generator, ruby-idiomatic, that builds modern, namespaced, multi-instance clients on faraday + zeitwerk. Ships with generator docs (beta), SPI registration, unit tests, and sample clients (petstore and Qdrant) with CI; adds a samples workflow running petstore RSpec on Ruby 3.0/3.3 and links the new docs entry as ruby-idiomatic (beta).

  • New Features

    • Namespaced sub-clients via path→route mapping (e.g., client.collections.points.search).
    • Single Connection#call (faraday); keyword args only; no _with_http_info.
    • Unified Response (data/status/headers) and rich ApiError wrapping Faraday errors.
    • Native multi-instance with per-client Configuration/Connection; no globals.
    • Configuration#use to register custom Faraday middleware.
    • Models use an attribute DSL with shared Serializable/Validations; anyOf/oneOf via Polymorphism; preserves additionalProperties.
    • Zeitwerk autoloading with nested module support and acronym inflections; Ruby >= 3.0; JSON-only; depends on faraday, faraday-multipart, zeitwerk.
    • Generator docs page added and linked as ruby-idiomatic (beta); unit tests; petstore + Qdrant samples with CI; samples workflow monitors both sample paths.
  • Bug Fixes

    • Normalize HTTP methods to lowercase symbols in Connection#call.
    • Percent-encode path params when interpolating into URLs.
    • Apply auth on every request and honor apiKey locations (header/query/cookie) plus basic/bearer.
    • Only materialize schema defaults for required properties; optional fields remain nil so server defaults apply.
    • Fix Windows CI: join API subpaths with '/' in toApiFilename for consistent output and manifests across platforms.
    • Regenerated docs index and canonical FILES manifests to fix CI drift.

Written for commit 2f460af. Summary will update on new commits.

Review in cubic

@wing328

wing328 commented Jul 7, 2026

Copy link
Copy Markdown
Member

Thanks for the PR

cc @cliffano (2017/07) @zlx (2017/09) @autopp (2019/02) (Ruby Technical Committee)

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

40 issues found across 792 files

Note: This PR contains a large number of files. cubic only reviews up to 200 files per PR, so some files may not have been reviewed. cubic prioritizes the most important files to review.

Re-trigger cubic

Comment thread modules/openapi-generator/src/main/resources/ruby-idiomatic/api.mustache Outdated
Comment thread samples/client/others/ruby-idiomatic-qdrant/lib/qdrant/api/root.rb
Comment thread samples/client/others/ruby-idiomatic-qdrant/lib/qdrant/api/collections.rb Outdated
n-rodriguez added a commit to n-rodriguez/openapi-generator that referenced this pull request Jul 7, 2026
…time bugs

From the automated review on PR OpenAPITools#24220:
- Faraday only accepts lowercase HTTP method symbols; normalize the method in
  Connection#call so operations no longer raise ArgumentError on every real call.
- Percent-encode path parameters (ERB::Util.url_encode) instead of raw to_s.
- Apply authentication PER REQUEST instead of baking it once into the persistent
  Faraday connection, so access_token / api_key changes (token refresh, credential
  rotation) take effect on subsequent requests.
- Honour the apiKey location: header / query / cookie (not always a header).
- Only materialize an OpenAPI `default` for REQUIRED properties; optional fields
  stay nil so Serializable#to_hash omits them and the server default applies.
- Harden the generated GitHub CI workflow with `permissions: contents: read`.

Templates only; both samples regenerated, rspec green (16/0, 577/0) and rubocop clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@n-rodriguez

Copy link
Copy Markdown
Contributor Author

Thanks for the automated review — went through all 40 findings. Pushed fixes for the genuine runtime bugs and want to explain the ones left as-is.

Fixed (commit pushed):

  • Uppercase HTTP method → normalized to lowercase in Connection#call (Faraday's run_request rejects :GET). This was the important one — confirmed it raised ArgumentError on every real call; the specs only assert reachability so it stayed latent.
  • Path params now percent-encoded (ERB::Util.url_encode) instead of raw to_s.
  • Auth applied per-request instead of baked once into the persistent Faraday connection, so token refresh / credential rotation now works.
  • apiKey location honoured: header / query / cookie (was always a header).
  • Optional-field defaults no longer materialized — only required fields get their default, so to_hash keeps omitting unset optionals (e.g. HnswConfig#max_indexing_threads).
  • CI workflow hardened with permissions: contents: read.

Left as designed (with reasoning):

  • "required not enforced in initialize" (~12 findings): required is enforced — via list_invalid_properties / valid?. Validation is explicit and on-demand (the attribute DSL design), not a constructor-time raise. Same posture as the model layer's valid? contract.
  • "silently ignores unknown keyword arguments" (~15 findings): deliberate lenient construction; from_hash also relies on it to skip keys with no matching attribute.
  • oneOf first-match: pragmatic; strict exactly-one is a possible future refinement.
  • "qdrant.rb missing collapse": false positive — @loader.collapse is conditional ({{^apiNamespacePresent}}) and is correctly omitted when the Api wrapper is enabled (the default). The comment above it explains the case.

Both samples regenerate green (petstore 16/0, qdrant 577/0) and pass rubocop with no offenses.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 30 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread modules/openapi-generator/src/main/resources/ruby-idiomatic/connection.mustache Outdated
@wing328 wing328 added this to the 7.24.0 milestone Jul 7, 2026
@n-rodriguez n-rodriguez force-pushed the ruby-idiomatic branch 4 times, most recently from 473e551 to 094af17 Compare July 8, 2026 01:35
Add `ruby-idiomatic`, a new opt-in Ruby client generator that emits modern,
DRY, multi-instance Ruby. It mirrors the Crystal idiomatic redesign as a
SEPARATE generator: the existing `ruby` generator, its templates, and its
samples are left byte-for-byte unchanged, so users opt in and migrate when
they choose.

Highlights:
- Namespaced sub-clients (`client.dcim.cable_terminations.list`) via a
  path->route helper (RubyApiRouting) and a hybrid module nesting
  (moduleName + configurable `apiNamespace` wrapper, resource leaf compact).
- A single `Connection#call` choke-point (Faraday) — keyword args everywhere,
  no `_with_http_info` twins; a delegating `Response` and one rich `ApiError`
  (all Faraday errors wrapped); per-request auth (header/query/cookie apiKey,
  basic, bearer) so token refresh/rotation works; a `Configuration#use`
  Faraday-middleware seam for request signing.
- Native multi-instance: a `Client` facade owns a per-instance
  Configuration/Connection — no global singleton.
- Models on a declarative `attribute` DSL + shared `Serializable`/`Validations`
  mixins; recursive `from_hash` deserializes nested models/arrays/maps/unions/
  enums; `additionalProperties` preserved; anyOf/oneOf via a `Polymorphism`
  helper; array minItems/maxItems validated; numeric enums bare.
- Names are always legal Ruby: enum constants and method names derived from
  digit-leading values/paths get an `N`/`call_` prefix, purely-symbolic enum
  values (telephony IVR keys `#`/`*`, ...) map to word names, and deeply-nested
  inline model names are shortened with a deterministic hash suffix to stay
  under tar's 100-byte path-component limit so `gem build` succeeds.
- Class loading via Zeitwerk. A nested `moduleName` (e.g. `Ovh::Api`) is
  supported: the parent modules are predefined, the loader is driven
  explicitly, the gemspec reads VERSION by regex, and version.rb is required
  rather than autoloaded. Acronym names in both model and API resource classes
  (HTTPConfig, IPRestriction, DedicatedCloud::TwoFAWhitelist, ...) register
  explicit Zeitwerk inflections from the gem entrypoint so autoloading resolves
  the real constant instead of raising Zeitwerk::NameError.
- Faraday only; form/multipart (incl. file upload); Ruby 3.0 floor;
  frozen_string_literal on every generated Ruby file; single-quoted strings;
  path params percent-encoded; FeatureSet declared honestly (JSON only, no XML,
  allOf/anyOf/oneOf/Union, apiKey/basic/bearer).
- Generated gem passes RuboCop clean out of the box: a real `.rubocop.yml`
  (rubocop-performance/rake/rspec), a modern gemspec with configurable gem*
  options (author/homepage/summary/license, MIT-aware LICENSE), LICENSE, dev
  deps + binstubs, `.rspec`, spec_helper (SimpleCov), a commented `.gitignore`,
  a GitHub Actions CI workflow, and meaningful RSpec.

Generated runtime is strict where a caller can err and lenient where the server can
drift: model constructors reject unknown keyword args and validate required attributes,
Configuration rejects unknown options, and required API params are nil-guarded, while
from_hash deserialization bypasses that validation to tolerate server omissions/drift.
Authentication is scoped per operation (only the schemes an operation declares are
applied, so credentials never leak to `security: []` endpoints); oneOf/anyOf resolve
strictly (ambiguous or unmatched payloads raise); JSON parse errors are wrapped in
ApiError; deeply-nested request paths keep any base_url path prefix; and a multi-tagged
operation yields a single method per path+verb instead of duplicates.

Samples: petstore and a real-world Qdrant client, both green and rubocop-clean.
Codegen unit tests (RubyIdiomaticClientCodegenTest, RubyApiRoutingTest) pass. A
samples CI workflow and the generated generator docs are included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants