Skip to content

feat(go): add opt-in appInfo client option appended to User-Agent - #17305

Open
cadesark wants to merge 3 commits into
mainfrom
cade/go-user-agent-app-info
Open

feat(go): add opt-in appInfo client option appended to User-Agent#17305
cadesark wants to merge 3 commits into
mainfrom
cade/go-user-agent-app-info

Conversation

@cadesark

@cadesark cadesark commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Ports the opt-in appInfo User-Agent feature (TS reference PR #17290) to the Go SDK generator (v1 generators/go + v2 config layer generators/go-v2). This is the last of six language ports; the design mirrors the TS reference and the PHP/Ruby/Java/Python/C# siblings.

Adds a new SDK generator config allowUserAgentAppInfo (boolean, default false, independent of includePlatformHeaders). When enabled, generated Go clients expose an option.WithUserAgentAppInfo(name, version, comment string) client option whose sanitized product token is appended to whatever User-Agent the SDK would otherwise send, following RFC 9110 §10.1.5.

Changes Made

  • Config flag allowUserAgentAppInfo:
    • v2: generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts (next to includePlatformHeaders).
    • v1: internal/generator/config.go (field + constructor param), internal/cmd/cmd.go (JSON parse + default false), plumbed through both cmd/fern-go-sdk and cmd/fern-go-model.
  • Options-struct refactor (replaces the positional flag): newFileWriter previously took omitFernHeaders/includePlatformHeaders as two positional booleans threaded through ~16 call sites. Rather than adding a 15th positional bool, the three User-Agent flags are collapsed into a single userAgentConfig value (omitFernHeaders, includePlatformHeaders, allowUserAgentAppInfo) with an emitsAppInfo() helper. All call sites updated.
  • internal/generator/sdk.go:
    • writePlatformHeaders wraps both User-Agent branches (structured platformUserAgent(...) and the raw configured/default {package}/{version}) with appendAppInfoToUserAgent(base, r.AppInfo) when the flag is on.
    • Emits a self-contained, gated AppInfo struct + appendAppInfoToUserAgent sanitizer/appender into core/request_option.go (never touches the shared as-is core files).
    • RequestOptions gains an AppInfo *AppInfo field; writeRequestOptionStructs emits AppInfoOption (via the existing writeOptionStruct machinery); WriteRequestOptions emits the public option.WithUserAgentAppInfo(name, version, comment string).
  • Seed fixture variant allow-user-agent-app-info added next to include-platform-headers (seed/go-sdk/seed.yml, imdb group) + regenerated output.
  • Changelog: generators/go/sdk/changes/unreleased/allow-user-agent-app-info.yml (type: feat).

Gated / default-off (byte-identical)

The AppInfo type, AppInfoOption, public WithUserAgentAppInfo option, the append call, and the emitted sanitizer are generated only when the flag is on (and a User-Agent is actually written; suppressed by omitFernHeaders). Regenerating the whole imdb group produced changes only under the new allow-user-agent-app-info/ fixture; every pre-existing fixture is byte-identical (only local-run .fern/metadata.json churn, reverted).

Before / after User-Agent (from the new fixture)

  • Before (no-custom-config): headers.Set("User-Agent", "github.com/imdb/fern/0.0.1")
  • After (allow-user-agent-app-info): headers.Set("User-Agent", appendAppInfoToUserAgent("github.com/imdb/fern/0.0.1", r.AppInfo))

Runtime examples produced by the emitted helper:

  • full: base/1.0 app/3.1.0 (+https://x.example)
  • name only: base/1.0 app
  • whitespace-only name -> unchanged: base/1.0

Sanitization (trim before blank-check + before encode)

Each field is trimmed first; blank -> segment dropped (blank name -> UA unchanged). name/version percent-encoded down to RFC 7230 tchar; comment escapes (, ), \ and control chars incl. CR/LF. Avoids the whitespace-junk-token bug from the first TS revision.

Testing

  • Unit tests added: internal/generator/sdk_user_agent_app_info_test.go — asserts the emitted source structure, and compiles+runs the emitted helper against a table covering CRLF injection (%0D%0A), paren/backslash escaping (%28/%29/%5C), tab (%09), empty/nil, whitespace-only name (unchanged) and version (dropped), trimming, and version/comment omission.
  • go build ./... && go vet ./... && go test ./... clean in generators/go (go1.26 local).
  • pnpm compile (go-v2 TS config), pnpm seed:build, seed regen for the imdb fixture group — 7/7 pass.
  • NEW-fixture full-build gate: go build ./..., go vet ./..., gofmt clean on the regenerated allow-user-agent-app-info module (only the pre-existing dynamic-snippets gofmt quirk, shared with the baseline fixture).
  • biome check clean; go-v2 package tsc clean.

Notes

  • No IR change; no changes to packages/ir-sdk or sdkConfig.platformHeaders. versions.yml untouched.

Generated with Claude Code


Open in Devin Review

Add a new SDK generator config `allowUserAgentAppInfo` (default false). When
enabled, generated Go clients expose an `option.WithUserAgentAppInfo(name,
version, comment string)` client option whose sanitized product token is
appended to whatever `User-Agent` the SDK would otherwise send, producing e.g.
`github.com/acme/sdk/1.4.0 (linux; x86_64) Go/1.23 partner-app/3.1.0 (+https://partner.example)`
per RFC 9110 §10.1.5.

The appendix is applied to both User-Agent branches, gated on the flag: the
structured, runtime-computed value (`includePlatformHeaders`) and the raw
configured/default `{package}/{version}` value. Caller-supplied values are
trimmed before the blank check and before encoding, so blank name/version/comment
are dropped rather than encoded into whitespace tokens; then name/version are
percent-encoded to RFC 7230 `tchar` and comment delimiters `(`, `)`, `\` and
control chars (incl. CR/LF) are escaped, so untrusted values cannot inject
header content.

The `AppInfo` type, the `AppInfoOption`, and the emitted
`appendAppInfoToUserAgent` helper are only generated when the flag is on (and a
User-Agent is actually written), so default-off output is byte-identical and the
shared always-shipped core-utilities are never modified. Still overridable by an
explicit `User-Agent` header and suppressed by `omitFernHeaders`. No IR change.

The v1 file writers previously threaded `omitFernHeaders`/`includePlatformHeaders`
as positional booleans through ~16 `newFileWriter` call sites; this collapses the
three User-Agent flags into a single `userAgentConfig` value rather than adding a
15th positional bool.

Co-Authored-By: Claude <noreply@anthropic.com>
@cadesark cadesark self-assigned this Jul 30, 2026

@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.

Reviewed the changes — everything looks good. No issues found.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread generators/go/internal/generator/sdk.go Outdated
Comment on lines +1006 to +1014
if f.userAgent.emitsAppInfo() {
// The AppInfo type is always emitted when the feature is enabled so the
// AppInfo option surface resolves; the appender is emitted only when a
// User-Agent header is actually written.
f.writeAppInfoType()
if sdkConfig.PlatformHeaders.UserAgent != nil {
f.writeAppendAppInfoFunc()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Generated Go SDKs fail to build when the new User-Agent app-info feature is turned on for APIs without a version or platform headers

The application-info type is only written out (f.writeAppInfoType() at generators/go/internal/generator/sdk.go:1010) inside the branch that requires both a non-empty SDK version and configured platform headers, while the option that uses it is always written when the feature is enabled, so the generated SDK references a type that was never created and does not build.
Impact: Turning on the new option for such an API produces an SDK that cannot be compiled by its users.

Mechanism: AppInfo type emission is gated more narrowly than its consumers

writePlatformHeaders (generators/go/internal/generator/sdk.go:965-1016) returns early when sdkVersion == "" (or omitFernHeaders), and the remainder of the body only runs when sdkConfig.PlatformHeaders != nil. writeAppInfoType() sits inside that inner block, so it is skipped for:

  • local/downloadFiles generation where no version is available (Config.Version == ""), and
  • IRs whose sdkConfig.PlatformHeaders is nil.

Meanwhile the consumers are gated only on userAgentConfig.emitsAppInfo() (allowUserAgentAppInfo && !omitFernHeaders):

  • the AppInfo *AppInfo field on RequestOptions (generators/go/internal/generator/sdk.go:491-495),
  • AppInfoOption via writeOptionStruct (generators/go/internal/generator/sdk.go:1196-1200),
  • option.WithUserAgentAppInfo returning *core.AppInfoOption and constructing &core.AppInfo{...} (generators/go/internal/generator/sdk.go:1562-1577).

Result: core.AppInfo is undefined in the generated package. The in-code comment at generators/go/internal/generator/sdk.go:1007-1009 ("The AppInfo type is always emitted when the feature is enabled") does not match the actual gating.

Prompt for agents
In generators/go/internal/generator/sdk.go, the AppInfo struct emission (writeAppInfoType) lives inside writePlatformHeaders' inner `if sdkConfig.PlatformHeaders != nil` block, which is additionally unreachable when sdkVersion == "" (early return) — but the AppInfo field on RequestOptions, the AppInfoOption struct (writeRequestOptionStructs), and option.WithUserAgentAppInfo (WriteRequestOptions) are emitted purely based on userAgentConfig.emitsAppInfo(). For APIs generated without a version (e.g. local downloadFiles output) or with a nil sdkConfig.PlatformHeaders, the generated core package references an undefined AppInfo type and will not compile. Either emit the AppInfo type unconditionally whenever emitsAppInfo() is true (e.g. from WriteRequestOptionsDefinition, outside writePlatformHeaders' early-return/PlatformHeaders gating), or make all consumers (RequestOptions field, AppInfoOption, WithUserAgentAppInfo) share the exact same condition as the type emission so the surfaces stay consistent. Also fix the stale comment claiming the type is always emitted.
Open in Devin Review

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

@github-actions

github-actions Bot commented Jul 30, 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-31T05:08:23Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
go-sdk square 143s (n=5) 308s (n=5) 138s -5s (-3.5%)

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-31T05:08:23Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-31 15:36 UTC

…formHeaders

Co-Authored-By: Claude <noreply@anthropic.com>
offsetSemantics: z.enum(["item-index", "page-index"]).optional(),
omitFernHeaders: z.boolean().optional(),
includePlatformHeaders: z.boolean().optional(),
allowUserAgentAppInfo: z.boolean().optional(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

okay

The AppInfo struct was emitted by writeAppInfoType() inside
writePlatformHeaders' `if sdkConfig.PlatformHeaders != nil` block, which
early-returns when sdkVersion == "" (local/downloadFiles generation) or
omitFernHeaders is set. Its consumers (the RequestOptions.AppInfo field,
AppInfoOption, and option.WithUserAgentAppInfo constructing &core.AppInfo)
are gated solely on userAgentConfig.emitsAppInfo(), so for versionless
generation or IRs without platform headers the generated core package
referenced an undefined core.AppInfo and failed to compile.

Emit the AppInfo type from WriteRequestOptionsDefinition gated only on
emitsAppInfo(), independent of sdkVersion/PlatformHeaders, so the type and
all its consumers are always emitted together and exactly once. The
platform-headers path now only emits the appender helper (which still
requires a User-Agent header). Fix the stale comment claiming the type was
already always emitted.

Adds unit tests asserting the type is emitted (once) for the previously
broken path (empty version, nil PlatformHeaders) and not emitted when the
feature is disabled. Regenerates the two go appInfo seed fixtures.

Co-Authored-By: Claude <noreply@anthropic.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.

2 participants