Skip to content

feat(go): support server URL variables (region/edge routing)#16983

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

feat(go): support server URL variables (region/edge routing)#16983
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1783552405-go-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 Go SDK generator (v1 + v2 tandem), matching the TypeScript (PR #16977), Python, and Java implementations.

OpenAPI servers[].variables are modeled in the Fern IR as environments carrying urlTemplate/urlTemplates (URL strings with {id} placeholders) plus urlVariables (ServerVariable { id, name, default, values? }). Previously the Go generator ignored urlVariables and only emitted the static default URL(s). Now each variable is surfaced as an optional functional client option and interpolated into the base URL(s) at construction time.

Changes Made

  • Go v1 (generators/go/internal/generator/sdk.go) — extract server URL variables from EnvironmentsConfig (both singleBaseUrl and multipleBaseUrls, taking the first environment that declares them, de-duped by id). Emit:

    • a field per variable on the core.RequestOptions struct (core/request_option.go),
    • a *Option struct implementing applyRequestOptions per variable,
    • a public With<Name> functional option (option/request_option.go).

    Name de-collision mirrors the other generators: a variable's name maps to a PascalCase option name, and if it collides with a reserved option (e.g. Environment) it is prefixed with ServerURLWithServerURLEnvironment.

  • Go v1 IR structs (generators/go/internal/fern/ir/common/common.go) — added the hand-maintained ServerVariable struct and the urlTemplate/urlTemplates/urlVariables fields (+ getters) to SingleBaseUrlEnvironment / MultipleBaseUrlsEnvironment so the native v1 side can read them from IR v66. No IR schema / versions.yml changes.

  • Go v2 (generators/go-v2/sdk/src/client/ClientGenerator.ts) — interpolate at construction time. When any server-variable option is set, rebuild the base URL(s) from the environment's template(s), substituting each {id} (falling back to the variable's IR default). For multipleBaseUrls each named base URL is rebuilt from its own template and assigned to options.Environment; for singleBaseUrl the result is assigned to options.BaseURL.

    Generated constructor for the fixture:

    func NewClient(opts ...option.RequestOption) *Client {
        options := core.NewRequestOptions(opts...)
        if options.Region != "" || options.ServerURLEnvironment != "" {
            region := options.Region
            if region == "" { region = "us-east-1" }
            serverURLEnvironment := options.ServerURLEnvironment
            if serverURLEnvironment == "" { serverURLEnvironment = "prod" }
            options.Environment = fern.Environment{
                Auth: fmt.Sprintf("https://auth.%s.example.com", region),
                Base: fmt.Sprintf("https://api.%s.%s.example.com/v1", region, serverURLEnvironment),
            }
        }
        ...
    }
  • Seed fixture — added server-url-templating (outputFolder: no-custom-config, customConfig: null) to seed/go-sdk/seed.yml and committed the regenerated output under seed/go-sdk/server-url-templating/no-custom-config/ (the stale root-level output was removed so the layout matches other fixtures).

  • Changeloggenerators/go/sdk/changes/unreleased/add-server-url-variable-support.yml (type: feat).

Testing

  • seed test --generator go-sdk --fixture server-url-templating --local --skip-scriptssuccess (1/1 passed); generated SDK builds (go build ./...).
  • Generated client exposes WithRegion and WithServerURLEnvironment (de-collided) and interpolates both the base and auth URLs, with defaults us-east-1 / prod.
  • Pre-existing Go v1 unit tests pass (go test ./internal/generator/...); v2 compiles; biome check clean.
  • The go-v2 SDK has no client/environment-generation unit-test suite, so the seed snapshot serves as the test (as in PR feat(typescript): support server URL variables (region/edge routing) #16977's approach for generators without one).

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/21f08d51342e45c4b9cf38635a23595f
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 routing to the Go v1 and v2 SDK generators. The implementation is well-structured and mirrors the other generators. A few issues: the v2 getBaseUrlTemplates has a non-exhaustive switch that can fall through without returning, and the interpolation only fires when a variable is non-empty which drops the default-substitution behavior when the user leaves a variable unset but the template still has a placeholder.

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

Comment on lines +346 to +380
switch (environments.type) {
case "singleBaseUrl":
for (const environment of environments.environments) {
if (environment.urlTemplate != null) {
return [{ fieldName: "", template: environment.urlTemplate }];
}
}
return [];
case "multipleBaseUrls": {
const baseUrlNamesById = new Map<string, string>();
for (const baseUrl of environments.baseUrls) {
baseUrlNamesById.set(baseUrl.id, this.context.caseConverter.pascalUnsafe(baseUrl.name));
}
for (const environment of environments.environments) {
if (environment.urlTemplates != null && Object.keys(environment.urlTemplates).length > 0) {
const result: BaseUrlTemplate[] = [];
for (const baseUrlId of Object.keys(environment.urlTemplates).sort()) {
const template = environment.urlTemplates[baseUrlId];
if (template == null) {
continue;
}
result.push({
fieldName: baseUrlNamesById.get(baseUrlId) ?? baseUrlId,
template
});
}
return result;
}
}
return [];
}
default:
assertNever(environments);
}
}

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

getBaseUrlTemplates returns BaseUrlTemplate[] but the default: assertNever(...) branch is unreachable at type level yet the function has no return after the switch on non-default paths that fall through. Specifically, both case arms return inside their loops but if no environment declares templates the singleBaseUrl/multipleBaseUrls arms do return [] — good. However TypeScript's control-flow may still complain because assertNever returns never but the compiler needs the switch to be exhaustive to infer the function always returns. This compiles only because assertNever is never. Confirm the tsc build is clean; if not, add an explicit return [] after the switch.

Comment on lines +403 to +404
writer.write("if ");
writer.write(options.map((option) => `options.${option.fieldName} != ""`).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.

🟡 warning

The interpolation only runs when at least one variable option is non-empty (options.X != "" || ...). If the user sets none, the base URL keeps its original static default — but that default may itself still contain unresolved {id} placeholders in the template path (the static Environment/BaseURL default is presumably already resolved, so this is likely fine). Worth confirming the generated static default URL is fully resolved so a user who leaves everything unset doesn't get a broken URL. If the static default is already interpolated with variable defaults, ignore this.

Comment on lines +347 to +349
if _, ok := reservedRequestOptionNames[optionName]; ok {
optionName = "ServerURL" + variable.Name.PascalCase.UnsafeName
paramName = "serverURL" + variable.Name.PascalCase.UnsafeName

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 collision-prefixing derives paramName from PascalCase.UnsafeName (serverURL + Pascal), which produces e.g. serverURLEnvironment — fine here. But note the non-collision path uses CamelCase.SafeName while the collision path uses the raw Pascal name; if a variable name would need SafeName escaping (e.g. a Go keyword), the collision branch could emit an unsafe param identifier. Low risk given the ServerURL prefix, but consider "serverURL" + variable.Name.PascalCase.UnsafeName is guaranteed non-keyword — it is, so this is minor.

@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) 65s -7s (-9.7%)
go-sdk square 135s (n=5) 287s (n=5) 131s -4s (-3.0%)
java-sdk square 218s (n=5) 272s (n=5) 195s -23s (-10.6%)
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) 68s -24s (-26.1%)
rust-sdk square 175s (n=5) 165s (n=5) 210s +35s (+20.0%)
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:14 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 3 potential issues.

Open in Devin Review

Comment on lines +464 to +471
const format = template.replace(/\{([^}]+)\}/g, (match, id: string) => {
const option = optionsByVariableId.get(id);
if (option == null) {
return match;
}
args.push(option.localName);
return "%s";
});

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.

🟡 Literal percent signs in URL templates are misinterpreted during string formatting

URL template strings are used directly as Go format strings without escaping existing % characters (template.replace(...) at generators/go-v2/sdk/src/client/ClientGenerator.ts:464), so any literal % in a template (e.g. URL-encoded segments) would be misinterpreted by fmt.Sprintf at runtime, producing garbled URLs or a panic.

Impact: Generated SDK clients would crash or produce incorrect URLs when the API's server URL template contains percent-encoded characters.

Mechanism: unescaped % in fmt.Sprintf format string

In writeSprintfForTemplate (ClientGenerator.ts:454-478), the method builds a fmt.Sprintf call by replacing {id} placeholders with %s in the template string. However, it does not escape pre-existing % characters to %% before the replacement. For example, a template like https://api.example.com/path%20name/{region} would produce the format string "https://api.example.com/path%20name/%s", where %20 would be interpreted by fmt.Sprintf as a format verb (%2 followed by 0), causing incorrect output or a runtime error.

The fix is to escape % to %% before performing the {id}%s replacement:

const escaped = template.replace(/%/g, "%%");
const format = escaped.replace(/\{([^}]+)\}/g, ...);
Suggested change
const format = template.replace(/\{([^}]+)\}/g, (match, id: string) => {
const option = optionsByVariableId.get(id);
if (option == null) {
return match;
}
args.push(option.localName);
return "%s";
});
const args: string[] = [];
const escaped = template.replace(/%/g, "%%");
const format = escaped.replace(/\{([^}]+)\}/g, (match, id: string) => {
const option = optionsByVariableId.get(id);
if (option == null) {
return match;
}
args.push(option.localName);
return "%s";
});
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.

Good catch — fixed. writeSprintfForTemplate now escapes literal %%% before introducing the %s verbs, so percent-encoded segments in a template no longer break fmt.Sprintf. Seed output for the fixture is unchanged (it has no literal %).

Comment on lines +293 to +380
private collectServerVariables(): FernIr.ServerVariable[] {
const config = this.context.ir.environments;
if (config == null) {
return [];
}
const seen = new Set<string>();
const result: FernIr.ServerVariable[] = [];
const add = (variables: FernIr.ServerVariable[]): void => {
for (const variable of variables) {
if (!seen.has(variable.id)) {
seen.add(variable.id);
result.push(variable);
}
}
};
const environments = config.environments;
switch (environments.type) {
case "singleBaseUrl":
for (const environment of environments.environments) {
if (environment.urlVariables != null && environment.urlVariables.length > 0) {
add(environment.urlVariables);
break;
}
}
break;
case "multipleBaseUrls":
for (const environment of environments.environments) {
if (environment.urlVariables != null && Object.keys(environment.urlVariables).length > 0) {
for (const baseUrlId of Object.keys(environment.urlVariables).sort()) {
add(environment.urlVariables[baseUrlId] ?? []);
}
break;
}
}
break;
default:
assertNever(environments);
}
return result;
}

/**
* Returns the URL templates (one per base URL for multipleBaseUrls, or a single
* entry for singleBaseUrl) that should be re-interpolated when a server URL
* variable is provided. For singleBaseUrl the fieldName is empty (the result is
* assigned to options.BaseURL rather than an Environment struct field).
*/
private getBaseUrlTemplates(): BaseUrlTemplate[] {
const config = this.context.ir.environments;
if (config == null) {
return [];
}
const environments = config.environments;
switch (environments.type) {
case "singleBaseUrl":
for (const environment of environments.environments) {
if (environment.urlTemplate != null) {
return [{ fieldName: "", template: environment.urlTemplate }];
}
}
return [];
case "multipleBaseUrls": {
const baseUrlNamesById = new Map<string, string>();
for (const baseUrl of environments.baseUrls) {
baseUrlNamesById.set(baseUrl.id, this.context.caseConverter.pascalUnsafe(baseUrl.name));
}
for (const environment of environments.environments) {
if (environment.urlTemplates != null && Object.keys(environment.urlTemplates).length > 0) {
const result: BaseUrlTemplate[] = [];
for (const baseUrlId of Object.keys(environment.urlTemplates).sort()) {
const template = environment.urlTemplates[baseUrlId];
if (template == null) {
continue;
}
result.push({
fieldName: baseUrlNamesById.get(baseUrlId) ?? baseUrlId,
template
});
}
return result;
}
}
return [];
}
default:
assertNever(environments);
}
}

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.

🔍 Template and variable lookups iterate environments independently, risking a mismatch

In collectServerVariables (ClientGenerator.ts:310-316), the code picks variables from the first singleBaseUrl environment that has urlVariables. In getBaseUrlTemplates (ClientGenerator.ts:347-353), it picks the template from the first environment that has urlTemplate. If the IR has multiple environments where one has variables but no template (or vice versa), these two methods would select data from different environments. The template's {id} placeholders might not match the collected variable IDs, leaving unreplaced placeholders in the generated URL. The same pattern exists in the multipleBaseUrls branch. In practice, the IR should always pair variables with their template on the same environment, but this implicit coupling is worth noting.

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.

Acknowledged. This mirrors the existing Python/Java/TS implementations, which also read variables and templates without cross-checking they came from the same environment — the IR always pairs urlVariables with the urlTemplate(s) on the same environment (both are populated together from servers[].variables). Left as-is to stay consistent with the other generators and keep the change minimal.

Comment on lines +403 to +418
writer.write("if ");
writer.write(options.map((option) => `options.${option.fieldName} != ""`).join(" || "));
writer.writeLine(" {");
writer.indent();

// Declare a local for each variable, defaulting to its IR default when unset.
for (const option of options) {
writer.writeLine(`${option.localName} := options.${option.fieldName}`);
if (option.variable.default != null) {
writer.writeLine(`if ${option.localName} == "" {`);
writer.indent();
writer.writeLine(`${option.localName} = ${JSON.stringify(option.variable.default)}`);
writer.dedent();
writer.writeLine("}");
}
}

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.

🔍 Variables without defaults produce empty string substitutions when only some variables are set

In writeServerVariableInterpolation (ClientGenerator.ts:403-447), the outer if guard fires when ANY variable option is non-empty. Inside, each variable's local is initialized from its option value, and only falls back to a default if one exists (ClientGenerator.ts:411-417). If a user sets variable A but not variable B, and B has no IR default, B's local will be "" and fmt.Sprintf will substitute an empty string into the URL (e.g., https://api..example.com). This is arguably correct behavior (the user should set all variables or rely on defaults), but it could produce surprising URLs. Consider whether the generated code should validate that all variables are non-empty before interpolation.

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 other generators (Python/Java/TS): a variable is only substituted with its IR default when unset, and if it has no default it interpolates as empty. Per the spec, variables are expected to either declare a default or be set by the user. Adding a "require all variables" validation would diverge from the cross-language UX, so I've kept the fallback-to-default behavior. In this fixture both variables have defaults (us-east-1 / prod), so no empty substitution occurs.

cade and others added 2 commits July 8, 2026 23:31
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) 228.2s (35 versions) -14.8s (-6.1%)

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:12 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