diff --git a/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGenerator.ts b/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGenerator.ts index 847db8f71e0f..babb49d2d9d2 100644 --- a/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGenerator.ts +++ b/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGenerator.ts @@ -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"; @@ -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. diff --git a/generators/browser-compatible-base/src/dynamic-snippets/AbstractEndpointSnippetGenerator.ts b/generators/browser-compatible-base/src/dynamic-snippets/AbstractEndpointSnippetGenerator.ts index ba9d9f8af9eb..a13565fc13f8 100644 --- a/generators/browser-compatible-base/src/dynamic-snippets/AbstractEndpointSnippetGenerator.ts +++ b/generators/browser-compatible-base/src/dynamic-snippets/AbstractEndpointSnippetGenerator.ts @@ -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 { @@ -33,4 +34,24 @@ export abstract class AbstractEndpointSnippetGenerator; + + /** + * 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; } diff --git a/generators/browser-compatible-base/src/dynamic-snippets/InvocationSnippetResponse.ts b/generators/browser-compatible-base/src/dynamic-snippets/InvocationSnippetResponse.ts new file mode 100644 index 000000000000..7aef7193dcd1 --- /dev/null +++ b/generators/browser-compatible-base/src/dynamic-snippets/InvocationSnippetResponse.ts @@ -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; +} diff --git a/generators/browser-compatible-base/src/dynamic-snippets/Options.ts b/generators/browser-compatible-base/src/dynamic-snippets/Options.ts index 5cbccf6b445e..61023f0909ae 100644 --- a/generators/browser-compatible-base/src/dynamic-snippets/Options.ts +++ b/generators/browser-compatible-base/src/dynamic-snippets/Options.ts @@ -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; } diff --git a/generators/browser-compatible-base/src/dynamic-snippets/index.ts b/generators/browser-compatible-base/src/dynamic-snippets/index.ts index 775b9746b2b4..a63f1c1dab51 100644 --- a/generators/browser-compatible-base/src/dynamic-snippets/index.ts +++ b/generators/browser-compatible-base/src/dynamic-snippets/index.ts @@ -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"; diff --git a/generators/csharp/codegen/src/ast/core/AstNode.ts b/generators/csharp/codegen/src/ast/core/AstNode.ts index c4b3b2c4ea63..8ebfc7a9a95f 100644 --- a/generators/csharp/codegen/src/ast/core/AstNode.ts +++ b/generators/csharp/codegen/src/ast/core/AstNode.ts @@ -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; + allTypeClassReferences: Map>; + 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, + imports + }; + } + public toFormattedSnippet({ allNamespaceSegments, allTypeClassReferences, diff --git a/generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts index 92061e5d55da..3ca1db82b764 100644 --- a/generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -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"; @@ -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; @@ -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({ + namespace: "Examples", + generation: this.generation, + allNamespaceSegments: new Set(), + allTypeClassReferences: new Map(), + // 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() + }; + } + + private stripTrailingSemicolon(code: string): string { + return code.endsWith(";") ? code.slice(0, -1).trimEnd() : code; + } + private buildCodeBlock({ endpoint, snippet, @@ -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, diff --git a/generators/csharp/dynamic-snippets/src/__test__/InvocationSnippet.test.ts b/generators/csharp/dynamic-snippets/src/__test__/InvocationSnippet.test.ts new file mode 100644 index 000000000000..e848ce3b54c9 --- /dev/null +++ b/generators/csharp/dynamic-snippets/src/__test__/InvocationSnippet.test.ts @@ -0,0 +1,116 @@ +import { AbsoluteFilePath, join } from "@fern-api/path-utils"; + +import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator.js"; +import { buildGeneratorConfig } from "./utils/buildGeneratorConfig.js"; + +const DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY = AbsoluteFilePath.of( + `${__dirname}/../../../../../packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions` +); + +// invocation-only snippets are rendered inside code the caller already owns (e.g. a +// documentation code template), so they must not include client instantiation or the +// surrounding `Examples` class/method scaffold. The `using ...;` block the call references is +// surfaced separately in the `imports` field. A plain C# invocation references no usings, so +// `imports` is empty for a bare call and only populated when the invocation constructs imported +// types inline. +describe("invocation-only snippets", () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "exhaustive.json")), + config: buildGeneratorConfig({}) + }); + + const request = { + endpoint: { + method: "GET" as const, + path: "/http-methods/{id}" + }, + baseURL: undefined, + environment: undefined, + auth: { + type: "bearer" as const, + token: "" + }, + pathParameters: { + id: "id" + }, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }; + + it("generates the invocation without client instantiation or class scaffold", () => { + const response = generator.generateInvocationSync(request); + + // The generated invocation is multiline (matching the full snippet's `invokeMethod` + // rendering), so the path argument sits on its own line. + expect(response?.snippet).toBe('await client.Endpoints.HTTPMethods.TestGetAsync(\n "id"\n)'); + // The invocation is a bare expression: no `var client = new ...Client(...)` construction, + // no `Examples` class/method wrapper, no `using ...;` block, and no trailing `;`. + expect(response?.snippet).not.toContain("var client"); + expect(response?.snippet).not.toContain("new "); + expect(response?.snippet).not.toContain("class Examples"); + expect(response?.snippet).not.toContain("using "); + expect(response?.snippet.endsWith(";")).toBe(false); + expect(response?.errors).toBeUndefined(); + }); + + it("returns no imports for a bare call that references none", () => { + const response = generator.generateInvocationSync(request); + + expect(response?.imports).toBe(""); + }); + + it("exposes the generated client class name so docs can render the client instantiation", () => { + const response = generator.generateInvocationSync(request); + + expect(response?.clientName).toBe("AcmeClient"); + }); + + it("invokes the endpoint on the requested client variable", () => { + const response = generator.generateInvocationSync(request, { clientVariableName: "mailchimp" }); + + expect(response?.snippet).toBe('await mailchimp.Endpoints.HTTPMethods.TestGetAsync(\n "id"\n)'); + }); + + it("surfaces the using block the invocation references instead of falling back to the full snippet", () => { + // This body references types the call constructs inline (a DateTime and a Guid), so the + // invocation must carry the corresponding `using System;` directive. The previous + // invocation-only contract had no way to express this; now the usings are surfaced + // separately so docs can regenerate both the call and the usings it needs. + const response = generator.generateInvocationSync({ + endpoint: { + method: "POST" as const, + path: "/object/get-and-return-with-optional-field" + }, + baseURL: undefined, + environment: undefined, + auth: { + type: "bearer" as const, + token: "" + }, + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: { + string: "string", + integer: 1, + long: 1000000, + double: 1.1, + bool: true, + datetime: "2024-01-15T09:30:00Z", + date: "2023-01-15", + uuid: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32", + base64: "SGVsbG8gd29ybGQh", + list: ["list", "list"], + set: ["set"], + map: { 1: "map" }, + bigint: "1000000" + } + }); + + expect(response).not.toBeUndefined(); + expect(response?.snippet).toContain("DateTime"); + expect(response?.imports).toContain("using System;"); + expect(response?.errors).toBeUndefined(); + }); +}); diff --git a/generators/csharp/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml b/generators/csharp/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml new file mode 100644 index 000000000000..1e5876a08977 --- /dev/null +++ b/generators/csharp/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Generate invocation-only dynamic snippets. In addition to the full snippet + (client construction plus the call), the generator now exposes the structured + pieces a caller can render inside code it already owns (e.g. a documentation + code template): the bare invocation (`await client.Endpoints.Update(...)`, + honoring a custom client variable name), the C# `using ...;` block that + invocation references (empty string when none), and the generated client class + name so docs can render `var client = new (...)` and track renames. + + The `using` block is captured separately from the call via a new + `AstNode.toStringWithoutImports` helper, the C# analogue of the TypeScript AST's + `toStringWithoutImports` (and the Java AST's `renderNodeWithoutImports`): a single + render pass yields the node body and the `using ...;` directives it references. + Invocations that construct imported types inline (e.g. a body value built with a + `System.DateTime` or `System.Guid`) surface those usings rather than falling back + to the complete snippet. A plain C# invocation references no usings, so a bare + call returns an empty using block. + type: feat diff --git a/generators/typescript-v2/ast/src/ast/core/AstNode.ts b/generators/typescript-v2/ast/src/ast/core/AstNode.ts index 92d7b9eaaa97..c2ae4084e53c 100644 --- a/generators/typescript-v2/ast/src/ast/core/AstNode.ts +++ b/generators/typescript-v2/ast/src/ast/core/AstNode.ts @@ -30,4 +30,25 @@ export abstract class AstNode extends AbstractAstNode { this.write(file); return file.toString(); } + + /** + * Writes the node without the import statements it references. `imports` is the rendered + * import block the code would otherwise need (empty string when none), and `hasImports` + * reports whether any were separated out. + */ + public toStringWithoutImports({ + customConfig, + formatter + }: { + customConfig: TypescriptCustomConfigSchema | undefined; + formatter?: AbstractFormatter; + }): { code: string; imports: string; hasImports: boolean } { + const file = new TypeScriptFile({ customConfig, formatter }); + this.write(file); + return { + code: file.toString({ omitImports: true }), + imports: file.getImports(), + hasImports: file.hasImports() + }; + } } diff --git a/generators/typescript-v2/ast/src/ast/core/TypeScriptFile.ts b/generators/typescript-v2/ast/src/ast/core/TypeScriptFile.ts index e350e92c8281..6558d2340c5d 100644 --- a/generators/typescript-v2/ast/src/ast/core/TypeScriptFile.ts +++ b/generators/typescript-v2/ast/src/ast/core/TypeScriptFile.ts @@ -9,8 +9,8 @@ export class TypeScriptFile extends Writer { super({ customConfig, formatter }); } - public async toStringAsync(): Promise { - const content = this.getContent(); + public async toStringAsync({ omitImports }: { omitImports?: boolean } = {}): Promise { + const content = this.getContent({ omitImports }); if (this.formatter != null) { try { return this.formatter.format(content); @@ -21,8 +21,8 @@ export class TypeScriptFile extends Writer { return content; } - public toString(): string { - const content = this.getContent(); + public toString({ omitImports }: { omitImports?: boolean } = {}): string { + const content = this.getContent({ omitImports }); if (this.formatter != null) { try { return this.formatter.formatSync(content); @@ -33,7 +33,10 @@ export class TypeScriptFile extends Writer { return content; } - public getContent(): string { + public getContent({ omitImports }: { omitImports?: boolean } = {}): string { + if (omitImports) { + return this.buffer; + } const imports = this.stringifyImports(); if (imports.length > 0) { return imports + "\n" + this.buffer; @@ -41,6 +44,22 @@ export class TypeScriptFile extends Writer { return this.buffer; } + /** + * Whether anything written to this file references an import. + */ + public hasImports(): boolean { + return this.stringifyImports().length > 0; + } + + /** + * The rendered import block for everything written to this file, or an empty string when + * nothing references an import. Does not include the trailing blank line the full file + * output places between the imports and the body. + */ + public getImports(): string { + return this.stringifyImports().trimEnd(); + } + private stringifyImports(): string { let result = ""; for (const [module, references] of Object.entries(this.imports)) { diff --git a/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index 74a91c959ef4..88820924cdf4 100644 --- a/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -1,4 +1,4 @@ -import { Scope, Severity } from "@fern-api/browser-compatible-base-generator"; +import { InvocationSnippetResponse, Options, Scope, Severity } from "@fern-api/browser-compatible-base-generator"; import { assertNever } from "@fern-api/core-utils"; import { FernIr } from "@fern-api/dynamic-ir-sdk"; import { AstNode, ts } from "@fern-api/typescript-ast"; @@ -52,6 +52,36 @@ export class EndpointSnippetGenerator { return this.buildCodeBlock({ endpoint, snippet: request }); } + 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, + await_: false + }); + // The caller supplies the client and terminates the statement themselves, so the + // invocation is emitted as a bare expression. When the call references SDK types + // (e.g. branded aliases), the imports it needs are surfaced separately so the caller + // can render them rather than falling back to the complete snippet. + const { code, imports } = invocation.toStringWithoutImports({ + customConfig: this.context.customConfig + }); + return { + snippet: code.trim().replace(/;$/, ""), + imports, + clientName: this.context.getRootClientName(), + errors: this.context.errors.empty() ? undefined : this.context.errors.toDynamicSnippetErrors() + }; + } + private buildCodeBlock({ endpoint, snippet @@ -67,7 +97,7 @@ export class EndpointSnippetGenerator { parameters: [], body: ts.codeblock((writer) => { writer.writeNodeStatement(this.constructClient({ endpoint, snippet })); - writer.writeNodeStatement(this.callMethod({ endpoint, snippet })); + writer.writeNodeStatement(this.callMethod({ endpoint, snippet, await_: true })); }) }) ); @@ -381,15 +411,19 @@ export class EndpointSnippetGenerator { private callMethod({ endpoint, - snippet + snippet, + clientVariableName, + await_ }: { endpoint: FernIr.dynamic.Endpoint; snippet: FernIr.dynamic.EndpointSnippetRequest; + clientVariableName?: string; + await_: boolean; }): ts.AstNode { return ts.invokeMethod({ - on: ts.reference({ name: CLIENT_VAR_NAME }), + on: ts.reference({ name: clientVariableName ?? CLIENT_VAR_NAME }), method: this.getMethod({ endpoint }), - async: true, + async: await_, arguments_: this.getMethodArgs({ endpoint, snippet }) }); } diff --git a/generators/typescript-v2/dynamic-snippets/src/__test__/InvocationSnippet.test.ts b/generators/typescript-v2/dynamic-snippets/src/__test__/InvocationSnippet.test.ts new file mode 100644 index 000000000000..f0c0e3dcc8b1 --- /dev/null +++ b/generators/typescript-v2/dynamic-snippets/src/__test__/InvocationSnippet.test.ts @@ -0,0 +1,86 @@ +import { AbsoluteFilePath, join } from "@fern-api/path-utils"; + +import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator.js"; +import { buildGeneratorConfig } from "./utils/buildGeneratorConfig.js"; + +const DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY = AbsoluteFilePath.of( + `${__dirname}/../../../../../packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions` +); + +// invocation-only snippets are rendered inside code the caller already owns (e.g. a +// documentation code template), so they must not include imports or client instantiation +describe("invocation-only snippets", () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "exhaustive.json")), + config: buildGeneratorConfig({}) + }); + + const request = { + endpoint: { + method: "PUT" as const, + path: "/http-methods/{id}" + }, + baseURL: undefined, + environment: undefined, + auth: { + type: "bearer" as const, + token: "" + }, + pathParameters: { + id: "id" + }, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }; + + it("generates the invocation without imports or client instantiation", () => { + const response = generator.generateInvocationSync(request); + + expect(response?.snippet).toBe('client.endpoints.httpMethods.testPut("id")'); + expect(response?.imports).toBe(""); + expect(response?.errors).toBeUndefined(); + }); + + it("exposes the generated client class name so docs can render the client instantiation", () => { + const response = generator.generateInvocationSync(request); + + expect(response?.clientName).toBe("AcmeClient"); + }); + + it("invokes the endpoint on the requested client variable", () => { + const response = generator.generateInvocationSync(request, { clientVariableName: "mailchimp" }); + + expect(response?.snippet).toBe('mailchimp.endpoints.httpMethods.testPut("id")'); + }); + + it("returns the imports the invocation references instead of falling back to the full snippet", () => { + const brandedGenerator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "alias.json")), + config: buildGeneratorConfig({ customConfig: { useBrandedStringAliases: true } }) + }); + + const response = brandedGenerator.generateInvocationSync({ + endpoint: { + method: "GET" as const, + path: "/{typeId}" + }, + baseURL: undefined, + environment: undefined, + auth: undefined, + pathParameters: { + typeId: "type-abc123" + }, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }); + + // The branded-alias case used to return undefined; now it returns the call plus the + // import the call needs (the SDK namespace import), so docs can regenerate both. + expect(response).not.toBeUndefined(); + expect(response?.snippet).toBe('client.get(Acme.TypeID("type-abc123"))'); + expect(response?.imports).toContain("import"); + expect(response?.imports).toContain("Acme"); + }); +}); diff --git a/generators/typescript/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml b/generators/typescript/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml new file mode 100644 index 000000000000..6add3e673bc6 --- /dev/null +++ b/generators/typescript/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml @@ -0,0 +1,13 @@ +- summary: | + Dynamic snippets can now generate the endpoint invocation on its own (without imports + or client instantiation), so that callers such as documentation code templates can + render a generated SDK call inside code they author themselves. The client variable the + endpoint is invoked on is configurable. + + The invocation-only result is now a structured object exposing the bare call (`snippet`), + the import block the call requires (`imports`, empty when none), and the generated client + class name (`clientName`), so docs can regenerate the imports and client instantiation and + keep them in sync across SDK renames. Invocations that reference imported SDK types (e.g. + branded string aliases) now return those imports instead of falling back to the full + snippet. + type: feat