diff --git a/.github/workflows/samples-ruby-idiomatic.yaml b/.github/workflows/samples-ruby-idiomatic.yaml
new file mode 100644
index 000000000000..074824df14df
--- /dev/null
+++ b/.github/workflows/samples-ruby-idiomatic.yaml
@@ -0,0 +1,34 @@
+name: Samples Ruby Idiomatic
+
+on:
+ push:
+ paths:
+ - 'samples/client/petstore/ruby-idiomatic/**'
+ - 'samples/client/others/ruby-idiomatic-qdrant/**'
+ pull_request:
+ paths:
+ - 'samples/client/petstore/ruby-idiomatic/**'
+ - 'samples/client/others/ruby-idiomatic-qdrant/**'
+
+jobs:
+ build:
+ name: Build Ruby Idiomatic client
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ sample:
+ - samples/client/petstore/ruby-idiomatic
+ ruby-version:
+ - '3.0'
+ - '3.3'
+ steps:
+ - uses: actions/checkout@v7
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby-version }}
+ - name: Install and test
+ working-directory: ${{ matrix.sample }}
+ run: |
+ bundle install
+ bundle exec rspec
diff --git a/bin/configs/ruby-idiomatic-qdrant.yaml b/bin/configs/ruby-idiomatic-qdrant.yaml
new file mode 100644
index 000000000000..c8f813d3dae9
--- /dev/null
+++ b/bin/configs/ruby-idiomatic-qdrant.yaml
@@ -0,0 +1,13 @@
+generatorName: ruby-idiomatic
+outputDir: samples/client/others/ruby-idiomatic-qdrant
+inputSpec: modules/openapi-generator/src/test/resources/3_0/crystal/qdrant.json
+templateDir: modules/openapi-generator/src/main/resources/ruby-idiomatic
+globalProperties:
+ apiTests: "true"
+ modelTests: "true"
+ apiDocs: "false"
+ modelDocs: "false"
+additionalProperties:
+ gemName: qdrant
+ moduleName: Qdrant
+ gemVersion: 1.0.0
diff --git a/bin/configs/ruby-idiomatic.yaml b/bin/configs/ruby-idiomatic.yaml
new file mode 100644
index 000000000000..57d6b6fae3e7
--- /dev/null
+++ b/bin/configs/ruby-idiomatic.yaml
@@ -0,0 +1,13 @@
+generatorName: ruby-idiomatic
+outputDir: samples/client/petstore/ruby-idiomatic
+inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
+templateDir: modules/openapi-generator/src/main/resources/ruby-idiomatic
+globalProperties:
+ apiTests: "true"
+ modelTests: "true"
+ apiDocs: "false"
+ modelDocs: "false"
+additionalProperties:
+ gemName: petstore
+ moduleName: Petstore
+ gemVersion: 1.0.0
diff --git a/docs/generators.md b/docs/generators.md
index ed22c4b53e16..2729a40542aa 100644
--- a/docs/generators.md
+++ b/docs/generators.md
@@ -58,6 +58,7 @@ The following generators are available:
* [python-pydantic-v1](generators/python-pydantic-v1.md)
* [r](generators/r.md)
* [ruby](generators/ruby.md)
+* [ruby-idiomatic (beta)](generators/ruby-idiomatic.md)
* [rust](generators/rust.md)
* [scala-akka](generators/scala-akka.md)
* [scala-gatling](generators/scala-gatling.md)
diff --git a/docs/generators/ruby-idiomatic.md b/docs/generators/ruby-idiomatic.md
new file mode 100644
index 000000000000..e8be3db0abe1
--- /dev/null
+++ b/docs/generators/ruby-idiomatic.md
@@ -0,0 +1,230 @@
+---
+title: Documentation for the ruby-idiomatic Generator
+---
+
+## METADATA
+
+| Property | Value | Notes |
+| -------- | ----- | ----- |
+| generator name | ruby-idiomatic | pass this to the generate command after -g |
+| generator stability | BETA | |
+| generator type | CLIENT | |
+| generator language | Ruby | |
+| generator default templating engine | mustache | |
+| helpTxt | Generates an idiomatic Ruby client library (Faraday, Zeitwerk, namespaced sub-clients). | |
+
+## CONFIG OPTIONS
+These options may be applied as additional-properties (cli) or configOptions (plugins). Refer to [configuration docs](https://openapi-generator.tech/docs/configuration) for more details.
+
+| Option | Description | Values | Default |
+| ------ | ----------- | ------ | ------- |
+|allowUnicodeIdentifiers|boolean, toggles whether unicode identifiers are allowed in names or not, default is false| |false|
+|apiNamespace|Module wrapper nesting the API resource classes (default 'Api'); set empty to disable the wrapper.| |Api|
+|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|
- **false**
- The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.
- **true**
- Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.
|true|
+|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true|
+|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response. With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.|- **false**
- No changes to the enums are made, this is the default option.
- **true**
- With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the enum case sent by the server is not known by the client/spec, can safely be decoded to this case.
|false|
+|gemAuthor|gem author (only one is supported).| |OpenAPI-Generator|
+|gemAuthorEmail|gem author email (only one is supported).| |null|
+|gemDescription|gem description.| |A Ruby client generated by openapi-generator|
+|gemHomepage|gem homepage.| |https://openapitools.org|
+|gemLicense|gem license.| |unlicense|
+|gemName|gem name (convention: underscore_case).| |null|
+|gemSummary|gem summary.| |A Ruby client generated by openapi-generator|
+|gemVersion|gem version.| |null|
+|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|- **true**
- The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.
- **false**
- The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.
|true|
+|moduleName|top module name (convention: CamelCase, usually corresponding to gem name).| |null|
+|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false|
+|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true|
+|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true|
+
+## IMPORT MAPPING
+
+| Type/Alias | Imports |
+| ---------- | ------- |
+
+
+## INSTANTIATION TYPES
+
+| Type/Alias | Instantiated By |
+| ---------- | --------------- |
+|array|Array|
+|map|Hash|
+|set|Set|
+
+
+## LANGUAGE PRIMITIVES
+
+
+- Array
+- Boolean
+- Date
+- File
+- Float
+- Hash
+- Integer
+- Object
+- String
+- Time
+
+
+## RESERVED WORDS
+
+
+- __file__
+- __line__
+- alias
+- and
+- begin
+- break
+- case
+- class
+- def
+- defined?
+- do
+- else
+- elsif
+- end
+- ensure
+- false
+- for
+- if
+- in
+- module
+- next
+- nil
+- not
+- or
+- redo
+- rescue
+- retry
+- return
+- self
+- super
+- then
+- true
+- undef
+- unless
+- until
+- when
+- while
+- yield
+
+
+## FEATURE SET
+
+
+### Client Modification Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|BasePath|✗|ToolingExtension
+|Authorizations|✗|ToolingExtension
+|UserAgent|✗|ToolingExtension
+|MockServer|✗|ToolingExtension
+
+### Data Type Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|Custom|✗|OAS2,OAS3
+|Int32|✓|OAS2,OAS3
+|Int64|✓|OAS2,OAS3
+|Float|✓|OAS2,OAS3
+|Double|✓|OAS2,OAS3
+|Decimal|✓|ToolingExtension
+|String|✓|OAS2,OAS3
+|Byte|✓|OAS2,OAS3
+|Binary|✓|OAS2,OAS3
+|Boolean|✓|OAS2,OAS3
+|Date|✓|OAS2,OAS3
+|DateTime|✓|OAS2,OAS3
+|Password|✓|OAS2,OAS3
+|File|✓|OAS2
+|Uuid|✗|
+|Array|✓|OAS2,OAS3
+|Null|✗|OAS3
+|AnyType|✗|OAS2,OAS3
+|Object|✓|OAS2,OAS3
+|Maps|✓|ToolingExtension
+|CollectionFormat|✓|OAS2
+|CollectionFormatMulti|✓|OAS2
+|Enum|✓|OAS2,OAS3
+|ArrayOfEnum|✓|ToolingExtension
+|ArrayOfModel|✓|ToolingExtension
+|ArrayOfCollectionOfPrimitives|✓|ToolingExtension
+|ArrayOfCollectionOfModel|✓|ToolingExtension
+|ArrayOfCollectionOfEnum|✓|ToolingExtension
+|MapOfEnum|✓|ToolingExtension
+|MapOfModel|✓|ToolingExtension
+|MapOfCollectionOfPrimitives|✓|ToolingExtension
+|MapOfCollectionOfModel|✓|ToolingExtension
+|MapOfCollectionOfEnum|✓|ToolingExtension
+
+### Documentation Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|Readme|✗|ToolingExtension
+|Model|✓|ToolingExtension
+|Api|✓|ToolingExtension
+
+### Global Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|Host|✓|OAS2,OAS3
+|BasePath|✓|OAS2,OAS3
+|Info|✓|OAS2,OAS3
+|Schemes|✗|OAS2,OAS3
+|PartialSchemes|✓|OAS2,OAS3
+|Consumes|✓|OAS2
+|Produces|✓|OAS2
+|ExternalDocumentation|✓|OAS2,OAS3
+|Examples|✓|OAS2,OAS3
+|XMLStructureDefinitions|✗|OAS2,OAS3
+|MultiServer|✗|OAS3
+|ParameterizedServer|✗|OAS3
+|ParameterStyling|✗|OAS3
+|Callbacks|✓|OAS3
+|LinkObjects|✗|OAS3
+
+### Parameter Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|Path|✓|OAS2,OAS3
+|Query|✓|OAS2,OAS3
+|Header|✓|OAS2,OAS3
+|Body|✓|OAS2
+|FormUnencoded|✓|OAS2
+|FormMultipart|✓|OAS2
+|Cookie|✗|OAS3
+
+### Schema Support Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|Simple|✓|OAS2,OAS3
+|Composite|✓|OAS2,OAS3
+|Polymorphism|✓|OAS2,OAS3
+|Union|✓|OAS3
+|allOf|✓|OAS2,OAS3
+|anyOf|✓|OAS3
+|oneOf|✓|OAS3
+|not|✗|OAS3
+
+### Security Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|BasicAuth|✓|OAS2,OAS3
+|ApiKey|✓|OAS2,OAS3
+|OpenIDConnect|✗|OAS3
+|BearerToken|✓|OAS3
+|OAuth2_Implicit|✗|OAS2,OAS3
+|OAuth2_Password|✗|OAS2,OAS3
+|OAuth2_ClientCredentials|✗|OAS2,OAS3
+|OAuth2_AuthorizationCode|✗|OAS2,OAS3
+|SignatureAuth|✗|OAS3
+|AWSV4Signature|✗|ToolingExtension
+
+### Wire Format Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|JSON|✓|OAS2,OAS3
+|XML|✗|OAS2,OAS3
+|PROTOBUF|✗|ToolingExtension
+|Custom|✗|OAS2,OAS3
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyIdiomaticClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyIdiomaticClientCodegen.java
new file mode 100644
index 000000000000..932f1c5cfd90
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyIdiomaticClientCodegen.java
@@ -0,0 +1,733 @@
+package org.openapitools.codegen.languages;
+
+import io.swagger.v3.oas.models.*;
+import io.swagger.v3.oas.models.media.Schema;
+import org.openapitools.codegen.*;
+import org.openapitools.codegen.languages.rubyidiomatic.RubyApiRouting;
+import org.openapitools.codegen.model.*;
+import org.openapitools.codegen.meta.GeneratorMetadata;
+import org.openapitools.codegen.meta.Stability;
+import org.openapitools.codegen.meta.features.ParameterFeature;
+import org.openapitools.codegen.meta.features.SchemaSupportFeature;
+import org.openapitools.codegen.meta.features.SecurityFeature;
+import org.openapitools.codegen.meta.features.WireFormatFeature;
+import org.openapitools.codegen.templating.mustache.IndentedLambda;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.io.File;
+import java.util.*;
+
+import static org.openapitools.codegen.utils.StringUtils.camelize;
+import static org.openapitools.codegen.utils.StringUtils.underscore;
+
+public class RubyIdiomaticClientCodegen extends AbstractRubyCodegen {
+ private final Logger LOGGER = LoggerFactory.getLogger(RubyIdiomaticClientCodegen.class);
+
+ private static final String GEM_VERSION = "gemVersion";
+ private static final String GEM_VERSION_DESC = "gem version.";
+
+ private static final String GEM_AUTHOR = "gemAuthor";
+ private static final String GEM_AUTHOR_EMAIL = "gemAuthorEmail";
+ private static final String GEM_HOMEPAGE = "gemHomepage";
+ private static final String GEM_SUMMARY = "gemSummary";
+ private static final String GEM_DESCRIPTION = "gemDescription";
+ private static final String GEM_LICENSE = "gemLicense";
+
+ public static final String API_NAMESPACE = "apiNamespace";
+
+ // Names of infra classes generated at the moduleName root (client.rb, connection.rb, ...).
+ // Model names that clash with these are renamed (e.g. "Client" -> "ModelClient") to avoid
+ // Zeitwerk const collisions.
+ private static final Set RESERVED_MODEL_NAMES = new HashSet<>(Arrays.asList(
+ "Client", "Connection", "Configuration", "Response", "ApiError", "Serializable", "Validations", "Polymorphism"));
+
+ // Methods already defined on the generated Client (Client#initialize plus the
+ // configuration/connection attr_readers). A namespace whose accessor would shadow one
+ // of these is suffixed so `client.connection` keeps returning the transport, not a
+ // sub-client. Mirrors the RESERVED_MODEL_NAMES guard, one level up.
+ private static final Set RESERVED_ACCESSOR_NAMES = new HashSet<>(Arrays.asList(
+ "initialize", "configuration", "connection", "client"));
+
+ // Prefix for enum constants whose value begins with a digit. A bare "_" prefix would
+ // collide with Ruby 3.0+ numbered block parameters (_1.._9 are reserved and raise on
+ // assignment); a letter keeps the constant legal. Mirrors the stock ruby generator.
+ private static final String NUMERIC_ENUM_PREFIX = "N";
+
+ // Upper bound (in bytes/chars) for a model file's *base* name (before ".rb"/"_spec.rb").
+ // The tar ustar format used by `gem build` caps every path component at 100 bytes, so
+ // deeply-nested inline body models (whose name concatenates the full request path) must
+ // be shortened. 72 leaves room for the "_spec.rb" test-file suffix (-> <= 80) with margin.
+ private static final int MODEL_FILE_MAX = 72;
+
+ protected String gemName;
+ protected String moduleName;
+ protected String gemVersion = "1.0.0";
+ protected String libFolder = "lib";
+ protected String apiNamespace = "Api";
+ protected String gemAuthor = "OpenAPI-Generator";
+ protected String gemAuthorEmail = "";
+ protected String gemHomepage = "https://openapitools.org";
+ protected String gemSummary = "A Ruby client generated by openapi-generator";
+ protected String gemDescription = "A Ruby client generated by openapi-generator";
+ protected String gemLicense = "unlicense";
+
+ private Set resourceSegments = Collections.emptySet();
+ private String apiBasePrefix = "";
+ private int emptyMethodNameCounter = 0;
+
+ // Accumulated across postProcessModels calls: file basename -> class name, for every
+ // autoloaded model. Consumed by postProcessSupportingFileData to emit Zeitwerk
+ // inflections for names whose acronym casing diverges from the default inflector.
+ private final java.util.LinkedHashMap modelFileToClass = new java.util.LinkedHashMap<>();
+
+ public RubyIdiomaticClientCodegen() {
+ super();
+ generatorMetadata = GeneratorMetadata.newBuilder(generatorMetadata)
+ .stability(Stability.BETA)
+ .build();
+ // Honest capability declaration (final whole-branch review FIX 4): the default
+ // FeatureSet inherited from DefaultCodegen claims XML wire format, Cookie params,
+ // OAuth2 security, and no Union/anyOf/oneOf/allOf schema support -- the exact
+ // inverse of what this generator actually does. Correct it here.
+ modifyFeatureSet(features -> features
+ .wireFormatFeatures(EnumSet.of(WireFormatFeature.JSON))
+ .includeSchemaSupportFeatures(
+ SchemaSupportFeature.Union,
+ SchemaSupportFeature.allOf,
+ SchemaSupportFeature.anyOf,
+ SchemaSupportFeature.oneOf
+ )
+ .excludeParameterFeatures(
+ ParameterFeature.Cookie
+ )
+ .securityFeatures(EnumSet.of(
+ SecurityFeature.ApiKey,
+ SecurityFeature.BasicAuth,
+ SecurityFeature.BearerToken
+ ))
+ );
+ outputFolder = "generated-code" + File.separator + "ruby-idiomatic";
+ modelTemplateFiles.put("model.mustache", ".rb");
+ apiTemplateFiles.put("api.mustache", ".rb");
+ modelTestTemplateFiles.put("model_test.mustache", ".rb");
+ apiTestTemplateFiles.put("api_test.mustache", ".rb");
+ embeddedTemplateDir = templateDir = "ruby-idiomatic";
+ modelPackage = "models";
+ apiPackage = "api";
+ cliOptions.add(new CliOption(CodegenConstants.GEM_NAME, CodegenConstants.GEM_NAME_DESC));
+ cliOptions.add(new CliOption(CodegenConstants.MODULE_NAME, CodegenConstants.MODULE_NAME_DESC));
+ cliOptions.add(new CliOption(GEM_VERSION, GEM_VERSION_DESC));
+ cliOptions.add(new CliOption(API_NAMESPACE, "Module wrapper nesting the API resource classes (default 'Api'); set empty to disable the wrapper.").defaultValue("Api"));
+ cliOptions.add(new CliOption(GEM_AUTHOR, "gem author (only one is supported).").defaultValue(gemAuthor));
+ cliOptions.add(new CliOption(GEM_AUTHOR_EMAIL, "gem author email (only one is supported)."));
+ cliOptions.add(new CliOption(GEM_HOMEPAGE, "gem homepage.").defaultValue(gemHomepage));
+ cliOptions.add(new CliOption(GEM_SUMMARY, "gem summary.").defaultValue(gemSummary));
+ cliOptions.add(new CliOption(GEM_DESCRIPTION, "gem description.").defaultValue(gemDescription));
+ cliOptions.add(new CliOption(GEM_LICENSE, "gem license.").defaultValue(gemLicense));
+ }
+
+ @Override
+ public void processOpts() {
+ super.processOpts();
+ if (additionalProperties.containsKey(CodegenConstants.GEM_NAME)) {
+ setGemName((String) additionalProperties.get(CodegenConstants.GEM_NAME));
+ }
+ if (additionalProperties.containsKey(CodegenConstants.MODULE_NAME)) {
+ setModuleName((String) additionalProperties.get(CodegenConstants.MODULE_NAME));
+ }
+ if (gemName == null && moduleName == null) {
+ setGemName("openapi_client");
+ setModuleName(generateModuleName(gemName));
+ } else if (gemName == null) {
+ setGemName(generateGemName(moduleName));
+ } else if (moduleName == null) {
+ setModuleName(generateModuleName(gemName));
+ }
+ additionalProperties.put(CodegenConstants.GEM_NAME, gemName);
+ additionalProperties.put(CodegenConstants.MODULE_NAME, moduleName);
+ if (additionalProperties.containsKey(GEM_VERSION)) {
+ setGemVersion((String) additionalProperties.get(GEM_VERSION));
+ }
+ additionalProperties.put(GEM_VERSION, gemVersion);
+ if (additionalProperties.containsKey(API_NAMESPACE)) {
+ setApiNamespace((String) additionalProperties.get(API_NAMESPACE));
+ }
+ additionalProperties.put(API_NAMESPACE, apiNamespace);
+ if (additionalProperties.containsKey(GEM_AUTHOR)) {
+ setGemAuthor((String) additionalProperties.get(GEM_AUTHOR));
+ }
+ additionalProperties.put(GEM_AUTHOR, gemAuthor);
+ if (additionalProperties.containsKey(GEM_AUTHOR_EMAIL)) {
+ setGemAuthorEmail((String) additionalProperties.get(GEM_AUTHOR_EMAIL));
+ }
+ additionalProperties.put(GEM_AUTHOR_EMAIL, gemAuthorEmail);
+ if (additionalProperties.containsKey(GEM_HOMEPAGE)) {
+ setGemHomepage((String) additionalProperties.get(GEM_HOMEPAGE));
+ }
+ additionalProperties.put(GEM_HOMEPAGE, gemHomepage);
+ if (additionalProperties.containsKey(GEM_SUMMARY)) {
+ setGemSummary((String) additionalProperties.get(GEM_SUMMARY));
+ }
+ additionalProperties.put(GEM_SUMMARY, gemSummary);
+ if (additionalProperties.containsKey(GEM_DESCRIPTION)) {
+ setGemDescription((String) additionalProperties.get(GEM_DESCRIPTION));
+ }
+ additionalProperties.put(GEM_DESCRIPTION, gemDescription);
+ if (additionalProperties.containsKey(GEM_LICENSE)) {
+ setGemLicense((String) additionalProperties.get(GEM_LICENSE));
+ }
+ additionalProperties.put(GEM_LICENSE, gemLicense);
+ // explicit boolean: jmustache treats an empty string section as truthy, so guard the
+ // api namespace `module` wrapper on this instead of on {{#apiNamespace}}.
+ additionalProperties.put("apiNamespacePresent", apiNamespace != null && !apiNamespace.isEmpty());
+ // api.mustache renders the class body from a single column-0 partial (api_operations)
+ // and re-indents it via these lambdas so the body sits at the right depth in BOTH module
+ // shapes: 4 spaces when the `module ` wrapper is present (moduleName + Api +
+ // class), 2 spaces when it is absent (single `module ` + class, the nested
+ // moduleName case). skipEmptyLines=true keeps blank separator lines free of the trailing
+ // whitespace RuboCop's Layout/TrailingWhitespace would otherwise flag.
+ additionalProperties.put("indent2", new IndentedLambda(2, " ", false, true));
+ additionalProperties.put("indent4", new IndentedLambda(4, " ", false, true));
+ // Nested moduleName support (e.g. "Dolibarr::Api"): the generated files open
+ // `module Dolibarr::Api` directly, which raises NameError unless the parent
+ // `Dolibarr` already exists. Expose the cumulative parent prefixes so gem.rb /
+ // the gemspec can pre-define them, and flag the nested case so those templates
+ // switch to `Zeitwerk::Loader.new` + `push_dir(namespace:)` (for_gem can't target
+ // a nested namespace). Single-level modules keep the exact prior output.
+ String[] moduleParts = moduleName.split("::");
+ boolean moduleNameNested = moduleParts.length > 1;
+ additionalProperties.put("moduleNameNested", moduleNameNested);
+ if (moduleNameNested) {
+ List parents = new ArrayList<>();
+ StringBuilder acc = new StringBuilder();
+ for (int i = 0; i < moduleParts.length - 1; i++) {
+ if (i > 0) acc.append("::");
+ acc.append(moduleParts[i]);
+ parents.add(acc.toString());
+ }
+ additionalProperties.put("moduleNameParents", parents);
+ }
+ // LICENSE.mustache emits the Unlicense text by default; flag MIT so it can render
+ // the MIT license body (and keep s.license in the gemspec consistent with the file).
+ additionalProperties.put("isLicenseMIT", "MIT".equalsIgnoreCase(gemLicense));
+
+ String gemFolder = libFolder + File.separator + gemName;
+ supportingFiles.add(new SupportingFile("gem.mustache", libFolder, gemName + ".rb"));
+ supportingFiles.add(new SupportingFile("client.mustache", gemFolder, "client.rb"));
+ supportingFiles.add(new SupportingFile("connection.mustache", gemFolder, "connection.rb"));
+ supportingFiles.add(new SupportingFile("configuration.mustache", gemFolder, "configuration.rb"));
+ supportingFiles.add(new SupportingFile("response.mustache", gemFolder, "response.rb"));
+ supportingFiles.add(new SupportingFile("api_error.mustache", gemFolder, "api_error.rb"));
+ supportingFiles.add(new SupportingFile("serializable.mustache", gemFolder, "serializable.rb"));
+ supportingFiles.add(new SupportingFile("validations.mustache", gemFolder, "validations.rb"));
+ supportingFiles.add(new SupportingFile("polymorphism.mustache", gemFolder, "polymorphism.rb"));
+ supportingFiles.add(new SupportingFile("version.mustache", gemFolder, "version.rb"));
+ supportingFiles.add(new SupportingFile("gemspec.mustache", "", gemName + ".gemspec"));
+ supportingFiles.add(new SupportingFile("Gemfile.mustache", "", "Gemfile"));
+ supportingFiles.add(new SupportingFile("Rakefile.mustache", "", "Rakefile"));
+ supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
+ supportingFiles.add(new SupportingFile("LICENSE.mustache", "", "LICENSE"));
+ supportingFiles.add(new SupportingFile("rubocop.mustache", "", ".rubocop.yml"));
+ supportingFiles.add(new SupportingFile("rspec.mustache", "", ".rspec"));
+ supportingFiles.add(new SupportingFile("gitignore.mustache", "", ".gitignore"));
+ supportingFiles.add(new SupportingFile("spec_helper.mustache", "spec", "spec_helper.rb"));
+ supportingFiles.add(new SupportingFile("github-workflow.mustache", ".github/workflows", "ci.yml"));
+ supportingFiles.add(new SupportingFile("bin_rspec.mustache", "bin", "rspec"));
+ supportingFiles.add(new SupportingFile("bin_rubocop.mustache", "bin", "rubocop"));
+ }
+
+ public void setGemName(String gemName) { this.gemName = gemName; }
+ public void setModuleName(String moduleName) { this.moduleName = moduleName; }
+ public void setGemVersion(String v) { this.gemVersion = v; }
+ public void setApiNamespace(String apiNamespace) { this.apiNamespace = apiNamespace; }
+ public void setGemAuthor(String gemAuthor) { this.gemAuthor = gemAuthor; }
+ public void setGemAuthorEmail(String gemAuthorEmail) { this.gemAuthorEmail = gemAuthorEmail; }
+ public void setGemHomepage(String gemHomepage) { this.gemHomepage = gemHomepage; }
+ public void setGemSummary(String gemSummary) { this.gemSummary = gemSummary; }
+ public void setGemDescription(String gemDescription) { this.gemDescription = gemDescription; }
+ public void setGemLicense(String gemLicense) { this.gemLicense = gemLicense; }
+ public String generateModuleName(String gemName) { return camelize(gemName.replaceAll("[^\\w]+", "_")); }
+ public String generateGemName(String moduleName) { return underscore(moduleName.replaceAll("[^\\w]+", "")); }
+
+ @Override
+ public String apiFileFolder() {
+ return outputFolder + File.separator + libFolder + File.separator + gemName + File.separator + apiPackage.replace("/", File.separator);
+ }
+
+ @Override
+ public String modelFileFolder() {
+ return outputFolder + File.separator + libFolder + File.separator + gemName + File.separator + modelPackage.replace("/", File.separator);
+ }
+
+ @Override
+ public String apiTestFileFolder() {
+ return outputFolder + File.separator + "spec" + File.separator + apiPackage.replace("/", File.separator);
+ }
+
+ @Override
+ public String modelTestFileFolder() {
+ return outputFolder + File.separator + "spec" + File.separator + modelPackage.replace("/", File.separator);
+ }
+
+ @Override
+ public CodegenType getTag() {
+ return CodegenType.CLIENT;
+ }
+
+ @Override
+ public String getName() {
+ return "ruby-idiomatic";
+ }
+
+ @Override
+ public String getHelp() {
+ return "Generates an idiomatic Ruby client library (Faraday, Zeitwerk, namespaced sub-clients).";
+ }
+
+ // Ruby method names cannot be empty or begin with a digit. Non-pathological names are
+ // delegated unchanged to the parent (reserved-word handling + underscore), so existing
+ // generated method names are untouched; only empty and digit-leading actions are renamed
+ // (e.g. OVH price-plan segments like "2013v1_bhs1a_filer_hourly" -> "call_2013v1_...").
+ @Override
+ public String toOperationId(String operationId) {
+ if (operationId == null || operationId.isEmpty()) {
+ String renamed = "empty_method_name_" + emptyMethodNameCounter++;
+ LOGGER.warn("ruby-idiomatic: empty method name renamed to `{}`", renamed);
+ return renamed;
+ }
+ if (operationId.matches("^\\d.*")) {
+ String renamed = underscore("call_" + operationId);
+ LOGGER.warn("ruby-idiomatic: method name `{}` starts with a digit, renamed to `{}`", operationId, renamed);
+ return renamed;
+ }
+ return super.toOperationId(operationId);
+ }
+
+ // AbstractRubyCodegen escapes a keyword-named identifier by PREPENDING "_" (module ->
+ // _module). But these params are *used* in the method body, so a leading underscore
+ // trips RuboCop's Lint/UnderscorePrefixedVariableName ("used variable with `_` prefix").
+ // Append the underscore instead (module -> module_): the conventional keyword escape that
+ // no cop flags. reservedWordsMappings still win, matching the parent's contract.
+ @Override
+ public String escapeReservedWord(String name) {
+ if (this.reservedWordsMappings().containsKey(name)) {
+ return this.reservedWordsMappings().get(name);
+ }
+ return name + "_";
+ }
+
+ @Override
+ public String toModelName(String name) {
+ String camelized = camelize(sanitizeName(name));
+ if (RESERVED_MODEL_NAMES.contains(camelized) || isReservedWord(camelized.toLowerCase(Locale.ROOT))) {
+ camelized = "Model" + camelized;
+ }
+ return shortenModelName(camelized);
+ }
+
+ // Shorten model names whose file form would overflow tar's per-component limit (see
+ // MODEL_FILE_MAX). The head is trimmed until underscore(head + suffix) fits, and a stable
+ // hash of the *full* name is appended so distinct long names never collide even when
+ // their truncated heads coincide. This is the single source of truth for model naming --
+ // toModelFilename derives the file from underscore(this), keeping Zeitwerk consistent.
+ private String shortenModelName(String camelized) {
+ if (underscore(camelized).length() <= MODEL_FILE_MAX) {
+ return camelized;
+ }
+ // "H" keeps the suffix a clean camel segment (uppercase-led, underscores to one word).
+ String suffix = "H" + fnv1a32Hex(camelized);
+ String head = camelized;
+ while (!head.isEmpty() && underscore(head + suffix).length() > MODEL_FILE_MAX) {
+ head = head.substring(0, head.length() - 1);
+ }
+ String shortened = head + suffix;
+ LOGGER.warn("ruby-idiomatic: model name too long for tar, shortened `{}` -> `{}`", camelized, shortened);
+ return shortened;
+ }
+
+ // Deterministic 32-bit FNV-1a hash rendered as 8 lowercase hex chars. Used only to
+ // disambiguate truncated model names; no cryptographic properties are required.
+ private static String fnv1a32Hex(String s) {
+ int h = 0x811c9dc5;
+ for (int i = 0; i < s.length(); i++) {
+ h ^= s.charAt(i);
+ h *= 0x01000193;
+ }
+ return String.format(Locale.ROOT, "%08x", h);
+ }
+
+ // DefaultCodegen prefixes a leading-digit enum value with "_", producing illegal Ruby
+ // constants (_1.._9 are reserved numbered block parameters in Ruby 3.0+). Mirror the
+ // stock ruby generator instead: a letter prefix keeps every constant legal.
+ @Override
+ public String toEnumVarName(String name, String datatype) {
+ if (enumNameMapping.containsKey(name)) {
+ return enumNameMapping.get(name);
+ }
+ if (name.isEmpty()) {
+ return "EMPTY";
+ }
+ if ("Integer".equals(datatype) || "Float".equals(datatype)) {
+ String varName = name
+ .replace("-", "MINUS_")
+ .replace("+", "PLUS_")
+ .replace(".", "_DOT_");
+ return varName.matches("^\\d.*") ? NUMERIC_ENUM_PREFIX + varName : varName;
+ }
+ // Standard case (mirrors the stock ruby generator): upcase the underscored value.
+ // Keeps meaningful separators intact -- "average_vector" -> "AVERAGE_VECTOR",
+ // "best-score" -> "BEST_SCORE".
+ String enumName = sanitizeName(underscore(name).toUpperCase(Locale.ROOT))
+ .replaceFirst("^_+", "")
+ .replaceFirst("_+$", "");
+ if (enumName.isEmpty()) {
+ // Purely-symbolic values (telephony IVR keys "#"/"*", comparison operators, ...)
+ // sanitize to nothing. Translate each symbol to its word name (e.g. "#" -> "Hash",
+ // "*" -> "Star") so the constant is legal AND meaningful.
+ String translated = org.openapitools.codegen.utils.StringUtils
+ .escape(name, specialCharReplacements, null, null);
+ enumName = sanitizeName(underscore(translated).toUpperCase(Locale.ROOT))
+ .replaceFirst("^_+", "")
+ .replaceFirst("_+$", "");
+ }
+ if (enumName.isEmpty()) {
+ // Nothing survived even word-translation -> deterministic, collision-free fallback.
+ enumName = "VALUE_" + fnv1a32Hex(name).toUpperCase(Locale.ROOT);
+ }
+ return enumName.matches("^\\d.*") ? NUMERIC_ENUM_PREFIX + enumName : enumName;
+ }
+
+ @Override
+ public String toModelFilename(String name) {
+ return underscore(toModelName(name));
+ }
+
+ @Override
+ public String toModelTestFilename(String name) {
+ return toModelFilename(name) + "_spec";
+ }
+
+ @Override
+ public String toApiTestFilename(String name) {
+ return toApiFilename(name) + "_spec";
+ }
+
+ @Override
+ public String toApiFilename(String name) {
+ String[] parts = name.split("/");
+ List out = new ArrayList<>();
+ for (String p : parts) out.add(underscore(p.replace('-', '_')));
+ // Join with '/' (not File.separator): the api sub-path must be identical on all
+ // platforms so generated output and the FILES manifest match on Windows too.
+ return String.join("/", out);
+ }
+
+ @Override
+ public String toApiName(String name) {
+ // HYBRID NAMESPACING (design spec decision #13, mirrors CrystalClientCodegen):
+ // this produces the COMPACT resource leaf ("dcim/cable-terminations" ->
+ // "Dcim::CableTerminations"). The api.mustache template nests moduleName + the
+ // `Api` wrapper as `module` blocks, then declares `class ` compact.
+ // Unlike Crystal, we deliberately do NOT prefix the api-namespace wrapper here
+ // (Crystal prefixes then strips via x-cr-api-inner-class) -- keep this split.
+ // The `Api` wrapper itself is templated via the `apiNamespace` CliOption (default
+ // "Api", empty disables it) and applied purely in api.mustache; see API_NAMESPACE.
+ String[] parts = name.split("/");
+ List out = new ArrayList<>();
+ for (String p : parts) out.add(camelize(p.replace('-', '_')));
+ return String.join("::", out);
+ }
+
+ @Override
+ public String toEnumValue(String value, String datatype) {
+ if ("Integer".equals(datatype) || "Float".equals(datatype)) {
+ return value;
+ }
+ return "'" + escapeText(value).replace("'", "\\'") + "'";
+ }
+
+ // DefaultCodegen#getSchemaType returns the raw OAI type (e.g. "number", "integer") for
+ // top-level (model-level) enum schemas, whereas CodegenProperty#dataType goes through
+ // getTypeDeclaration/typeMapping and already yields "Integer"/"Float". Without this
+ // override, model-level numeric enums (e.g. a schema whose type is `number`/`integer` with
+ // an `enum:` list) would never match the toEnumValue check above and would stay quoted.
+ // Mirrors RubyClientCodegen.getSchemaType.
+ @Override
+ public String getSchemaType(Schema schema) {
+ String openAPIType = super.getSchemaType(schema);
+ String type;
+ if (typeMapping.containsKey(openAPIType)) {
+ type = typeMapping.get(openAPIType);
+ if (languageSpecificPrimitives.contains(type)) {
+ return type;
+ }
+ } else {
+ type = openAPIType;
+ }
+ if (type == null) {
+ return null;
+ }
+ return toModelName(type);
+ }
+
+ @Override
+ public void preprocessOpenAPI(OpenAPI openAPI) {
+ super.preprocessOpenAPI(openAPI);
+ Set paths = openAPI.getPaths() == null
+ ? Collections.emptySet() : openAPI.getPaths().keySet();
+ this.resourceSegments = RubyApiRouting.resourceSegments(paths);
+ this.apiBasePrefix = additionalProperties.containsKey("apiBasePath")
+ ? stripSlashes((String) additionalProperties.get("apiBasePath"))
+ : RubyApiRouting.commonBasePrefix(paths);
+ }
+
+ private static String stripSlashes(String s) {
+ return s == null ? "" : s.replaceAll("^/+", "").replaceAll("/+$", "");
+ }
+
+ @Override
+ public void addOperationToGroup(String tag, String resourcePath, Operation operation,
+ CodegenOperation co, Map> operations) {
+ RubyApiRouting.Route r = RubyApiRouting.route(
+ co.path, co.httpMethod, co.operationId, resourceSegments, apiBasePrefix);
+ String groupKey = r.resource == null ? r.namespace : r.namespace + "/" + r.resource;
+ co.operationId = toOperationId(r.action);
+ co.vendorExtensions.put("x-rb-namespace", r.namespace);
+ if (r.resource != null) co.vendorExtensions.put("x-rb-resource", r.resource);
+ co.baseName = groupKey;
+ List opList = operations.computeIfAbsent(groupKey, k -> new ArrayList<>());
+ // An operation carrying multiple tags is delivered once per tag; because we group by
+ // PATH (not by tag), those extra deliveries would emit duplicate methods (a single
+ // GET .../cluster becoming both `cluster` and `cluster_get`). A path+verb pair is
+ // unique in an OpenAPI document, so if it is already present this is a re-delivery --
+ // skip it.
+ for (CodegenOperation existing : opList) {
+ if (existing.path.equals(co.path) && existing.httpMethod.equals(co.httpMethod)) {
+ return;
+ }
+ }
+ // operationId uniqueness within the group (verb suffix on collision);
+ // re-scan after each mutation so 2+ prior collisions are handled correctly.
+ String unique = co.operationId;
+ int counter = 0;
+ boolean clash = uniqueClash(opList, unique);
+ while (clash) {
+ unique = co.operationId + "_" + co.httpMethod.toLowerCase(Locale.ROOT)
+ + (counter == 0 ? "" : "_" + counter);
+ counter++;
+ clash = uniqueClash(opList, unique);
+ }
+ if (!unique.equals(co.operationId)) {
+ LOGGER.warn("ruby-idiomatic: action collision, renamed `{}` -> `{}`", co.operationId, unique);
+ co.operationId = unique;
+ }
+ opList.add(co);
+ }
+
+ /** Returns true if any operation in {@code opList} already uses {@code candidate} as its operationId. */
+ private static boolean uniqueClash(List opList, String candidate) {
+ for (CodegenOperation op : opList) {
+ if (op.operationId.equals(candidate)) return true;
+ }
+ return false;
+ }
+
+ // Zeitwerk's default Inflector#camelize, reproduced to detect divergence:
+ // basename.split('_').map { seg -> Character.toUpperCase(seg[0]) + seg.substring(1) }.join
+ // Segments are already lowercase in file names, so acronyms lose their casing here.
+ private static String zeitwerkCamelize(String filename) {
+ StringBuilder b = new StringBuilder();
+ for (String seg : filename.split("_")) {
+ if (seg.isEmpty()) continue;
+ b.append(Character.toUpperCase(seg.charAt(0))).append(seg.substring(1));
+ }
+ return b.toString();
+ }
+
+ // Last "::"-separated component of a (possibly qualified) constant name -- the leaf that
+ // Zeitwerk resolves from a single file/dir basename. "DedicatedCloud::TwoFAWhitelist" ->
+ // "TwoFAWhitelist"; a bare "Config" is returned unchanged.
+ private static String lastConstantSegment(String className) {
+ int i = className.lastIndexOf("::");
+ return i < 0 ? className : className.substring(i + 2);
+ }
+
+ // A namespace accessor that would collide with the Client's own methods is suffixed
+ // (e.g. a namespace named "connection" -> `client.connection_api`) so the real
+ // configuration/connection accessors keep working.
+ private static String safeAccessorName(String name) {
+ return RESERVED_ACCESSOR_NAMES.contains(name) ? name + "_api" : name;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Map postProcessSupportingFileData(Map objs) {
+ Map> nsMap = new TreeMap<>();
+ Map> resourcesByNs = new TreeMap<>();
+ Map apiInfo = (Map) objs.get("apiInfo");
+ if (apiInfo != null) {
+ List