feat(go): add opt-in appInfo client option appended to User-Agent - #17305
feat(go): add opt-in appInfo client option appended to User-Agent#17305cadesark wants to merge 3 commits into
Conversation
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>
| 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() | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 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.PlatformHeadersis nil.
Meanwhile the consumers are gated only on userAgentConfig.emitsAppInfo() (allowUserAgentAppInfo && !omitFernHeaders):
- the
AppInfo *AppInfofield onRequestOptions(generators/go/internal/generator/sdk.go:491-495), AppInfoOptionviawriteOptionStruct(generators/go/internal/generator/sdk.go:1196-1200),option.WithUserAgentAppInforeturning*core.AppInfoOptionand 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
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 |
…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(), |
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>
Description
Ports the opt-in
appInfoUser-Agent feature (TS reference PR #17290) to the Go SDK generator (v1generators/go+ v2 config layergenerators/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, defaultfalse, independent ofincludePlatformHeaders). When enabled, generated Go clients expose anoption.WithUserAgentAppInfo(name, version, comment string)client option whose sanitized product token is appended to whateverUser-Agentthe SDK would otherwise send, following RFC 9110 §10.1.5.Changes Made
allowUserAgentAppInfo:generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts(next toincludePlatformHeaders).internal/generator/config.go(field + constructor param),internal/cmd/cmd.go(JSON parse + default false), plumbed through bothcmd/fern-go-sdkandcmd/fern-go-model.newFileWriterpreviously tookomitFernHeaders/includePlatformHeadersas two positional booleans threaded through ~16 call sites. Rather than adding a 15th positional bool, the three User-Agent flags are collapsed into a singleuserAgentConfigvalue (omitFernHeaders,includePlatformHeaders,allowUserAgentAppInfo) with anemitsAppInfo()helper. All call sites updated.internal/generator/sdk.go:writePlatformHeaderswraps both User-Agent branches (structuredplatformUserAgent(...)and the raw configured/default{package}/{version}) withappendAppInfoToUserAgent(base, r.AppInfo)when the flag is on.AppInfostruct +appendAppInfoToUserAgentsanitizer/appender intocore/request_option.go(never touches the shared as-is core files).RequestOptionsgains anAppInfo *AppInfofield;writeRequestOptionStructsemitsAppInfoOption(via the existingwriteOptionStructmachinery);WriteRequestOptionsemits the publicoption.WithUserAgentAppInfo(name, version, comment string).allow-user-agent-app-infoadded next toinclude-platform-headers(seed/go-sdk/seed.yml, imdb group) + regenerated output.generators/go/sdk/changes/unreleased/allow-user-agent-app-info.yml(type: feat).Gated / default-off (byte-identical)
The
AppInfotype,AppInfoOption, publicWithUserAgentAppInfooption, the append call, and the emitted sanitizer are generated only when the flag is on (and a User-Agent is actually written; suppressed byomitFernHeaders). Regenerating the whole imdb group produced changes only under the newallow-user-agent-app-info/fixture; every pre-existing fixture is byte-identical (only local-run.fern/metadata.jsonchurn, reverted).Before / after User-Agent (from the new fixture)
no-custom-config):headers.Set("User-Agent", "github.com/imdb/fern/0.0.1")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:
base/1.0 app/3.1.0 (+https://x.example)base/1.0 appbase/1.0Sanitization (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
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 ingenerators/go(go1.26 local).pnpm compile(go-v2 TS config),pnpm seed:build, seed regen for the imdb fixture group — 7/7 pass.go build ./...,go vet ./...,gofmtclean on the regeneratedallow-user-agent-app-infomodule (only the pre-existing dynamic-snippets gofmt quirk, shared with the baseline fixture).biome checkclean; go-v2 packagetscclean.Notes
packages/ir-sdkorsdkConfig.platformHeaders.versions.ymluntouched.Generated with Claude Code