From 9d782bf7f738369264b1565177a94167aea605bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 04:54:40 +0000 Subject: [PATCH 1/5] fix(csharp): fall back to NuGet package name in User-Agent header Co-Authored-By: will.kendall@buildwithfern.com --- .../sdk/changes/unreleased/user-agent-fallback.yml | 7 +++++++ .../sdk/src/root-client/RootClientGenerator.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml diff --git a/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml new file mode 100644 index 000000000000..38ba55c9957b --- /dev/null +++ b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Fall back to a `/` User-Agent header when no + `platformHeaders.userAgent` is set in the IR (e.g. for OpenAPI imports), + matching the TypeScript generator's npm-package-name fallback. + type: fix diff --git a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts index fd6185d9e432..904237b732eb 100644 --- a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts @@ -335,6 +335,19 @@ export class RootClientGenerator extends FileGenerator/` so SDKs imported from OpenAPI + // (where `platformHeaders.userAgent` is unset) still send a User-Agent, + // mirroring the TypeScript generator's npm-package-name fallback. + const packageName = this.generation.names.project.packageId; + platformHeaderEntries.push({ + key: this.csharp.codeblock('"User-Agent"'), + value: this.csharp.codeblock((writer) => { + writer.write(`$"${packageName}/{`); + writer.writeNode(this.context.getCurrentVersionValueAccess()); + writer.write('}"'); + }) + }); } } From 0d70b484a1dfe31052b1fddbdd86b23388c7f121 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 05:04:31 +0000 Subject: [PATCH 2/5] test(csharp): add unit tests for User-Agent header builder Co-Authored-By: will.kendall@buildwithfern.com --- .../src/root-client/RootClientGenerator.ts | 31 +++---- .../buildUserAgentHeaderEntry.test.ts | 91 +++++++++++++++++++ .../root-client/buildUserAgentHeaderEntry.ts | 39 ++++++++ 3 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts create mode 100644 generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts diff --git a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts index 904237b732eb..f78b88327a52 100644 --- a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts @@ -23,6 +23,7 @@ import { RawClient } from "../endpoint/http/RawClient.js"; import { SdkGeneratorContext } from "../SdkGeneratorContext.js"; import { collectInferredAuthCredentials } from "../utils/inferredAuthUtils.js"; import { WebSocketClientGenerator } from "../websocket/WebsocketClientGenerator.js"; +import { buildUserAgentHeaderEntry } from "./buildUserAgentHeaderEntry.js"; import { dedupAuthHeaderEntries } from "./dedupAuthHeaderEntries.js"; const GetFromEnvironmentOrThrow = "GetFromEnvironmentOrThrow"; @@ -330,25 +331,17 @@ export class RootClientGenerator extends FileGenerator/` so SDKs imported from OpenAPI - // (where `platformHeaders.userAgent` is unset) still send a User-Agent, - // mirroring the TypeScript generator's npm-package-name fallback. - const packageName = this.generation.names.project.packageId; - platformHeaderEntries.push({ - key: this.csharp.codeblock('"User-Agent"'), - value: this.csharp.codeblock((writer) => { - writer.write(`$"${packageName}/{`); - writer.writeNode(this.context.getCurrentVersionValueAccess()); - writer.write('}"'); - }) - }); - } + // Falls back to `$"/{Version.Current}"` when the IR has no + // `platformHeaders.userAgent` (e.g. OpenAPI imports), mirroring the TypeScript + // generator's npm-package-name fallback. + platformHeaderEntries.push( + buildUserAgentHeaderEntry({ + userAgent: platformHeaders.userAgent, + packageName: this.generation.names.project.packageId, + csharp: this.csharp, + versionValueAccess: this.context.getCurrentVersionValueAccess() + }) + ); } const platformHeaderDictionary = this.csharp.dictionary({ diff --git a/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts new file mode 100644 index 000000000000..944194c6f668 --- /dev/null +++ b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts @@ -0,0 +1,91 @@ +import { ast, CsharpConfigSchema, Generation } from "@fern-api/csharp-codegen"; +import { FernIr } from "@fern-fern/ir-sdk"; + +import { buildUserAgentHeaderEntry } from "../buildUserAgentHeaderEntry.js"; + +type IntermediateRepresentation = FernIr.IntermediateRepresentation; + +const generation = new Generation({} as unknown as IntermediateRepresentation, "", {} as CsharpConfigSchema, { + dryRun: false, + irFilepath: "", + organization: "", + workspaceName: "" +}); + +function render(node: ast.AstNode): string { + return node.toString({ + namespace: "", + allNamespaceSegments: new Set(), + allTypeClassReferences: new Map>(), + generation + }); +} + +// Stand-in for `context.getCurrentVersionValueAccess()`. The real codeblock writes +// `Version.Current` (and emits a using directive); for unit-testing the helper's +// composition with the version expression, a literal codeblock is sufficient. +function fakeVersionAccess(): ast.CodeBlock { + return generation.csharp.codeblock("Version.Current"); +} + +describe("buildUserAgentHeaderEntry", () => { + it("emits the IR-supplied header and value when `userAgent` is set", () => { + // Mirrors the Fern Definition path where `sdkConfig.platformHeaders.userAgent` + // is always populated, so the generator should pass it through verbatim. + const entry = buildUserAgentHeaderEntry({ + userAgent: { header: "User-Agent", value: "MyClient/1.2.3" }, + packageName: "Plantstore", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess() + }); + + expect(render(entry.key)).toBe('"User-Agent"'); + expect(render(entry.value)).toBe('"MyClient/1.2.3"'); + }); + + it("falls back to `/{Version.Current}` when `userAgent` is undefined", () => { + // OpenAPI imports never set `platformHeaders.userAgent`, so the generator + // must synthesize a User-Agent from the NuGet package id and the static + // `Version.Current` expression — matching the TS generator's + // `/` fallback. + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: "Plantstore", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess() + }); + + expect(render(entry.key)).toBe('"User-Agent"'); + expect(render(entry.value)).toBe('$"Plantstore/{Version.Current}"'); + }); + + it("preserves dotted NuGet package ids verbatim in the fallback", () => { + // NuGet package ids canonically use `.` segments (e.g. `DocuSign.eSign`), + // so the helper must not normalize or escape them. + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: "DocuSign.eSign", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess() + }); + + expect(render(entry.value)).toBe('$"DocuSign.eSign/{Version.Current}"'); + }); + + it("interpolates the version value access without re-quoting the prefix", () => { + // Regression guard: the helper builds the value via a writer callback so + // the version sub-expression is emitted as code, not as a string literal. + // The output must be a single `$"..."`-style interpolated string with the + // version inside `{...}` braces. + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: "pkg", + csharp: generation.csharp, + versionValueAccess: generation.csharp.codeblock((writer) => { + writer.write("MyVersionType.Current"); + }) + }); + + expect(render(entry.value)).toBe('$"pkg/{MyVersionType.Current}"'); + }); +}); diff --git a/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts new file mode 100644 index 000000000000..edfbcf7fbf50 --- /dev/null +++ b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts @@ -0,0 +1,39 @@ +import { ast } from "@fern-api/csharp-codegen"; +import { FernIr } from "@fern-fern/ir-sdk"; + +/** + * Builds the platform-headers `Dictionary` entry for the `User-Agent` header. + * + * - When the IR's `sdkConfig.platformHeaders.userAgent` is set (Fern Definition), + * emit the explicit `header`/`value` pair as a plain string literal. + * - Otherwise (e.g. OpenAPI imports, where the IR generator only sets + * `userAgent` when both `version` and `packageName` are non-null), fall back + * to `$"/{Version.Current}"` — mirroring the TypeScript + * generator's `/` fallback. + */ +export function buildUserAgentHeaderEntry({ + userAgent, + packageName, + csharp, + versionValueAccess +}: { + userAgent: FernIr.UserAgent | undefined; + packageName: string; + csharp: { codeblock: (arg: ast.CodeBlock.Arg) => ast.CodeBlock }; + versionValueAccess: ast.CodeBlock; +}): ast.Dictionary.MapEntry { + if (userAgent != null) { + return { + key: csharp.codeblock(`"${userAgent.header}"`), + value: csharp.codeblock(`"${userAgent.value}"`) + }; + } + return { + key: csharp.codeblock('"User-Agent"'), + value: csharp.codeblock((writer) => { + writer.write(`$"${packageName}/{`); + writer.writeNode(versionValueAccess); + writer.write('}"'); + }) + }; +} From 63c61b9c472701af834e2fe23beaaaaf8491a9da Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 05:22:53 +0000 Subject: [PATCH 3/5] Gate User-Agent fallback behind user-agent-from-package flag Co-Authored-By: will.kendall@buildwithfern.com --- .../codegen/src/context/generation-info.ts | 2 + .../src/custom-config/CsharpConfigSchema.ts | 5 ++ .../unreleased/user-agent-fallback.yml | 12 +++-- .../src/root-client/RootClientGenerator.ts | 26 +++++---- .../buildUserAgentHeaderEntry.test.ts | 53 +++++++++++++++---- .../root-client/buildUserAgentHeaderEntry.ts | 24 ++++++--- 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/generators/csharp/codegen/src/context/generation-info.ts b/generators/csharp/codegen/src/context/generation-info.ts index 07af5f84e408..d2e6fe318880 100644 --- a/generators/csharp/codegen/src/context/generation-info.ts +++ b/generators/csharp/codegen/src/context/generation-info.ts @@ -235,6 +235,8 @@ export class Generation { extraDependencies: () => this.customConfig["extra-dependencies"] ?? {}, /** When true, omits Fern platform headers (X-Fern-Language, SDK name/version, User-Agent) from generated SDK requests. Default: false. */ omitFernHeaders: () => this.customConfig["omit-fern-headers"] ?? false, + /** When true, falls back to `/` for the `User-Agent` header when the IR doesn't supply one. Default: false. */ + userAgentFromPackage: () => this.customConfig["user-agent-from-package"] ?? false, /** When true, moves auth params and IR headers into ClientOptions so the constructor takes only named arguments. Default: false. */ unifiedClientOptions: () => this.customConfig["unified-client-options"] ?? false, /** When true, uses PascalCase for environment names (e.g., "Production" instead of "production"). Default: true. */ diff --git a/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts b/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts index 8abab11c425a..8235077eb955 100644 --- a/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts +++ b/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts @@ -99,6 +99,11 @@ export const CsharpConfigSchema = z.object({ "custom-readme-sections": z.array(CustomReadmeSectionSchema).optional(), "omit-fern-headers": z.boolean().optional(), "unified-client-options": z.boolean().optional(), + // When true, fall back to `$"/{Version.Current}"` for the + // `User-Agent` platform header when the IR's `platformHeaders.userAgent` is + // unset (e.g. SDKs imported from OpenAPI). Off by default to preserve the + // pre-existing behavior of emitting no `User-Agent` header in that case. + "user-agent-from-package": z.boolean().optional(), // Deprecated. "extra-dependencies": z diff --git a/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml index 38ba55c9957b..e5a5676bba80 100644 --- a/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml +++ b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml @@ -1,7 +1,11 @@ # yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json - summary: | - Fall back to a `/` User-Agent header when no - `platformHeaders.userAgent` is set in the IR (e.g. for OpenAPI imports), - matching the TypeScript generator's npm-package-name fallback. - type: fix + Add an opt-in `user-agent-from-package` custom config option. When set to + `true`, the generated client falls back to a + `/` `User-Agent` header when no + `platformHeaders.userAgent` is set in the IR (e.g. for SDKs imported from + OpenAPI), matching the TypeScript generator's npm-package-name fallback. + Defaults to `false`, preserving the existing behavior of emitting no + `User-Agent` header in that case. + type: feat diff --git a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts index f78b88327a52..8fce40b57554 100644 --- a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts @@ -331,17 +331,21 @@ export class RootClientGenerator extends FileGenerator/{Version.Current}"` when the IR has no - // `platformHeaders.userAgent` (e.g. OpenAPI imports), mirroring the TypeScript - // generator's npm-package-name fallback. - platformHeaderEntries.push( - buildUserAgentHeaderEntry({ - userAgent: platformHeaders.userAgent, - packageName: this.generation.names.project.packageId, - csharp: this.csharp, - versionValueAccess: this.context.getCurrentVersionValueAccess() - }) - ); + // When `user-agent-from-package` is enabled, falls back to + // `$"/{Version.Current}"` when the IR has no + // `platformHeaders.userAgent` (e.g. OpenAPI imports), mirroring the + // TypeScript generator's npm-package-name fallback. Defaults off so + // existing C# SDKs imported from OpenAPI keep emitting no User-Agent. + const userAgentEntry = buildUserAgentHeaderEntry({ + userAgent: platformHeaders.userAgent, + packageName: this.generation.names.project.packageId, + csharp: this.csharp, + versionValueAccess: this.context.getCurrentVersionValueAccess(), + userAgentFromPackage: this.settings.userAgentFromPackage + }); + if (userAgentEntry != null) { + platformHeaderEntries.push(userAgentEntry); + } } const platformHeaderDictionary = this.csharp.dictionary({ diff --git a/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts index 944194c6f668..df1ca406b8b3 100644 --- a/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts +++ b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts @@ -29,32 +29,55 @@ function fakeVersionAccess(): ast.CodeBlock { } describe("buildUserAgentHeaderEntry", () => { - it("emits the IR-supplied header and value when `userAgent` is set", () => { + it("emits the IR-supplied header and value when `userAgent` is set, regardless of `userAgentFromPackage`", () => { // Mirrors the Fern Definition path where `sdkConfig.platformHeaders.userAgent` - // is always populated, so the generator should pass it through verbatim. + // is always populated, so the generator should pass it through verbatim + // even when the new opt-in flag is off. const entry = buildUserAgentHeaderEntry({ userAgent: { header: "User-Agent", value: "MyClient/1.2.3" }, packageName: "Plantstore", csharp: generation.csharp, - versionValueAccess: fakeVersionAccess() + versionValueAccess: fakeVersionAccess(), + userAgentFromPackage: false }); + if (entry == null) { + throw new Error("Expected entry to be defined when `userAgent` is set."); + } expect(render(entry.key)).toBe('"User-Agent"'); expect(render(entry.value)).toBe('"MyClient/1.2.3"'); }); - it("falls back to `/{Version.Current}` when `userAgent` is undefined", () => { - // OpenAPI imports never set `platformHeaders.userAgent`, so the generator - // must synthesize a User-Agent from the NuGet package id and the static - // `Version.Current` expression — matching the TS generator's - // `/` fallback. + it("returns `undefined` when `userAgent` is unset and `userAgentFromPackage` is false", () => { + // Default behavior for OpenAPI imports: the generator must emit no + // User-Agent entry at all so the generated SDK matches the pre-flag + // baseline. Returning `undefined` lets the caller skip the push. const entry = buildUserAgentHeaderEntry({ userAgent: undefined, packageName: "Plantstore", csharp: generation.csharp, - versionValueAccess: fakeVersionAccess() + versionValueAccess: fakeVersionAccess(), + userAgentFromPackage: false }); + expect(entry).toBeUndefined(); + }); + + it("falls back to `/{Version.Current}` when `userAgent` is undefined and the flag is on", () => { + // Opt-in parity with the TypeScript generator's + // `/` fallback: only emitted when the user + // has explicitly enabled `user-agent-from-package`. + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: "Plantstore", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess(), + userAgentFromPackage: true + }); + + if (entry == null) { + throw new Error("Expected entry to be defined when `userAgentFromPackage` is true."); + } expect(render(entry.key)).toBe('"User-Agent"'); expect(render(entry.value)).toBe('$"Plantstore/{Version.Current}"'); }); @@ -66,9 +89,13 @@ describe("buildUserAgentHeaderEntry", () => { userAgent: undefined, packageName: "DocuSign.eSign", csharp: generation.csharp, - versionValueAccess: fakeVersionAccess() + versionValueAccess: fakeVersionAccess(), + userAgentFromPackage: true }); + if (entry == null) { + throw new Error("Expected entry to be defined when `userAgentFromPackage` is true."); + } expect(render(entry.value)).toBe('$"DocuSign.eSign/{Version.Current}"'); }); @@ -83,9 +110,13 @@ describe("buildUserAgentHeaderEntry", () => { csharp: generation.csharp, versionValueAccess: generation.csharp.codeblock((writer) => { writer.write("MyVersionType.Current"); - }) + }), + userAgentFromPackage: true }); + if (entry == null) { + throw new Error("Expected entry to be defined when `userAgentFromPackage` is true."); + } expect(render(entry.value)).toBe('$"pkg/{MyVersionType.Current}"'); }); }); diff --git a/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts index edfbcf7fbf50..be3d01761667 100644 --- a/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts +++ b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts @@ -2,32 +2,42 @@ import { ast } from "@fern-api/csharp-codegen"; import { FernIr } from "@fern-fern/ir-sdk"; /** - * Builds the platform-headers `Dictionary` entry for the `User-Agent` header. + * Builds the platform-headers `Dictionary` entry for the `User-Agent` header, + * or `undefined` when no entry should be emitted. * * - When the IR's `sdkConfig.platformHeaders.userAgent` is set (Fern Definition), * emit the explicit `header`/`value` pair as a plain string literal. - * - Otherwise (e.g. OpenAPI imports, where the IR generator only sets - * `userAgent` when both `version` and `packageName` are non-null), fall back - * to `$"/{Version.Current}"` — mirroring the TypeScript - * generator's `/` fallback. + * `userAgentFromPackage` is irrelevant in this branch. + * - When `userAgent` is unset and `userAgentFromPackage` is `true`, fall back to + * `$"/{Version.Current}"` — mirroring the TypeScript + * generator's `/` fallback. This is the opt-in + * parity behavior for SDKs imported from OpenAPI. + * - When `userAgent` is unset and `userAgentFromPackage` is `false` (default), + * return `undefined` so the caller emits no `User-Agent` entry — preserving + * the historical C# generator behavior for OpenAPI imports. */ export function buildUserAgentHeaderEntry({ userAgent, packageName, csharp, - versionValueAccess + versionValueAccess, + userAgentFromPackage }: { userAgent: FernIr.UserAgent | undefined; packageName: string; csharp: { codeblock: (arg: ast.CodeBlock.Arg) => ast.CodeBlock }; versionValueAccess: ast.CodeBlock; -}): ast.Dictionary.MapEntry { + userAgentFromPackage: boolean; +}): ast.Dictionary.MapEntry | undefined { if (userAgent != null) { return { key: csharp.codeblock(`"${userAgent.header}"`), value: csharp.codeblock(`"${userAgent.value}"`) }; } + if (!userAgentFromPackage) { + return undefined; + } return { key: csharp.codeblock('"User-Agent"'), value: csharp.codeblock((writer) => { From 5499a280657a0249ef8fe8056ca3f319fb6d2e9e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 05:27:56 +0000 Subject: [PATCH 4/5] Rename flag to user-agent-name-from-package Co-Authored-By: will.kendall@buildwithfern.com --- .../codegen/src/context/generation-info.ts | 2 +- .../src/custom-config/CsharpConfigSchema.ts | 2 +- .../unreleased/user-agent-fallback.yml | 4 ++-- .../src/root-client/RootClientGenerator.ts | 4 ++-- .../buildUserAgentHeaderEntry.test.ts | 22 +++++++++---------- .../root-client/buildUserAgentHeaderEntry.ts | 22 +++++++++---------- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/generators/csharp/codegen/src/context/generation-info.ts b/generators/csharp/codegen/src/context/generation-info.ts index d2e6fe318880..03162dabd5a4 100644 --- a/generators/csharp/codegen/src/context/generation-info.ts +++ b/generators/csharp/codegen/src/context/generation-info.ts @@ -236,7 +236,7 @@ export class Generation { /** When true, omits Fern platform headers (X-Fern-Language, SDK name/version, User-Agent) from generated SDK requests. Default: false. */ omitFernHeaders: () => this.customConfig["omit-fern-headers"] ?? false, /** When true, falls back to `/` for the `User-Agent` header when the IR doesn't supply one. Default: false. */ - userAgentFromPackage: () => this.customConfig["user-agent-from-package"] ?? false, + userAgentNameFromPackage: () => this.customConfig["user-agent-name-from-package"] ?? false, /** When true, moves auth params and IR headers into ClientOptions so the constructor takes only named arguments. Default: false. */ unifiedClientOptions: () => this.customConfig["unified-client-options"] ?? false, /** When true, uses PascalCase for environment names (e.g., "Production" instead of "production"). Default: true. */ diff --git a/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts b/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts index 8235077eb955..bc1ecaedc8c7 100644 --- a/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts +++ b/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts @@ -103,7 +103,7 @@ export const CsharpConfigSchema = z.object({ // `User-Agent` platform header when the IR's `platformHeaders.userAgent` is // unset (e.g. SDKs imported from OpenAPI). Off by default to preserve the // pre-existing behavior of emitting no `User-Agent` header in that case. - "user-agent-from-package": z.boolean().optional(), + "user-agent-name-from-package": z.boolean().optional(), // Deprecated. "extra-dependencies": z diff --git a/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml index e5a5676bba80..01247069e1a4 100644 --- a/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml +++ b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml @@ -1,8 +1,8 @@ # yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json - summary: | - Add an opt-in `user-agent-from-package` custom config option. When set to - `true`, the generated client falls back to a + Add an opt-in `user-agent-name-from-package` custom config option. When set + to `true`, the generated client falls back to a `/` `User-Agent` header when no `platformHeaders.userAgent` is set in the IR (e.g. for SDKs imported from OpenAPI), matching the TypeScript generator's npm-package-name fallback. diff --git a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts index 8fce40b57554..b70415654ec6 100644 --- a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts @@ -331,7 +331,7 @@ export class RootClientGenerator extends FileGenerator/{Version.Current}"` when the IR has no // `platformHeaders.userAgent` (e.g. OpenAPI imports), mirroring the // TypeScript generator's npm-package-name fallback. Defaults off so @@ -341,7 +341,7 @@ export class RootClientGenerator extends FileGenerator { - it("emits the IR-supplied header and value when `userAgent` is set, regardless of `userAgentFromPackage`", () => { + it("emits the IR-supplied header and value when `userAgent` is set, regardless of `userAgentNameFromPackage`", () => { // Mirrors the Fern Definition path where `sdkConfig.platformHeaders.userAgent` // is always populated, so the generator should pass it through verbatim // even when the new opt-in flag is off. @@ -38,7 +38,7 @@ describe("buildUserAgentHeaderEntry", () => { packageName: "Plantstore", csharp: generation.csharp, versionValueAccess: fakeVersionAccess(), - userAgentFromPackage: false + userAgentNameFromPackage: false }); if (entry == null) { @@ -48,7 +48,7 @@ describe("buildUserAgentHeaderEntry", () => { expect(render(entry.value)).toBe('"MyClient/1.2.3"'); }); - it("returns `undefined` when `userAgent` is unset and `userAgentFromPackage` is false", () => { + it("returns `undefined` when `userAgent` is unset and `userAgentNameFromPackage` is false", () => { // Default behavior for OpenAPI imports: the generator must emit no // User-Agent entry at all so the generated SDK matches the pre-flag // baseline. Returning `undefined` lets the caller skip the push. @@ -57,7 +57,7 @@ describe("buildUserAgentHeaderEntry", () => { packageName: "Plantstore", csharp: generation.csharp, versionValueAccess: fakeVersionAccess(), - userAgentFromPackage: false + userAgentNameFromPackage: false }); expect(entry).toBeUndefined(); @@ -66,17 +66,17 @@ describe("buildUserAgentHeaderEntry", () => { it("falls back to `/{Version.Current}` when `userAgent` is undefined and the flag is on", () => { // Opt-in parity with the TypeScript generator's // `/` fallback: only emitted when the user - // has explicitly enabled `user-agent-from-package`. + // has explicitly enabled `user-agent-name-from-package`. const entry = buildUserAgentHeaderEntry({ userAgent: undefined, packageName: "Plantstore", csharp: generation.csharp, versionValueAccess: fakeVersionAccess(), - userAgentFromPackage: true + userAgentNameFromPackage: true }); if (entry == null) { - throw new Error("Expected entry to be defined when `userAgentFromPackage` is true."); + throw new Error("Expected entry to be defined when `userAgentNameFromPackage` is true."); } expect(render(entry.key)).toBe('"User-Agent"'); expect(render(entry.value)).toBe('$"Plantstore/{Version.Current}"'); @@ -90,11 +90,11 @@ describe("buildUserAgentHeaderEntry", () => { packageName: "DocuSign.eSign", csharp: generation.csharp, versionValueAccess: fakeVersionAccess(), - userAgentFromPackage: true + userAgentNameFromPackage: true }); if (entry == null) { - throw new Error("Expected entry to be defined when `userAgentFromPackage` is true."); + throw new Error("Expected entry to be defined when `userAgentNameFromPackage` is true."); } expect(render(entry.value)).toBe('$"DocuSign.eSign/{Version.Current}"'); }); @@ -111,11 +111,11 @@ describe("buildUserAgentHeaderEntry", () => { versionValueAccess: generation.csharp.codeblock((writer) => { writer.write("MyVersionType.Current"); }), - userAgentFromPackage: true + userAgentNameFromPackage: true }); if (entry == null) { - throw new Error("Expected entry to be defined when `userAgentFromPackage` is true."); + throw new Error("Expected entry to be defined when `userAgentNameFromPackage` is true."); } expect(render(entry.value)).toBe('$"pkg/{MyVersionType.Current}"'); }); diff --git a/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts index be3d01761667..c0d41a82e9a2 100644 --- a/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts +++ b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts @@ -7,27 +7,27 @@ import { FernIr } from "@fern-fern/ir-sdk"; * * - When the IR's `sdkConfig.platformHeaders.userAgent` is set (Fern Definition), * emit the explicit `header`/`value` pair as a plain string literal. - * `userAgentFromPackage` is irrelevant in this branch. - * - When `userAgent` is unset and `userAgentFromPackage` is `true`, fall back to - * `$"/{Version.Current}"` — mirroring the TypeScript - * generator's `/` fallback. This is the opt-in - * parity behavior for SDKs imported from OpenAPI. - * - When `userAgent` is unset and `userAgentFromPackage` is `false` (default), - * return `undefined` so the caller emits no `User-Agent` entry — preserving - * the historical C# generator behavior for OpenAPI imports. + * `userAgentNameFromPackage` is irrelevant in this branch. + * - When `userAgent` is unset and `userAgentNameFromPackage` is `true`, fall + * back to `$"/{Version.Current}"` — mirroring the + * TypeScript generator's `/` fallback. This is + * the opt-in parity behavior for SDKs imported from OpenAPI. + * - When `userAgent` is unset and `userAgentNameFromPackage` is `false` + * (default), return `undefined` so the caller emits no `User-Agent` entry — + * preserving the historical C# generator behavior for OpenAPI imports. */ export function buildUserAgentHeaderEntry({ userAgent, packageName, csharp, versionValueAccess, - userAgentFromPackage + userAgentNameFromPackage }: { userAgent: FernIr.UserAgent | undefined; packageName: string; csharp: { codeblock: (arg: ast.CodeBlock.Arg) => ast.CodeBlock }; versionValueAccess: ast.CodeBlock; - userAgentFromPackage: boolean; + userAgentNameFromPackage: boolean; }): ast.Dictionary.MapEntry | undefined { if (userAgent != null) { return { @@ -35,7 +35,7 @@ export function buildUserAgentHeaderEntry({ value: csharp.codeblock(`"${userAgent.value}"`) }; } - if (!userAgentFromPackage) { + if (!userAgentNameFromPackage) { return undefined; } return { From 3093cdb26e8d7b087ab0531351519d93136872cc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 05:40:49 +0000 Subject: [PATCH 5/5] Add regression test for User-Agent prefix when no package-id is configured Co-Authored-By: will.kendall@buildwithfern.com --- .../buildUserAgentHeaderEntry.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts index b357b7171ee3..bb025afbf5df 100644 --- a/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts +++ b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts @@ -99,6 +99,49 @@ describe("buildUserAgentHeaderEntry", () => { expect(render(entry.value)).toBe('$"DocuSign.eSign/{Version.Current}"'); }); + it("uses the namespace cascade as the User-Agent prefix when no `package-id` is configured", () => { + // Regression guard for the `names.project.packageId` cascade in + // generation-info.ts: when neither `package-id` nor `namespace` is set + // in custom config, the project's package id falls back to + // `PascalCase(_)` — the same string the SDK + // ships under as a NuGet project. The fallback must therefore be + // non-empty, so the User-Agent prefix always matches whatever package + // the customer is publishing. + const cascadingGeneration = new Generation( + {} as unknown as IntermediateRepresentation, + "plantstore", + {} as CsharpConfigSchema, + { + dryRun: false, + irFilepath: "", + organization: "fern", + workspaceName: "plantstore" + } + ); + const cascadedPackageId = cascadingGeneration.names.project.packageId; + expect(cascadedPackageId).toBe("FernPlantstore"); + + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: cascadedPackageId, + csharp: cascadingGeneration.csharp, + versionValueAccess: cascadingGeneration.csharp.codeblock("Version.Current"), + userAgentNameFromPackage: true + }); + + if (entry == null) { + throw new Error("Expected entry to be defined when `userAgentNameFromPackage` is true."); + } + expect( + entry.value.toString({ + namespace: "", + allNamespaceSegments: new Set(), + allTypeClassReferences: new Map>(), + generation: cascadingGeneration + }) + ).toBe('$"FernPlantstore/{Version.Current}"'); + }); + it("interpolates the version value access without re-quoting the prefix", () => { // Regression guard: the helper builds the value via a writer callback so // the version sub-expression is emitted as code, not as a string literal.