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
11 changes: 11 additions & 0 deletions generators/csharp/codegen/src/context/generation-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export class Generation {
omitFernHeaders: () => this.customConfig["omit-fern-headers"] ?? false,
/** When true, emits the platform observability headers (X-Fern-Runtime, X-Fern-Runtime-Version, X-Fern-Platform). Default: false. Still subject to omitFernHeaders. */
includePlatformHeaders: () => this.customConfig["include-platform-headers"] ?? false,
/** When true, exposes an `AppInfo` client option whose sanitized product token is appended to the `User-Agent` header (RFC 9110). Default: false. Independent of includePlatformHeaders; still subject to omitFernHeaders. */
allowUserAgentAppInfo: () => this.customConfig["allow-user-agent-app-info"] ?? 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. */
Expand Down Expand Up @@ -577,6 +579,15 @@ export class Generation {
origin: this.model.staticExplicit("ClientOptions"),
namespace: this.namespaces.publicCoreClasses
}),
/**
* Optional application-info product token appended to the `User-Agent`
* header. Only generated when the `allow-user-agent-app-info` config is on.
*/
AppInfo: () =>
this.csharp.classReference({
origin: this.model.staticExplicit("AppInfo"),
namespace: this.namespaces.publicCoreClasses
}),
/** Low-level HTTP client wrapper for making raw API calls */
RawClient: () =>
this.csharp.classReference({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export const CsharpConfigSchema = z.object({
// Off by default so existing generated output is unchanged. Still subject to
// `omit-fern-headers`.
"include-platform-headers": z.boolean().optional(),
// When true, generated clients accept an `AppInfo` client option (`Name`,
// `Version?`, `Comment?`) whose sanitized product token is appended to whatever
// `User-Agent` the SDK would otherwise send (`{sdk}/{version} ... {product}/{ver}
// ({comment})`), following RFC 9110. Off by default so existing generated output
// is unchanged. Independent of `include-platform-headers`; still overridable by an
// explicit `User-Agent` header and suppressed by `omit-fern-headers`.
"allow-user-agent-app-info": z.boolean().optional(),
"unified-client-options": z.boolean().optional(),
// When true (default), server URL variables declared on the API's environments (e.g. region)
// are exposed as ClientOptions properties and interpolated into the environment URL template(s)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Add opt-in `allow-user-agent-app-info` config. When enabled, generated clients expose an
optional `AppInfo` client option (`Name`, `Version?`, `Comment?`) whose sanitized product
token is appended to whatever `User-Agent` the SDK would otherwise send
(`{sdk}/{version} ... {product}/{product-version} ({comment})`), following RFC 9110.
Disabled by default, so existing generated output is byte-identical, and it composes with
`include-platform-headers`, the configured `user-agent` template value, and the
`user-agent-name-from-package` fallback. Caller-supplied values are trimmed and encoded
(name/version percent-encoded to RFC 7230 `tchar`; comment delimiters `(`, `)`, `\` and
control characters incl. CR/LF escaped), so untrusted values cannot inject header content.
Still overridable by an explicit `User-Agent` header and suppressed by `omit-fern-headers`.
Works in both unified and non-unified client-options modes; generated code compiles for
`net462`, `net8.0`, and `netstandard2.0`.
type: feat
9 changes: 9 additions & 0 deletions generators/csharp/sdk/src/SdkGeneratorCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ErrorGenerator } from "./error/ErrorGenerator.js";
import { generateSdkTests } from "./generateSdkTests.js";
import { InferredAuthTokenProviderGenerator } from "./inferred-auth/InferredAuthTokenProviderGenerator.js";
import { OauthTokenProviderGenerator } from "./oauth/OauthTokenProviderGenerator.js";
import { AppInfoGenerator } from "./options/AppInfoGenerator.js";
import { BaseOptionsGenerator } from "./options/BaseOptionsGenerator.js";
import { ClientOptionsGenerator } from "./options/ClientOptionsGenerator.js";
import { IdempotentRequestOptionsGenerator } from "./options/IdempotentRequestOptionsGenerator.js";
Expand Down Expand Up @@ -293,6 +294,14 @@ export class SdkGeneratorCLI extends AbstractCsharpGeneratorCli {
const clientOptions = new ClientOptionsGenerator(context, baseOptionsGenerator);
context.project.addSourceFiles(clientOptions.generate());

// Emit the public `AppInfo` record only when the opt-in
// `allow-user-agent-app-info` config is enabled, so default-off output is
// byte-identical.
if (context.settings.allowUserAgentAppInfo) {
const appInfo = new AppInfoGenerator(context);
context.project.addSourceFiles(appInfo.generate());
}

const requestOptionsInterace = new RequestOptionsInterfaceGenerator(context, baseOptionsGenerator);
context.project.addSourceFiles(requestOptionsInterace.generate());

Expand Down
63 changes: 63 additions & 0 deletions generators/csharp/sdk/src/options/AppInfoGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { CSharpFile, FileGenerator } from "@fern-api/csharp-base";
import { ast } from "@fern-api/csharp-codegen";
import { join, RelativeFilePath } from "@fern-api/fs-utils";
import { APP_INFO_TYPE_NAME } from "../root-client/buildAppInfoUserAgent.js";
import { SdkGeneratorContext } from "../SdkGeneratorContext.js";

/**
* Emits the public `AppInfo` record used by the opt-in `allow-user-agent-app-info`
* feature. Only generated when that flag is enabled, so default-off output is
* unchanged. The sanitized product token built from these fields is appended to the
* SDK's `User-Agent` header (RFC 9110 §10.1.5).
*/
export class AppInfoGenerator extends FileGenerator<CSharpFile, SdkGeneratorContext> {
public doGenerate(): CSharpFile {
const class_ = this.csharp.class_({
reference: this.Types.AppInfo,
type: ast.Class.ClassType.Record,
sealed: true,
access: ast.Access.Public,
summary:
"Application information appended to the `User-Agent` header as an RFC 9110 product token\n(`{Name}/{Version} ({Comment})`). Caller-supplied values are sanitized before being written."
});

class_.addField({
origin: class_.explicit("Name"),
access: ast.Access.Public,
get: true,
init: true,
useRequired: true,
type: this.Primitive.string,
summary: "The product name. Required; when null, empty, or whitespace the `User-Agent` is left unchanged."
});
class_.addField({
origin: class_.explicit("Version"),
access: ast.Access.Public,
get: true,
init: true,
type: this.Primitive.string.asOptional(),
summary: "The optional product version. Omitted from the token when null or blank."
});
class_.addField({
origin: class_.explicit("Comment"),
access: ast.Access.Public,
get: true,
init: true,
type: this.Primitive.string.asOptional(),
summary: "An optional comment (e.g. a homepage URL). Omitted from the token when null or blank."
});

return new CSharpFile({
clazz: class_,
directory: this.context.getPublicCoreDirectory(),
allNamespaceSegments: this.context.getAllNamespaceSegments(),
allTypeClassReferences: this.context.getAllTypeClassReferences(),
namespace: this.namespaces.publicCore,
generation: this.generation
});
}

protected getFilepath(): RelativeFilePath {
return join(this.constants.folders.publicCoreFiles, RelativeFilePath.of(`${APP_INFO_TYPE_NAME}.cs`));
}
}
22 changes: 21 additions & 1 deletion generators/csharp/sdk/src/options/ClientOptionsGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export class ClientOptionsGenerator extends FileGenerator<CSharpFile, SdkGenerat
private environmentExplicitlySetField: ast.Field | undefined;
private serverVariableFields: ast.Field[] = [];
private unifiedFields: UnifiedField[] = [];
/** The opt-in `AppInfo` field, present only when `allow-user-agent-app-info` is enabled. */
private appInfoField: ast.Field | undefined;

public doGenerate(): CSharpFile {
const class_ = this.csharp.class_({
Expand Down Expand Up @@ -72,6 +74,21 @@ export class ClientOptionsGenerator extends FileGenerator<CSharpFile, SdkGenerat
this.baseOptionsGenerator.getTimeoutField(class_, optionArgs);
this.baseOptionsGenerator.getLiteralHeaderOptions(class_, optionArgs);

// The opt-in `allow-user-agent-app-info` client option. The sanitized
// product token built from these fields is appended to the SDK's
// `User-Agent` header by the root client. Only emitted when the flag is on so
// default-off output is byte-identical.
if (this.settings.allowUserAgentAppInfo) {
this.appInfoField = class_.addField({
origin: class_.explicit("AppInfo"),
access: ast.Access.Public,
get: true,
init: true,
type: this.Types.AppInfo.asOptional(),
summary: "Application information appended to the `User-Agent` header as an RFC 9110 product token."
});
}

if (isEndpointSecurity(this.context)) {
this.addEndpointSecurityAuthRouting(class_);
}
Expand Down Expand Up @@ -615,7 +632,7 @@ export class ClientOptionsGenerator extends FileGenerator<CSharpFile, SdkGenerat
`(new Dictionary<string, `,
this.Types.HeaderValue,
`>(Headers)),
AdditionalHeaders = AdditionalHeaders,${unifiedFieldLines}
AdditionalHeaders = AdditionalHeaders,${unifiedFieldLines}${this.appInfoField ? `\n ${this.appInfoField.name} = ${this.appInfoField.name},` : ""}
${this.settings.includeExceptionHandler ? "ExceptionHandler = ExceptionHandler.Clone()," : ""}
}`
);
Expand Down Expand Up @@ -689,6 +706,9 @@ export class ClientOptionsGenerator extends FileGenerator<CSharpFile, SdkGenerat
for (const field of this.unifiedFields) {
writer.writeLine(`${field.name} = other.${field.name};`);
}
if (this.appInfoField) {
writer.writeLine(`${this.appInfoField.name} = other.${this.appInfoField.name};`);
}
if (this.settings.includeExceptionHandler) {
writer.writeLine("ExceptionHandler = other.ExceptionHandler.Clone();");
}
Expand Down
71 changes: 69 additions & 2 deletions generators/csharp/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { isEndpointSecurity } from "../endpoint/request/endpointAuthHeaders.js";
import { SdkGeneratorContext } from "../SdkGeneratorContext.js";
import { collectInferredAuthCredentials } from "../utils/inferredAuthUtils.js";
import { WebSocketClientGenerator } from "../websocket/WebsocketClientGenerator.js";
import { APPEND_APP_INFO_METHOD_NAME, buildAppendAppInfoMethodLines } from "./buildAppInfoUserAgent.js";
import { buildUserAgentHeaderEntry } from "./buildUserAgentHeaderEntry.js";
import {
BUILD_USER_AGENT_METHOD_NAME,
Expand Down Expand Up @@ -87,6 +88,12 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
private grpcClientInfo: GrpcClientInfo | undefined;
private oauth: OAuthScheme | undefined;
private inferred: InferredAuthScheme | undefined;
/**
* Set while building the constructor body when the opt-in
* `allow-user-agent-app-info` config actually wraps a written User-Agent value,
* so the emitted `AppendAppInfoToUserAgent` helper is only added when referenced.
*/
private usesAppInfoHelper = false;

constructor(context: SdkGeneratorContext) {
super(context);
Expand Down Expand Up @@ -202,6 +209,14 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
this.addBuildUserAgentMethod(class_);
}

// Emit the self-contained `AppendAppInfoToUserAgent` helper only when the
// opt-in `allow-user-agent-app-info` config actually wrapped a written
// User-Agent value (set by `getConstructorMethod`, invoked above), so
// flag-off output and clients that never send a User-Agent stay unchanged.
if (this.usesAppInfoHelper) {
this.addAppendAppInfoMethod(class_);
}

for (const subpackage of this.getSubpackages()) {
if (this.context.subPackageHasEndpointsRecursively(subpackage)) {
class_.addField({
Expand Down Expand Up @@ -419,13 +434,33 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
key: this.csharp.codeblock(`"${platformHeaders.sdkVersion}"`),
value: this.context.getCurrentVersionValueAccess()
});
// When the opt-in `allow-user-agent-app-info` config is set, wrap the
// computed User-Agent value expression in the emitted
// `AppendAppInfoToUserAgent` helper, which appends the caller-supplied
// `AppInfo` product token. `clientOptions` is already initialized (and
// non-null) at the point the platform-headers dictionary is written, so
// `clientOptions.AppInfo` is safe to read. Keeping this wrapping local to
// the generated client (rather than modifying the shared
// `BuildUserAgent`/core-utilities) keeps flag-off output byte-identical.
const withAppInfo = (userAgentValue: ast.AstNode): ast.AstNode => {
if (!this.settings.allowUserAgentAppInfo) {
return userAgentValue;
}
this.usesAppInfoHelper = true;
return this.csharp.codeblock((writer) => {
writer.write(`${APPEND_APP_INFO_METHOD_NAME}(`);
writer.writeNode(userAgentValue);
writer.write(", clientOptions.AppInfo)");
});
};

if (this.settings.includePlatformHeaders) {
// Emit a single structured `User-Agent` consolidating the SDK
// name/version with the OS, architecture, and runtime, all
// resolved at runtime by the `BuildUserAgent` helper.
platformHeaderEntries.push({
key: this.csharp.codeblock(`"${platformHeaders.userAgent?.header ?? "User-Agent"}"`),
value: this.csharp.codeblock(`${BUILD_USER_AGENT_METHOD_NAME}()`)
value: withAppInfo(this.csharp.codeblock(`${BUILD_USER_AGENT_METHOD_NAME}()`))
});
} else {
// When `user-agent-name-from-package` is enabled, falls back to
Expand All @@ -441,7 +476,10 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
userAgentNameFromPackage: this.settings.userAgentNameFromPackage
});
if (userAgentEntry != null) {
platformHeaderEntries.push(userAgentEntry);
platformHeaderEntries.push({
key: userAgentEntry.key,
value: withAppInfo(userAgentEntry.value)
});
}
}
}
Expand Down Expand Up @@ -1541,6 +1579,35 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
});
}

/**
* Emits the self-contained static `AppendAppInfoToUserAgent(string userAgent,
* AppInfo? appInfo)` helper used by the opt-in `allow-user-agent-app-info`
* feature. It appends the caller-supplied, sanitized `AppInfo` product token to
* whichever `User-Agent` value the SDK would otherwise send. Percent-encodes
* non-RFC-7230 `tchar` in `Name`/`Version` and escapes comment delimiters and
* control characters (incl. CR/LF) in `Comment`; values are trimmed before the
* blank check and before encoding, so blank values are dropped rather than
* encoded into whitespace tokens. Only netstandard2.0/net462-safe APIs are used.
*/
private addAppendAppInfoMethod(cls: ast.Class) {
cls.addMethod({
access: ast.Access.Private,
name: APPEND_APP_INFO_METHOD_NAME,
return_: this.Primitive.string,
parameters: [
this.csharp.parameter({ name: "userAgent", type: this.Primitive.string }),
this.csharp.parameter({ name: "appInfo", type: this.Types.AppInfo.asOptional() })
],
isAsync: false,
body: this.csharp.codeblock((writer) => {
for (const line of buildAppendAppInfoMethodLines()) {
writer.writeLine(line);
}
}),
type: ast.MethodType.STATIC
});
}

private getSubpackages(): Subpackage[] {
return this.context.getSubpackages(this.context.ir.rootPackage.subpackages);
}
Expand Down
Loading