diff --git a/.chronus/changes/replace-client-response-2026-08-06-10-49-09.md b/.chronus/changes/replace-client-response-2026-08-06-10-49-09.md new file mode 100644 index 0000000000..77d2456dfa --- /dev/null +++ b/.chronus/changes/replace-client-response-2026-08-06-10-49-09.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Allow `@override` to replace a client method response and add the `replaceResponse` customization function. diff --git a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts index e001fe3486..deacb85995 100644 --- a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts +++ b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts @@ -1259,6 +1259,26 @@ export type ReorderParametersFunctionImplementation = ( order: readonly string[], ) => Operation; +/** + * Replace the method response type of an operation. + * This preserves the HTTP response metadata and only changes the return type + * of the generated client method when used with `@@override`. + * + * @param operation The operation to transform. + * @param response The replacement method response type. + * @returns A new operation with the response type replaced. + * @example Replace a response with void + * ```typespec + * alias DeleteResponse = replaceResponse(MyService.delete, void); + * @@override(MyService.delete, DeleteResponse); + * ``` + */ +export type ReplaceResponseFunctionImplementation = ( + context: FunctionContext, + operation: Operation, + response: Type, +) => Operation; + /** * Mark a client name as exact, preventing language emitters from applying * their usual casing transformations (e.g., snake_case for Python, camelCase for JavaScript). @@ -1286,5 +1306,6 @@ export type AzureClientGeneratorCoreFunctions = { removeParameter: RemoveParameterFunctionImplementation; addParameter: AddParameterFunctionImplementation; reorderParameters: ReorderParametersFunctionImplementation; + replaceResponse: ReplaceResponseFunctionImplementation; exact: ExactFunctionImplementation; }; diff --git a/packages/typespec-client-generator-core/lib/functions.tsp b/packages/typespec-client-generator-core/lib/functions.tsp index 849aea16e1..e7f6a064e1 100644 --- a/packages/typespec-client-generator-core/lib/functions.tsp +++ b/packages/typespec-client-generator-core/lib/functions.tsp @@ -130,6 +130,24 @@ extern fn reorderParameters( order: valueof string[] ): Reflection.Operation; +/** + * Replace the method response type of an operation. + * This preserves the HTTP response metadata and only changes the return type + * of the generated client method when used with `@@override`. + * + * @param operation The operation to transform. + * @param response The replacement method response type. + * @returns A new operation with the response type replaced. + * + * @example Replace a response with void + * ```typespec + * alias DeleteResponse = replaceResponse(MyService.delete, void); + * @@override(MyService.delete, DeleteResponse); + * ``` + */ +#suppress "experimental-feature" "replaceResponse uses extern fn which is experimental but provides essential response transformation functionality" +extern fn replaceResponse(operation: Reflection.Operation, response: unknown): Reflection.Operation; + /** * Mark a client name as exact, preventing language emitters from applying * their usual casing transformations (e.g., snake_case for Python, camelCase for JavaScript). diff --git a/packages/typespec-client-generator-core/src/functions.ts b/packages/typespec-client-generator-core/src/functions.ts index f04832f4e6..4711c80568 100644 --- a/packages/typespec-client-generator-core/src/functions.ts +++ b/packages/typespec-client-generator-core/src/functions.ts @@ -240,6 +240,27 @@ export function reorderParameters( return cloneOperation(tk, operation, { parameters: newProperties }); } +/** + * Replace the method response type of an operation. + * + * The operation's HTTP response metadata is preserved; only the client method + * return type is changed when the operation is used with `@override`. + * + * @param context The function context provided by TypeSpec + * @param operation The operation to transform + * @param response The replacement method response type + * @returns A new operation with the response type replaced + */ +export function replaceResponse( + context: FunctionContext, + operation: Operation, + response: Type, +): Operation { + return cloneOperation($(context.program), operation, { + returnType: response, + }); +} + /** * Mark a client name as exact, preventing language emitters from applying * their usual casing transformations. @@ -269,7 +290,10 @@ export function hasExactNameMarker(name: string): boolean { * @param name The name to normalize * @returns An object with the clean name and whether it was marked as exact */ -export function normalizeExactName(name: string): { name: string; isExactName: boolean } { +export function normalizeExactName(name: string): { + name: string; + isExactName: boolean; +} { if (name.startsWith(EXACT_NAME_PREFIX)) { return { name: name.slice(EXACT_NAME_PREFIX.length), isExactName: true }; } diff --git a/packages/typespec-client-generator-core/src/methods.ts b/packages/typespec-client-generator-core/src/methods.ts index c0655074b3..728f12b7fb 100644 --- a/packages/typespec-client-generator-core/src/methods.ts +++ b/packages/typespec-client-generator-core/src/methods.ts @@ -167,7 +167,7 @@ function getSdkPagingServiceMethod(context, operation, client), + getSdkBasicServiceMethod(context, operation, client, false), ); // If the response body type itself is nullable (e.g., {@body body: Type | null}), unwrap it for paging/LRO processing @@ -327,7 +327,10 @@ export function getPropertySegmentsFromModelOrParameters( source: SdkModelType | SdkMethodParameter[], predicate: (property: SdkMethodParameter | SdkModelPropertyType) => boolean, ): (SdkMethodParameter | SdkModelPropertyType)[] | undefined { - const queue: { model: SdkModelType; path: (SdkMethodParameter | SdkModelPropertyType)[] }[] = []; + const queue: { + model: SdkModelType; + path: (SdkMethodParameter | SdkModelPropertyType)[]; + }[] = []; if (!Array.isArray(source)) { if (source.baseModel) { @@ -607,8 +610,12 @@ function getSdkMethodResponse( operation: Operation, sdkOperation: SdkServiceOperation, client: SdkClientType, + useResponseOverride = true, ): SdkMethodResponse { const responses = sdkOperation.responses; + const responseOverride = useResponseOverride + ? getOverriddenClientMethod(context, operation)?.returnType + : undefined; const allResponseBodies: SdkType[] = []; let containsResponseWithoutBody = false; @@ -622,7 +629,11 @@ function getSdkMethodResponse( const responseTypes = new Set(allResponseBodies.map((x) => getHashForType(x))); let type: SdkType | undefined = undefined; - if (getResponseAsBool(context, operation)) { + if (responseOverride && isNeverOrVoidType(responseOverride)) { + type = undefined; + } else if (responseOverride) { + type = ignoreDiagnostics(getClientTypeWithDiagnostics(context, responseOverride, operation)); + } else if (getResponseAsBool(context, operation)) { type = getSdkBuiltInType(context, $(context.program).builtin.boolean); } else { if (responseTypes.size > 1) { @@ -678,6 +689,7 @@ export function getSdkBasicServiceMethod, + useResponseOverride = true, ): [SdkServiceMethod, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); const methodParameters: SdkMethodParameter[] = []; @@ -722,7 +734,13 @@ export function getSdkBasicServiceMethod(context, operation, methodParameters, client), ); - const response = getSdkMethodResponse(context, operation, serviceOperation, client); + const response = getSdkMethodResponse( + context, + operation, + serviceOperation, + client, + useResponseOverride, + ); const name = getLibraryName(context, operation); return diagnostics.wrap({ __raw: operation, @@ -749,12 +767,13 @@ function getSdkServiceMethod( operation: Operation, client: SdkClientType, ): [SdkServiceMethod, readonly Diagnostic[]] { - const lro = getTcgcLroMetadata(context, operation, client); + const clientOperation = getOverriddenClientMethod(context, operation) ?? operation; + const lro = getTcgcLroMetadata(context, clientOperation, client); // `@disablePageable` disables paging even for operations with @list - const pagingDisabled = getDisablePageable(context, operation); + const pagingDisabled = getDisablePageable(context, clientOperation); const paging = !pagingDisabled && - (isList(context.program, operation) || getMarkAsPageable(context, operation)); + (isList(context.program, clientOperation) || getMarkAsPageable(context, clientOperation)); if (lro && paging) { return getSdkLroPagingServiceMethod(context, operation, client); } else if (paging) { diff --git a/packages/typespec-client-generator-core/src/tsp-index.ts b/packages/typespec-client-generator-core/src/tsp-index.ts index eb907ec382..d96923a57c 100644 --- a/packages/typespec-client-generator-core/src/tsp-index.ts +++ b/packages/typespec-client-generator-core/src/tsp-index.ts @@ -39,6 +39,7 @@ import { removeParameter, reorderParameters, replaceParameter, + replaceResponse, } from "./functions.js"; export { $lib } from "./lib.js"; @@ -91,6 +92,7 @@ export const $functions: Record = { removeParameter: removeParameter as AzureClientGeneratorCoreFunctions["removeParameter"], addParameter: addParameter as AzureClientGeneratorCoreFunctions["addParameter"], reorderParameters: reorderParameters as AzureClientGeneratorCoreFunctions["reorderParameters"], + replaceResponse: replaceResponse as AzureClientGeneratorCoreFunctions["replaceResponse"], exact: exact as AzureClientGeneratorCoreFunctions["exact"], }, }; diff --git a/packages/typespec-client-generator-core/test/functions/replace-response.test.ts b/packages/typespec-client-generator-core/test/functions/replace-response.test.ts new file mode 100644 index 0000000000..c28d232d59 --- /dev/null +++ b/packages/typespec-client-generator-core/test/functions/replace-response.test.ts @@ -0,0 +1,223 @@ +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { + createClientCustomizationInput, + createSdkContextForTester, + SimpleBaseTester, + SimpleTesterWithService, +} from "../tester.js"; +import { getServiceMethodOfClient } from "../utils.js"; + +it("replaces the generated method response without changing HTTP responses", async () => { + const { program } = await SimpleTesterWithService.compile(` + @error + model Error { + code: string; + } + + model Widget { + name: string; + } + + @post op create(): Widget | Error; + + alias CustomizedCreate = replaceResponse(TestService.create, void); + @@override(TestService.create, CustomizedCreate); + `); + + const context = await createSdkContextForTester(program); + const method = getServiceMethodOfClient(context.sdkPackage); + + strictEqual(method.response.type, undefined); + strictEqual(method.operation.responses.length, 1); + const response = method.operation.responses[0]; + ok(response.type); + strictEqual(response.type.kind, "model"); + strictEqual(response.type.name, "Widget"); + strictEqual(method.operation.exceptions.length, 1); +}); + +it("uses a different response type supplied by @override", async () => { + const { program } = await SimpleTesterWithService.compile(` + model Widget { + name: string; + } + + model DeleteResult { + deleted: boolean; + } + + @post op create(): Widget; + + op customizedCreate(): DeleteResult; + @@override(TestService.create, TestService.customizedCreate); + `); + + const context = await createSdkContextForTester(program); + const method = getServiceMethodOfClient(context.sdkPackage); + + ok(method.response.type); + strictEqual(method.response.type.kind, "model"); + strictEqual(method.response.type.name, "DeleteResult"); + ok(method.operation.responses[0].type); + strictEqual(method.operation.responses[0].type.name, "Widget"); +}); + +it("replaces a response with bytes", async () => { + const { program } = await SimpleTesterWithService.compile(` + model Metadata { + name: string; + } + + @get op download(): Metadata; + + #suppress "experimental-feature" "testing replaceResponse" + @@override(TestService.download, replaceResponse(TestService.download, bytes)); + `); + + const context = await createSdkContextForTester(program); + const method = getServiceMethodOfClient(context.sdkPackage); + + ok(method.response.type); + strictEqual(method.response.type.kind, "bytes"); + strictEqual(method.response.type.encode, "base64"); + strictEqual(method.operation.responses[0].type?.kind, "model"); +}); + +it("replaces a response with an anonymous bytes body", async () => { + const { program } = await SimpleTesterWithService.compile(` + model Metadata { + name: string; + } + + @get op download(): Metadata; + + alias BytesResponse = { + @body body: bytes; + }; + + #suppress "experimental-feature" "testing replaceResponse" + @@override(TestService.download, replaceResponse(TestService.download, BytesResponse)); + `); + + const context = await createSdkContextForTester(program); + const method = getServiceMethodOfClient(context.sdkPackage); + + ok(method.response.type); + strictEqual(method.response.type.kind, "model"); + strictEqual(method.response.type.properties[0].type.kind, "bytes"); + strictEqual(method.operation.responses[0].type?.kind, "model"); +}); + +it("removes pageable behavior when overriding a list operation with bytes", async () => { + const { program } = await SimpleTesterWithService.compile(` + model BlobPage { + @pageItems + items: string[]; + } + + @get + @list + op listBlobs(): BlobPage; + + @route("/bytes") + op listBlobsAsBytes(): bytes; + @@override(TestService.listBlobs, TestService.listBlobsAsBytes, "rust"); + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-rust", + }); + const method = getServiceMethodOfClient(context.sdkPackage); + + strictEqual(method.kind, "basic"); + ok(method.response.type); + strictEqual(method.response.type.kind, "bytes"); + strictEqual(method.operation.responses[0].type?.kind, "model"); +}); + +it("composes response replacement with other operation transformations", async () => { + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + namespace TestService; + + model Widget { + name: string; + } + + op create(@query name?: string): Widget; + `, + ` + model CreateResult { + id: string; + } + + #suppress "experimental-feature" "testing replaceParameter" + alias WithRequiredName = replaceParameter(TestService.create, "name", CreateResult.id); + #suppress "experimental-feature" "testing replaceResponse" + @@override(TestService.create, replaceResponse(WithRequiredName, CreateResult)); + `, + ), + ); + + const context = await createSdkContextForTester(program); + const method = getServiceMethodOfClient(context.sdkPackage); + + strictEqual(method.parameters[0].name, "id"); + strictEqual(method.parameters[0].optional, false); + ok(method.response.type); + strictEqual(method.response.type.name, "CreateResult"); + strictEqual(method.operation.responses[0].type?.name, "Widget"); +}); + +describe("scoped response replacement", () => { + const mainCode = ` + @service + namespace TestService; + + model Widget { + name: string; + } + + model CreateResult { + id: string; + } + + op create(): Widget; + `; + + const customizationCode = ` + model CreateResult { + id: string; + } + + #suppress "experimental-feature" "testing replaceResponse" + @@override(TestService.create, replaceResponse(TestService.create, CreateResult), "python"); + `; + + it("applies the response replacement in the selected scope", async () => { + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput(mainCode, customizationCode), + ); + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-python", + }); + const method = getServiceMethodOfClient(context.sdkPackage); + + strictEqual(method.response.type?.name, "CreateResult"); + }); + + it("does not apply the response replacement outside the selected scope", async () => { + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput(mainCode, customizationCode), + ); + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + const method = getServiceMethodOfClient(context.sdkPackage); + + strictEqual(method.response.type?.name, "Widget"); + }); +});