Skip to content

feat(csharp): support server URL variables (region/edge routing)#16981

Open
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1783552163-csharp-server-url-variables
Open

feat(csharp): support server URL variables (region/edge routing)#16981
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1783552163-csharp-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 (region/edge) routing support to the C# SDK generator, matching the behavior shipped for TypeScript in #16977 (and the existing Python/Java implementations).

When an API's OpenAPI servers[].variables are modeled in the IR as EnvironmentsConfig with urlTemplate(s) + urlVariables, the generator now exposes each variable as an optional client-construction option and interpolates the provided values into the environment base URL(s) at runtime. When a variable is omitted, its IR default is used, so behavior is unchanged for existing SDKs / APIs that declare no server variables.

Changes Made

  • New helper generators/csharp/sdk/src/root-client/serverVariables.ts:
    • getServerVariableOptions(environmentsConfig, caseConverter) extracts the variables from the first environment that declares them (handling both singleBaseUrl and multipleBaseUrls), de-dups by id, maps each name to an idiomatic PascalCase option, and de-collides reserved ClientOptions names by prefixing ServerUrl (so a variable named environment is surfaced as ServerUrlEnvironment).
    • urlTemplateToInterpolatedString(template, options) turns a {id} URL template into a C# interpolated string ($"https://api.{_region}.example.com").
  • ClientOptionsGenerator: emits an optional string? property per server variable (e.g. Region, ServerUrlEnvironment) with doc comments listing allowed values + default, and makes the BaseUrl/Environment property settable (rather than init-only) when server variables exist so the root client can rebuild it.
  • RootClientGenerator: in the client constructor, when any server variable is set, rebuilds the environment URL(s) from the template(s) — for multipleBaseUrls each named base URL is rebuilt from its own template (a template referencing only a subset of variables is fine).
  • Added the server-url-templating fixture to seed/csharp-sdk/seed.yml and committed the regenerated output under seed/csharp-sdk/server-url-templating/.
  • Added a changelog entry (type: feat) under generators/csharp/sdk/changes/unreleased/.

Generated ClientOptions (excerpt):

public SeedApiEnvironment Environment { get; set; } = SeedApiEnvironment.RegionalApiServer;

/// The Region to route requests to. Allowed values: us-east-1, us-west-2, eu-west-1. Defaults to "us-east-1".
public string? Region { get; init; }

/// The ServerUrlEnvironment to route requests to. Allowed values: prod, staging, dev. Defaults to "prod".
public string? ServerUrlEnvironment { get; init; }

Generated root client constructor (excerpt):

if (clientOptions.Region != null || clientOptions.ServerUrlEnvironment != null)
{
    var _region = clientOptions.Region ?? "us-east-1";
    var _serverUrlEnvironment = clientOptions.ServerUrlEnvironment ?? "prod";
    clientOptions.Environment = new SeedApiEnvironment
    {
        Base = $"https://api.{_region}.{_serverUrlEnvironment}.example.com/v1",
        Auth = $"https://auth.{_region}.example.com",
    };
}

Testing

  • Unit tests added/updated — serverVariables.test.ts covers extraction, dedup, reserved-name de-collision, and template interpolation (54/54 generator tests pass).
  • Manual testing completed — seed test --generator csharp-sdk --fixture server-url-templating --local returns success; the generated SDK builds with 0 Error(s) across net462/netstandard2.0/net8.0/net9.0.
  • Scope kept minimal: no changes to the IR schema, versions.yml, other generators, or unrelated fixtures.

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/db6628b6fc0f4fa7bfeb1d8911f7c69f
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 C# SDK support for server URL variables. The generator logic is reasonable, but there's a correctness bug: the ClientOptions properties are generated as init while the constructor mutates them via set, contradicting the PR's own stated intent to make them settable.

  • 🟡 2 warning(s)
  • 🔵 2 suggestion(s)

Comment on lines +130 to +131
get: true,
init: true,

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

The server variable fields are emitted with init: true, but the PR description's excerpt shows them as init while the root client mutates clientOptions.Environment/BaseUrl. That part is fine (Environment/BaseUrl get set when variables exist). However, note the doc comment in the PR shows public string? Region { get; init; } — that's consistent here. The real thing to double check: Region/ServerUrlEnvironment being init-only is correct since the constructor only reads them. No change needed, but verify the summary doc comment renders (currently passed as summary, not a doc-tagged field).

return collectServerVariables(environmentsConfig).map((variable) => {
const pascal = caseConverter.pascalSafe(variable.name);
const optionName = RESERVED_OPTION_NAMES.has(pascal)
? caseConverter.pascalSafe(`server url ${getOriginalName(variable.name)}`)

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

De-collision uses getOriginalName(variable.name) for the reserved-name branch but variable.name for the normal branch. If variable.name is a NameAndWireValue/object, pascalSafe(variable.name) may not behave as expected. Confirm both branches feed pascalSafe the same shape of input — inconsistent handling could produce mismatched option names vs. what tests assume.

export function urlTemplateToInterpolatedString(template: string, options: ServerVariableOption[]): string {
let result = escapeForCSharpString(template);
for (const { variable, localName } of options) {
result = result.split(`{${variable.id}}`).join(`{${localName}}`);

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

result.split(\{${variable.id}}`).join(...)runsescapeForCSharpString` first, then substitutes. If a template placeholder id ever contains regex/brace-special chars it's fine (string split, not regex), but be aware braces in the literal URL that aren't placeholders would be left as-is and become invalid C# interpolation syntax. Low risk, worth a comment or a guard if arbitrary URLs are possible.

private writeServerVariableLocals(writer: Writer, options: ServerVariableOption[]): void {
for (const { variable, optionName, localName } of options) {
writer.writeTextStatement(
`var ${localName} = clientOptions.${optionName} ?? "${escapeForCSharpString(variable.default ?? "")}"`

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 ?? "" silently substitutes an empty string when no default exists. If a variable has no default and the user omits it, this produces a URL with an empty segment (e.g. https://api..example.com). Consider whether a missing-default-with-no-value case should instead be an error or fall back to the static URL.

@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) 70s -2s (-2.8%)
go-sdk square 135s (n=5) 287s (n=5) 132s -3s (-2.2%)
java-sdk square 218s (n=5) 272s (n=5) 188s -30s (-13.8%)
php-sdk square 62s (n=5) 84s (n=5) 54s -8s (-12.9%)
python-sdk square 139s (n=5) 241s (n=5) 127s -12s (-8.6%)
ruby-sdk-v2 square 92s (n=5) 128s (n=5) 90s -2s (-2.2%)
rust-sdk square 175s (n=5) 165s (n=5) 160s -15s (-8.6%)
swift-sdk square 61s (n=5) 439s (n=5) 52s -9s (-14.8%)
ts-sdk square 129s (n=5) 128s (n=5) 117s -12s (-9.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:03 UTC

* the result wrapped as a C# interpolated string (e.g. `$"https://api.{_region}.example.com"`).
*/
export function urlTemplateToInterpolatedString(template: string, options: ServerVariableOption[]): string {
let result = escapeForCSharpString(template);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 C# expression injection via unescaped curly braces in URL template → generated interpolated string

In urlTemplateToInterpolatedString, the raw urlTemplate value (e.g. servers[].url from an OpenAPI spec) is passed through escapeForCSharpString then embedded verbatim inside a C# interpolated string ($"..."). escapeForCSharpString escapes \ and " but not { or }. Any {expr} placeholder in the template that does not match a declared variable.id survives unchanged and becomes a live C# interpolation expression compiled into the generated SDK.

An attacker-controlled OpenAPI spec with a server URL such as https://api.{region}.example.com{System.Environment.Exit(0)} produces:

clientOptions.BaseUrl = $"https://api.{_region}.example.com{System.Environment.Exit(0)}";

This compiles without error and terminates the host process on every SDK client instantiation (DoS). Expressions that don't require string literals — System.Environment.MachineName, System.Environment.ProcessId, System.GC.Collect(), System.Diagnostics.Process.GetCurrentProcess() etc. — are all valid. On C# 11 targets (net8.0 / net9.0, both in the fixture's CI matrix) the language-level restriction on " inside interpolation holes is relaxed, enabling full string-argument method calls.

Prompt To Fix With AI
In `urlTemplateToInterpolatedString`, after calling `escapeForCSharpString(template)`, escape ALL curly braces in the result to their doubled C# form (`{``{{`, `}``}}`), then re-introduce only the known variable placeholders as real interpolation expressions. Example fix:

```typescript
export function urlTemplateToInterpolatedString(template: string, options: ServerVariableOption[]): string {
    // 1. Escape string-level specials (backslash, double-quote, control chars)
    let result = escapeForCSharpString(template);
    // 2. Neutralise ALL braces so no arbitrary C# expression survives in the output
    result = result.replace(/\{/g, "{{").replace(/\}/g, "}}");
    // 3. Re-open only the declared variable placeholders as real interpolation holes
    for (const { variable, localName } of options) {
        result = result.split(`{{${variable.id}}}`).join(`{${localName}}`);
    }
    return `$"${result}"`;
}
```

This ensures any `{expr}` in the source template that does not correspond to a declared server variable becomes the literal text `{expr}` rather than an executed C# expression. Update the `serverVariables.test.ts` fixture expectations accordingly (the only observable change is that un-matched braces are now doubled).

Severity: medium | Confidence: 75% | React with 👍 if useful or 👎 if not

@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

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.

🔍 Clone method does not copy server variable fields

The Clone() method (both the object-initializer path at generators/csharp/sdk/src/options/ClientOptionsGenerator.ts:430-449 and the copy constructor at lines 470-511) does not include the new server variable fields (e.g., Region). This is functionally correct in the current flow because the interpolation writes to BaseUrl/Environment BEFORE the clone happens, and those fields ARE copied. However, if Clone() is ever used in a context where the server variable fields need to be preserved (e.g., re-constructing a client from cloned options), they would be lost. The existing TODO at line 411 acknowledges this pattern needs improvement.

(Refers to lines 430-449)

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.

Intentional. The server variables are construction-time-only inputs: they're consumed to rebuild BaseUrl/Environment before any Clone() happens, and those resolved URLs are copied. This mirrors the TS (#16977), Python, and Java implementations, where the variables are likewise applied during client construction and not preserved as separate fields. Adding them to Clone() would have no observable effect in the current flow, so I've left them out to keep parity with the other generators.

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) 246.3s (35 versions) +3.3s (+1.4%)

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

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