diff --git a/generators/csharp/codegen/src/context/generation-info.ts b/generators/csharp/codegen/src/context/generation-info.ts index 07af5f84e408..03162dabd5a4 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. */ + 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 8abab11c425a..bc1ecaedc8c7 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-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 new file mode 100644 index 000000000000..01247069e1a4 --- /dev/null +++ b/generators/csharp/sdk/changes/unreleased/user-agent-fallback.yml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + 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. + 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 fd6185d9e432..b70415654ec6 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,11 +331,20 @@ 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 + // 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(), + userAgentNameFromPackage: this.settings.userAgentNameFromPackage + }); + if (userAgentEntry != null) { + platformHeaderEntries.push(userAgentEntry); } } 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..bb025afbf5df --- /dev/null +++ b/generators/csharp/sdk/src/root-client/__test__/buildUserAgentHeaderEntry.test.ts @@ -0,0 +1,165 @@ +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, 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. + const entry = buildUserAgentHeaderEntry({ + userAgent: { header: "User-Agent", value: "MyClient/1.2.3" }, + packageName: "Plantstore", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess(), + userAgentNameFromPackage: 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("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. + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: "Plantstore", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess(), + userAgentNameFromPackage: 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-name-from-package`. + const entry = buildUserAgentHeaderEntry({ + userAgent: undefined, + packageName: "Plantstore", + csharp: generation.csharp, + versionValueAccess: fakeVersionAccess(), + userAgentNameFromPackage: true + }); + + if (entry == null) { + 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}"'); + }); + + 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(), + userAgentNameFromPackage: true + }); + + if (entry == null) { + throw new Error("Expected entry to be defined when `userAgentNameFromPackage` is true."); + } + 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. + // 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"); + }), + userAgentNameFromPackage: true + }); + + if (entry == null) { + 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 new file mode 100644 index 000000000000..c0d41a82e9a2 --- /dev/null +++ b/generators/csharp/sdk/src/root-client/buildUserAgentHeaderEntry.ts @@ -0,0 +1,49 @@ +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, + * 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. + * `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, + userAgentNameFromPackage +}: { + userAgent: FernIr.UserAgent | undefined; + packageName: string; + csharp: { codeblock: (arg: ast.CodeBlock.Arg) => ast.CodeBlock }; + versionValueAccess: ast.CodeBlock; + userAgentNameFromPackage: boolean; +}): ast.Dictionary.MapEntry | undefined { + if (userAgent != null) { + return { + key: csharp.codeblock(`"${userAgent.header}"`), + value: csharp.codeblock(`"${userAgent.value}"`) + }; + } + if (!userAgentNameFromPackage) { + return undefined; + } + return { + key: csharp.codeblock('"User-Agent"'), + value: csharp.codeblock((writer) => { + writer.write(`$"${packageName}/{`); + writer.writeNode(versionValueAccess); + writer.write('}"'); + }) + }; +}