Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { AbstractAstNode } from "../ast/index.js";
import { AbstractDynamicSnippetsGeneratorContext } from "./AbstractDynamicSnippetsGeneratorContext.js";
import { AbstractEndpointSnippetGenerator } from "./AbstractEndpointSnippetGenerator.js";
import { InvocationSnippetResponse } from "./InvocationSnippetResponse.js";
import { Options } from "./Options.js";
import { Result } from "./Result.js";

Expand Down Expand Up @@ -100,6 +101,73 @@ export abstract class AbstractDynamicSnippetsGenerator<
return result.getResponseOrThrow({ endpoint: request.endpoint });
}

/**
* Generates the structured pieces of an endpoint invocation for callers that render the
* invocation within code of their own (e.g. a documentation code template): the bare call
* (e.g. `client.plants.update(...)`), the imports the call requires, and the generated
* client class/type name.
*
* Returns undefined only if this generator does not support invocation-only snippets, so
* that callers can fall back to the complete snippet. This is the capability check the
* multi-language fan-out relies on. When the generator does support them, invocations that
* reference imported SDK types (e.g. branded string aliases) return those imports in the
* `imports` field rather than bailing out.
*/
public generateInvocationSync(
request: FernIr.dynamic.EndpointSnippetRequest,
options: Options = {}
): InvocationSnippetResponse | undefined {
const endpoints = this.resolveEndpoints({ request, options });
if (endpoints.length === 0) {
throw new Error(`No endpoints found that match "${request.endpoint.method} ${request.endpoint.path}"`);
}
let bestResponse: InvocationSnippetResponse | undefined = undefined;
let lastError: Error | undefined = undefined;
for (const endpoint of endpoints) {
const context = this.context.clone() as Context;
const snippetGenerator = this.createSnippetGenerator(context);
if (snippetGenerator.generateInvocationSnippetSync == null) {
return undefined;
}
try {
const response = snippetGenerator.generateInvocationSnippetSync({ endpoint, request, options });
if (response == null) {
return undefined;
}
if (context.errors.empty()) {
return response;
}
if (this.shouldUpdateInvocationResponse({ candidate: response, current: bestResponse })) {
bestResponse = response;
}
} catch (error) {
if (lastError == null) {
lastError = error as Error;
}
}
}
if (bestResponse != null) {
return bestResponse;
}
throw (
lastError ??
new Error(`Failed to generate snippet for endpoint "${request.endpoint.method} ${request.endpoint.path}"`)
);
}

private shouldUpdateInvocationResponse({
candidate,
current
}: {
candidate: InvocationSnippetResponse;
current: InvocationSnippetResponse | undefined;
}): boolean {
if (current == null) {
return true;
}
return candidate.snippet.length > current.snippet.length;
}

/**
* Resolves endpoints based on the request and options.
* If an endpointId is specified in options, returns only that specific endpoint.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { AbstractAstNode } from "../ast/index.js";
import { AbstractDynamicSnippetsGeneratorContext } from "./AbstractDynamicSnippetsGeneratorContext.js";
import { InvocationSnippetResponse } from "./InvocationSnippetResponse.js";
import { Options } from "./Options.js";

export abstract class AbstractEndpointSnippetGenerator<Context extends AbstractDynamicSnippetsGeneratorContext> {
Expand Down Expand Up @@ -33,4 +34,24 @@ export abstract class AbstractEndpointSnippetGenerator<Context extends AbstractD
request: FernIr.dynamic.EndpointSnippetRequest;
options?: Options;
}): Promise<AbstractAstNode>;

/**
* Generates the structured pieces of an endpoint invocation for callers that render the
* invocation within code of their own (e.g. a documentation code template): the bare call
* (e.g. `client.plants.update(...)`), the imports the call requires, and the generated
* client class/type name.
*
* Implemented per generator; generators that do not implement it are detected via the
* absence of this method (the capability check callers rely on) and fall back to the
* complete snippet.
*/
public generateInvocationSnippetSync?({
endpoint,
request,
options
}: {
endpoint: FernIr.dynamic.Endpoint;
request: FernIr.dynamic.EndpointSnippetRequest;
options?: Options;
}): InvocationSnippetResponse | undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { FernIr } from "@fern-api/dynamic-ir-sdk";

/**
* The structured result of an invocation-only snippet.
*
* Unlike {@link FernIr.dynamic.EndpointSnippetResponse}, which returns a single fully-formed
* snippet string, this exposes the individual pieces a docs template needs to render (and
* keep in sync) an invocation on its own: the bare call, the imports the call requires, and
* the generated client class/type name.
*/
export interface InvocationSnippetResponse {
/**
* The bare invocation/call (e.g. `client.plants.update(...)`) with no imports, no client
* instantiation, and no trailing statement terminator. Honors `options.clientVariableName`.
*/
snippet: string;
/**
* The import block the call requires (e.g. an SDK namespace import for a branded string
* alias). Empty string when the call references no imports.
*/
imports: string;
/**
* The generated client class/type name (e.g. `AcmeClient`), so docs can render
* `new {{clientName}}(...)` and track renames of the SDK client.
*/
clientName: string;
errors: FernIr.dynamic.Error_[] | undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export interface Options {
// This is useful when multiple endpoints have the same HTTP method and path
// across different namespaces, and we need to generate a snippet for a specific one.
endpointId?: string;

// The name of the variable the endpoint is invoked on. Only used when generating
// an invocation-only snippet, where the client is instantiated by the caller.
clientVariableName?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { AbstractDynamicSnippetsGenerator } from "./AbstractDynamicSnippetsGener
export { AbstractDynamicSnippetsGeneratorContext } from "./AbstractDynamicSnippetsGeneratorContext.js";
export { type DiscriminatedUnionTypeInstance } from "./DiscriminatedUnionTypeInstance.js";
export { ErrorReporter, Severity } from "./ErrorReporter.js";
export { type InvocationSnippetResponse } from "./InvocationSnippetResponse.js";
export { type Options, Style } from "./Options.js";
export { Result } from "./Result.js";
export { Scope } from "./Scope.js";
Expand Down
45 changes: 45 additions & 0 deletions generators/csharp/codegen/src/ast/core/AstNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,51 @@ export abstract class AstNode extends AbstractAstNode {
return formatter != null ? formatter.format(stringNode) : Promise.resolve(stringNode);
}

/**
* Renders the node body separately from the `using ...;` block it references. This is the C#
* analogue of the TypeScript AST's `toStringWithoutImports` (and the Java AST's
* `renderNodeWithoutImports`): `code` is the node's rendered body with no leading `using`
* block, and `imports` is the rendered `using ...;` block the body would otherwise need
* (empty string when none). This lets callers embed an invocation inside code they already
* own (e.g. a documentation code template) while surfacing the usings the call requires.
*
* A single write pass populates the writer's references, so `code` and `imports` are computed
* from the same render — exactly the split `toString` performs internally when prepending the
* using block. `skipGlobalQualifier` matches the user-facing snippet style (no `global::`).
*/
public toStringWithoutImports({
namespace,
allNamespaceSegments,
allTypeClassReferences,
generation,
formatter,
skipGlobalQualifier = false
}: {
namespace: string;
allNamespaceSegments: Set<string>;
allTypeClassReferences: Map<string, Set<Namespace>>;
generation: Generation;
formatter?: AbstractFormatter;
skipGlobalQualifier?: boolean;
}): { code: string; imports: string } {
const writer = new Writer({
namespace,
allNamespaceSegments,
allTypeClassReferences,
generation,
skipGlobalQualifier
});
this.write(writer);
// `writer.toString(true)` returns only the buffer (skipping the using block), while
// `importsToString()` returns the using block the body references.
const body = writer.toString(true);
const imports = writer.importsToString() ?? "";
return {
code: formatter != null ? formatter.formatSync(body) : body,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

formatSync is being handed a bare expression fragment, not a compilation unit. Most C# formatters (csharpier) will throw or mangle on unparseable input. No caller passes a formatter today, so this is latent — either drop the formatter param from this helper or document that callers must pass a formatter tolerant of fragments.

imports
};
}

public toFormattedSnippet({
allNamespaceSegments,
allTypeClassReferences,
Expand Down
64 changes: 61 additions & 3 deletions generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { NamedArgument, Options, Scope, Severity, Style } from "@fern-api/browser-compatible-base-generator";
import {
InvocationSnippetResponse,
NamedArgument,
Options,
Scope,
Severity,
Style
} from "@fern-api/browser-compatible-base-generator";
import { assertNever } from "@fern-api/core-utils";
import { ast, is, WithGeneration } from "@fern-api/csharp-codegen";
import { FernIr } from "@fern-api/dynamic-ir-sdk";
Expand All @@ -12,6 +19,9 @@ import { FilePropertyInfo } from "./context/FilePropertyMapper.js";
// DEFAULT_REQUEST_PARAMETER_NAME in convertHttpSdkRequest.ts).
const REQUEST_PARAMETER_NAME = "request";

// The generated full snippet constructs and invokes the client through a local named `client`.
const CLIENT_VAR_NAME = "client";

export class EndpointSnippetGenerator extends WithGeneration {
private context: DynamicSnippetsGeneratorContext;

Expand Down Expand Up @@ -75,6 +85,52 @@ export class EndpointSnippetGenerator extends WithGeneration {
throw new Error("Unsupported");
}

/**
* Generates the structured pieces of an endpoint invocation for callers that render the
* invocation within code of their own (e.g. a documentation code template): the bare call
* (e.g. `client.Plants.Update(...)`), the `using ...;` block the call requires, and the
* generated client class name.
*/
public generateInvocationSnippetSync({
endpoint,
request,
options
}: {
endpoint: FernIr.dynamic.Endpoint;
request: FernIr.dynamic.EndpointSnippetRequest;
options?: Options;
}): InvocationSnippetResponse {
const invocation = this.callMethod({
endpoint,
snippet: request,
clientVariableName: options?.clientVariableName
});
// The caller supplies the client and terminates the statement themselves, so the
// invocation is emitted as a bare expression: no `var client = new ...Client()`
// construction, no `Examples` class/method scaffold, and no trailing `;`. When the call
// references SDK or stdlib types (e.g. a body value constructed with `DateTime` or a
// `Guid`) the `using ...;` block it needs is surfaced separately so the caller can render
// it rather than falling back to the complete snippet.
const { code, imports } = invocation.toStringWithoutImports({
Comment on lines +110 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Invocation-only snippets skip the check that the requested example belongs to the endpoint

The invocation-only snippet path builds the call directly (this.callMethod(...) at generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts:110) without the example-matching validation the normal snippet path performs, so a request naming an example that belongs to a different endpoint silently produces a snippet for the wrong endpoint instead of being rejected.
Impact: Documentation can show a code sample for the wrong API operation when several operations share the same method and path.

Missing example-id guard compared with the full-snippet builder

buildCodeBlock (generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts:141-160) throws "Endpoint does not have an example that matches the snippet" when the request is an EndpointExample whose id is not among endpoint.examples. AbstractDynamicSnippetsGenerator relies on that throw to skip candidate endpoints: resolveEndpoints can return multiple endpoints for the same method+path (different namespaces), and both generateSync and generateInvocationSync (generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGenerator.ts:116-152) iterate the candidates and return the first result that produced no errors.

Because generateInvocationSnippetSync bypasses buildCodeBlock, no throw occurs, so the first candidate endpoint always wins even when the example id identifies a different one — diverging from the behavior of generateSnippetSync for identical input.

Prompt for agents
In generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts, generateInvocationSnippetSync calls callMethod directly and therefore skips the validation that buildCodeBlock performs: when the incoming snippet is a DynamicIR EndpointExample, buildCodeBlock throws if no example on the endpoint has a matching id. AbstractDynamicSnippetsGenerator.generateInvocationSync iterates all endpoints matching the method+path and returns the first successful response, so without the throw the wrong endpoint (e.g. a same-path endpoint in a different namespace) can be selected. Consider extracting the example-id validation into a small helper used by both buildCodeBlock and generateInvocationSnippetSync so the invocation-only path performs the same check.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

namespace: "Examples",
generation: this.generation,
allNamespaceSegments: new Set(),
allTypeClassReferences: new Map(),
Comment on lines +115 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

The full-snippet path renders with the real context (this.context.getNamespace() / getAllNamespaceSegments() / getAllTypeClassReferences()), while this passes a hardcoded "Examples" namespace and empty collections. Those inputs drive namespace-collision detection and type qualification in Writer, so an invocation referencing a type whose name collides with a namespace segment can render differently (or unqualified/ambiguously) here versus the full snippet — and the imports block computed alongside it will match that divergent render. Pass the same context values used by generateSnippet.

// See generateSnippet for rationale: user-facing snippets skip the global:: qualifier.
skipGlobalQualifier: true
});
return {
snippet: this.stripTrailingSemicolon(code.trim()),
imports,
clientName: this.Types.RootClientForSnippets.name,
errors: this.context.errors.empty() ? undefined : this.context.errors.toDynamicSnippetErrors()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

this.context.errors is shared across calls on the same generator instance; if this method is invoked repeatedly without a scope/reset, errors from a prior endpoint will leak into this response. Confirm the caller scopes errors the way generateSnippet does (this.context.errors.scope(...)).

};
}

private stripTrailingSemicolon(code: string): string {
return code.endsWith(";") ? code.slice(0, -1).trimEnd() : code;
}

private buildCodeBlock({
endpoint,
snippet,
Expand Down Expand Up @@ -152,17 +208,19 @@ export class EndpointSnippetGenerator extends WithGeneration {

private callMethod({
endpoint,
snippet
snippet,
clientVariableName
}: {
endpoint: FernIr.dynamic.Endpoint;
snippet: FernIr.dynamic.EndpointSnippetRequest;
clientVariableName?: string;
}): ast.CodeBlock | ast.MethodInvocation {
// if the example has *any* sample with stream set to true, then the method is an async enumerable
const isAsyncEnumerable =
endpoint.response?.type === "streaming" || endpoint.response?.type === "streamParameter";

const invocation = this.csharp.invokeMethod({
on: this.csharp.codeblock("client"),
on: this.csharp.codeblock(clientVariableName ?? CLIENT_VAR_NAME),
method: this.getMethod({ endpoint }),
arguments_: this.getMethodArgs({ endpoint, snippet }),
async: true,
Expand Down
Loading