Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions generators/csharp/codegen/src/context/generation-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<NuGetPackageId>/<version>` 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `$"<NuGetPackageId>/{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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
`<NuGetPackageId>/<version>` `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
20 changes: 15 additions & 5 deletions generators/csharp/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -330,11 +331,20 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
key: this.csharp.codeblock(`"${platformHeaders.sdkVersion}"`),
value: this.context.getCurrentVersionValueAccess()
});
if (platformHeaders.userAgent != null) {
platformHeaderEntries.push({
key: this.csharp.codeblock(`"${platformHeaders.userAgent.header}"`),
value: this.csharp.codeblock(`"${platformHeaders.userAgent.value}"`)
});
// When `user-agent-name-from-package` is enabled, falls back to
// `$"<NuGetPackageId>/{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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>(),
allTypeClassReferences: new Map<string, Set<string>>(),
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 `<packageName>/{Version.Current}` when `userAgent` is undefined and the flag is on", () => {
// Opt-in parity with the TypeScript generator's
// `<npm-package-name>/<version>` 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(<organization>_<apiName>)` — 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<string>(),
allTypeClassReferences: new Map<string, Set<string>>(),
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}"');
});
});
Original file line number Diff line number Diff line change
@@ -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 `$"<NuGetPackageId>/{Version.Current}"` — mirroring the
* TypeScript generator's `<npm-package-name>/<version>` 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('}"');
})
};
}