From 46b8280d4fe8d6c57b381b65857595bfe028b5fe Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 16:32:19 +0800 Subject: [PATCH 01/10] fix(http-client-java): honor response replacements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../http-client-java-response-replacement.md | 7 ++ packages/http-client-java/README.md | 7 ++ .../emitter/src/code-model-builder.ts | 27 ++++++- .../emitter/src/common/operation.ts | 3 + .../emitter/test/code-model.test.ts | 81 +++++++++++++++++++ .../test/fixtures/response-replacement.tsp | 66 +++++++++++++++ .../model/codemodel/ConvenienceApi.java | 19 +++++ .../ClientMethodsReturnDescription.java | 12 +++ .../ConvenienceAsyncMethodTemplate.java | 12 ++- .../ConvenienceSyncMethodTemplate.java | 16 +++- .../core/mapper/ClientMethodMapperTests.java | 35 ++++++++ 11 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 .chronus/changes/http-client-java-response-replacement.md create mode 100644 packages/http-client-java/emitter/test/fixtures/response-replacement.tsp diff --git a/.chronus/changes/http-client-java-response-replacement.md b/.chronus/changes/http-client-java-response-replacement.md new file mode 100644 index 00000000000..e3f8366f7df --- /dev/null +++ b/.chronus/changes/http-client-java-response-replacement.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-java" +--- + +Honor void and raw-byte response replacements in generated Java clients. diff --git a/packages/http-client-java/README.md b/packages/http-client-java/README.md index bbac395bced..77eb97475b4 100644 --- a/packages/http-client-java/README.md +++ b/packages/http-client-java/README.md @@ -43,6 +43,13 @@ options: option: value ``` +### Response replacement + +Use TCGC's `@override` with `replaceResponseWithVoid` or `replaceResponseWithBytes` to +ignore a basic operation's response body or return its raw `BinaryData`, without JSON decoding. +The HTTP status codes, content types, headers, and error responses are preserved. +For Azure clients, protocol methods still expose the original response body. + ## Emitter options ### `emitter-output-dir` diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index fa98e68fff6..65abf4e9942 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -1191,6 +1191,20 @@ export class CodeModelBuilder { lroMetadata = this.processLroMetadata(codeModelOperation, sdkMethod); } + const responseReplacement = + sdkMethod.isOverride && + sdkMethod.kind === "basic" && + (sdkMethod.response.type === undefined || sdkMethod.response.type.kind === "bytes") && + httpOperation.responses.some((response) => response.type !== sdkMethod.response.type) + ? sdkMethod.response.type === undefined + ? "void" + : "bytes" + : undefined; + if (responseReplacement && this.isAzureV1() && codeModelOperation.convenienceApi) { + codeModelOperation.convenienceApi.response = + responseReplacement === "void" ? createResponse() : createBinaryResponse(); + } + // responses for (const response of sdkMethod.operation.responses) { this.processResponse( @@ -1199,6 +1213,7 @@ export class CodeModelBuilder { response, lroMetadata.longRunning, false, + this.isAzureV1() ? undefined : responseReplacement, ); } @@ -2377,6 +2392,7 @@ export class CodeModelBuilder { sdkResponse: SdkHttpResponse | SdkHttpErrorResponse, longRunning: boolean, isErrorResponse: boolean, + responseReplacement?: "void" | "bytes", ) { // TODO: what to do if more than 1 response? // It happens when the response type is Union, on one status code. @@ -2419,8 +2435,10 @@ export class CodeModelBuilder { } } - const bodyType: SdkType | undefined = sdkResponse.type; - let trackConvenienceApi: boolean = Boolean(op.convenienceApi); + const bodyType: SdkType | undefined = + responseReplacement === "void" ? undefined : sdkResponse.type; + let trackConvenienceApi: boolean = + Boolean(op.convenienceApi) && (isErrorResponse || !op.convenienceApi?.response); const trackResponseHeadersAsModel = trackConvenienceApi && (op.convenienceApi?.responseHeadersAsModel === true || @@ -2436,7 +2454,7 @@ export class CodeModelBuilder { } } - let responseBodyIsFile: boolean = false; + let responseBodyIsFile: boolean = responseReplacement === "bytes" && bodyType !== undefined; if ( bodyType && bodyType.kind === "model" && @@ -2451,7 +2469,7 @@ export class CodeModelBuilder { sdkResponse.contentTypes && sdkResponse.contentTypes.length > 0 && !isKnownContentType(sdkResponse.contentTypes); - responseBodyIsFile = Boolean(unknownResponseBody); + responseBodyIsFile ||= Boolean(unknownResponseBody); } let response: Response; @@ -2524,6 +2542,7 @@ export class CodeModelBuilder { http: { statusCodes: this.getStatusCodes(statusCode), headers: headers, + ...(responseReplacement ? { mediaTypes: sdkResponse.contentTypes } : {}), }, }, language: { diff --git a/packages/http-client-java/emitter/src/common/operation.ts b/packages/http-client-java/emitter/src/common/operation.ts index e04a4208c4c..ec6d1d7277d 100644 --- a/packages/http-client-java/emitter/src/common/operation.ts +++ b/packages/http-client-java/emitter/src/common/operation.ts @@ -69,6 +69,9 @@ export interface Operation extends Aspect { export interface ConvenienceApi extends Metadata { requests?: Array; + /** Overrides the convenience return type without changing the protocol response. */ + response?: Response; + /** * Whether the convenience method returns the significant response headers as a strongly-typed model * (opt-in via the "responseHeadersAsModel" client option). Only applicable to data-plane operations diff --git a/packages/http-client-java/emitter/test/code-model.test.ts b/packages/http-client-java/emitter/test/code-model.test.ts index dc8483c8950..b01f1d5c0c7 100644 --- a/packages/http-client-java/emitter/test/code-model.test.ts +++ b/packages/http-client-java/emitter/test/code-model.test.ts @@ -32,6 +32,7 @@ import { createSecurity, createStringSchema, createVirtualParameter, + isSchemaResponse, } from "../src/common/schemas/model.js"; import { createResponseErrorSchema } from "../src/external-schemas.js"; import { cloneOperationParameter } from "../src/operation-utils.js"; @@ -283,6 +284,86 @@ describe("code model characterization", () => { expect(stringify(sortGraph(model), { version: "1.1" })).toMatchSnapshot(); }, ); + + it.each(["standard", "azure", "azurev2"])( + "honors response replacements in %s clients", + async (flavor) => { + const program = await compile( + NodeHost, + fileURLToPath(new URL("./fixtures/response-replacement.tsp", import.meta.url)), + { noEmit: true }, + ); + expect(program.diagnostics).toEqual([]); + const model = await new CodeModelBuilder(program, { + program, + emitterOutputDir: "/code-model-test-output", + options: { "dev-options": {}, flavor }, + perf: { + startTimer: () => ({ end: () => 0 }), + time: (_label, callback) => callback(), + timeAsync: (_label, callback) => callback(), + report: () => {}, + }, + }).build(); + expect(program.diagnostics.filter((d) => d.severity === "error")).toEqual([]); + const operations = model.clients.flatMap((client) => + client.operationGroups.flatMap((group) => group.operations ?? []), + ); + const voidOperation = operations.find((op) => op.language.default.name === "voidResponse")!; + const bytesOperation = operations.find((op) => op.language.default.name === "bytesResponse")!; + const widgetOperation = operations.find( + (op) => op.language.default.name === "widgetResponse", + )!; + for (const operation of [voidOperation, bytesOperation]) { + expect(operation.responses).toHaveLength(1); + expect(operation.responses![0].protocol.http).toMatchObject({ + statusCodes: ["200"], + mediaTypes: ["application/json"], + headers: [expect.objectContaining({ header: "x-response-id" })], + }); + expect(operation.exceptions).toHaveLength(1); + expect(operation.exceptions![0].protocol.http.statusCodes).toEqual(["400"]); + expect(isSchemaResponse(operation.exceptions![0])).toBe(true); + expect(operation.exceptions![0]).toMatchObject({ + schema: { + usage: expect.arrayContaining( + flavor === "standard" ? ["exception", "public"] : ["exception"], + ), + }, + }); + } + if (flavor === "azure") { + expect(voidOperation.convenienceApi).toHaveProperty("response"); + expect(voidOperation.convenienceApi).toMatchObject({ response: {} }); + expect(bytesOperation.convenienceApi).toMatchObject({ response: { binary: true } }); + expect(isSchemaResponse(voidOperation.responses![0])).toBe(true); + expect(isSchemaResponse(bytesOperation.responses![0])).toBe(true); + } else { + expect(isSchemaResponse(voidOperation.responses![0])).toBe(false); + expect(voidOperation.responses![0]).not.toHaveProperty("binary"); + expect(bytesOperation.responses![0]).toMatchObject({ binary: true }); + } + expect(widgetOperation.convenienceApi).not.toHaveProperty("response"); + expect(widgetOperation.responses![0]).toMatchObject({ + schema: { type: SchemaType.Object, language: { default: { name: "Widget" } } }, + }); + const encodedBytesOperation = operations.find( + (op) => op.language.default.name === "encodedBytesResponse", + )!; + expect( + flavor === "azure" + ? encodedBytesOperation.convenienceApi + : { response: encodedBytesOperation.responses![0] }, + ).toMatchObject({ response: { binary: true } }); + const parameterOverrideOperation = operations.find( + (op) => op.language.default.name === "parameterOverrideBytesResponse", + )!; + expect(parameterOverrideOperation.convenienceApi).not.toHaveProperty("response"); + expect(parameterOverrideOperation.responses![0]).toMatchObject({ + schema: { type: SchemaType.ByteArray }, + }); + }, + ); }); function assertPlainGraph(value: unknown, seen = new Set()) { diff --git a/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp b/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp new file mode 100644 index 00000000000..872507c7ae6 --- /dev/null +++ b/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp @@ -0,0 +1,66 @@ +import "@typespec/http"; +import "@azure-tools/typespec-client-generator-core"; + +using TypeSpec.Http; +using Azure.ClientGenerator.Core; + +@service +namespace ResponseReplacement; + +model Widget { + name: string; +} + +model WidgetResponse { + @statusCode statusCode: 200; + @header("x-response-id") responseId: string; + @body body: Widget; +} + +model EncodedBytesResponse { + @header contentType: "application/json"; + @body body: bytes; +} + +@error +model ErrorResponse { + @statusCode statusCode: 400; + message: string; +} + +@post +@route("/void") +op voidResponse(): WidgetResponse | ErrorResponse; + +@get +@route("/bytes") +op bytesResponse(): WidgetResponse | ErrorResponse; + +@get +@route("/widget") +op widgetResponse(): Widget; + +@get +@route("/encoded-bytes") +op encodedBytesResponse(): EncodedBytesResponse; + +@get +@route("/parameter-override") +op parameterOverrideBytesResponse( + @query first: string, + @query second: string, +): EncodedBytesResponse; + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing a void response replacement." +@@override(voidResponse, replaceResponseWithVoid(voidResponse)); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing a raw bytes response replacement." +@@override(bytesResponse, replaceResponseWithBytes(bytesResponse)); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing replacement of encoded bytes with raw bytes." +@@override(encodedBytesResponse, replaceResponseWithBytes(encodedBytesResponse)); + +@@override( + parameterOverrideBytesResponse, + reorderParameters(parameterOverrideBytesResponse, #["second", "first"]) +); diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/extension/model/codemodel/ConvenienceApi.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/extension/model/codemodel/ConvenienceApi.java index 70885056589..59b8d1bb80d 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/extension/model/codemodel/ConvenienceApi.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/extension/model/codemodel/ConvenienceApi.java @@ -10,6 +10,7 @@ */ public class ConvenienceApi extends Metadata { private List requests; + private Response response; private boolean responseHeadersAsModel; /** @@ -37,6 +38,24 @@ public void setRequests(List requests) { this.requests = requests; } + /** + * Gets the convenience response override, leaving the protocol response unchanged. + * + * @return the convenience response override. + */ + public Response getResponse() { + return response; + } + + /** + * Sets the convenience response override. + * + * @param response the convenience response override. + */ + public void setResponse(Response response) { + this.response = response; + } + /** * Gets whether the convenience method returns the significant response headers as a strongly-typed model. * diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodsReturnDescription.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodsReturnDescription.java index 04531f7745b..da297017e99 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodsReturnDescription.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodsReturnDescription.java @@ -6,6 +6,7 @@ import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.ObjectSchema; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Operation; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Property; +import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Response; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Schema; import com.microsoft.typespec.http.client.generator.core.extension.plugin.JavaSettings; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ClassType; @@ -389,6 +390,17 @@ private static ClientMethodsReturnDescription createForInputStream(Operation ope * @return the response body type. */ private static IType getResponseBodyType(Operation operation, boolean isProtocolMethod, JavaSettings settings) { + if (!isProtocolMethod + && operation.getConvenienceApi() != null + && operation.getConvenienceApi().getResponse() != null) { + Response response = operation.getConvenienceApi().getResponse(); + if (Boolean.TRUE.equals(response.getBinary())) { + return ClassType.BINARY_DATA; + } + return response.getSchema() == null + ? PrimitiveType.VOID + : Mappers.getSchemaMapper().map(response.getSchema()); + } if (!isProtocolMethod && operation.getConvenienceApi() != null && operation.getConvenienceApi().isResponseHeadersAsModel()) { diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java index 09ab038422f..49f141bc586 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java @@ -70,6 +70,14 @@ protected void writeInvocationAndConversion(ClientMethod convenienceMethod, Clie IType protocolResponseBodyType = getConvenienceResponseBodyType(protocolMethod); IType rawResponseBodyType = convenienceMethod.getProxyMethod().getRawResponseBodyType(); + if (convenienceMethod.getType() == ClientMethodType.SimpleAsync + && responseBodyType.asNullable() == ClassType.VOID + && protocolResponseBodyType == ClassType.BINARY_DATA) { + methodBlock + .methodReturn(String.format("%1$s(%2$s).then()", getMethodName(protocolMethod), invocationExpression)); + return; + } + if (convenienceMethod.getType() == ClientMethodType.SimpleAsync && isResponseHeadersAsModel(convenienceMethod)) { // The convenience method returns the strongly-typed response-headers model, built from the response @@ -116,7 +124,9 @@ && isResponseHeadersAsModel(convenienceMethod)) { GenericType responseBaseType = getResponseBaseType(convenienceMethod.getReturnValue().getType()); boolean responseBodyIsVoid = responseBodyType.asNullable() == ClassType.VOID; - if (responseBaseType == null && (responseBodyIsVoid || responseBodyType == ClassType.BINARY_DATA)) { + if (responseBaseType == null + && ((responseBodyIsVoid && protocolResponseBodyType.asNullable() == ClassType.VOID) + || responseBodyType == ClassType.BINARY_DATA)) { methodBlock.methodReturn(methodInvocation); return; } diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceSyncMethodTemplate.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceSyncMethodTemplate.java index 447ff2196ee..d06633dbdbf 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceSyncMethodTemplate.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceSyncMethodTemplate.java @@ -86,6 +86,13 @@ protected void writeInvocationAndConversion(ClientMethod convenienceMethod, Clie IType protocolResponseBodyType = getConvenienceResponseBodyType(protocolMethod); IType rawResponseBodyType = convenienceMethod.getProxyMethod().getRawResponseBodyType(); + if (convenienceMethod.getType() == ClientMethodType.SimpleSync + && responseBodyType.asNullable() == ClassType.VOID + && protocolResponseBodyType == ClassType.BINARY_DATA) { + methodBlock.line("%1$s(%2$s);", getMethodName(protocolMethod), invocationExpression); + return; + } + if (convenienceMethod.getType() == ClientMethodType.SimpleSync && isResponseHeadersAsModel(convenienceMethod)) { // The convenience method returns the strongly-typed response-headers model, built from the response // headers of the protocol method (which returns Response). @@ -118,14 +125,17 @@ && isResponseHeadersAsModel(convenienceMethod)) { "new SimpleResponse<>(protocolMethodResponse, new %1$s(" + "protocolMethodResponse.getHeaders()))", responseBodyType)); } else if (convenienceMethod.getType() == ClientMethodType.SimpleSyncRestResponse - && !(responseBodyType.asNullable() == ClassType.VOID || responseBodyType == ClassType.BINARY_DATA)) { + && responseBodyType != ClassType.BINARY_DATA + && !(responseBodyType.asNullable() == ClassType.VOID + && protocolResponseBodyType.asNullable() == ClassType.VOID)) { // protocolMethodResponse = ... methodBlock.line(getProtocolMethodResponseStatement(protocolMethod, invocationExpression)); // e.g. protocolMethodResponse.getValue().toObject(...) - String expressConversion = "protocolMethodResponse.getValue()"; - if (protocolResponseBodyType == ClassType.BINARY_DATA) { + String expressConversion + = responseBodyType.asNullable() == ClassType.VOID ? "null" : "protocolMethodResponse.getValue()"; + if (protocolResponseBodyType == ClassType.BINARY_DATA && responseBodyType.asNullable() != ClassType.VOID) { expressConversion = expressionConvertFromBinaryData(responseBodyType, rawResponseBodyType, expressConversion, protocolMethod.getProxyMethod().getResponseContentTypes(), typeReferenceStaticClasses); diff --git a/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodMapperTests.java b/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodMapperTests.java index 6f9e05fe38d..9f76c9814a6 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodMapperTests.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/mapper/ClientMethodMapperTests.java @@ -3,6 +3,7 @@ package com.microsoft.typespec.http.client.generator.core.mapper; +import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.ConvenienceApi; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Language; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Languages; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Operation; @@ -11,6 +12,7 @@ import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Request; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Response; import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.Schema; +import com.microsoft.typespec.http.client.generator.core.extension.model.codemodel.StringSchema; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ClassType; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ClientMethodParameter; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.GenericType; @@ -29,6 +31,39 @@ public class ClientMethodMapperTests { + @Test + public void testConvenienceResponseReplacement() { + Operation operation = operationWithNoDesc(); + operation.getRequests().get(0).getProtocol().getHttp().setMethod(HttpMethod.GET.name()); + StringSchema schema = new StringSchema(); + schema.setType(Schema.AllSchemaTypes.STRING); + operation.getResponses().get(0).setSchema(schema); + ConvenienceApi convenienceApi = new ConvenienceApi(); + operation.setConvenienceApi(convenienceApi); + Response response = new Response(); + convenienceApi.setResponse(response); + + Assertions.assertEquals(PrimitiveType.VOID, + ClientMethodsReturnDescription.create(operation, false, true).getSyncReturnType()); + Assertions.assertEquals(ClassType.BINARY_DATA, + ClientMethodsReturnDescription.create(operation, true, true).getSyncReturnType()); + + response.setBinary(true); + Assertions.assertEquals(ClassType.BINARY_DATA, + ClientMethodsReturnDescription.create(operation, false, true).getSyncReturnType()); + Assertions.assertEquals(ClassType.BINARY_DATA, + ClientMethodsReturnDescription.create(operation, true, true).getSyncReturnType()); + + response.setBinary(false); + response.setSchema(schema); + Assertions.assertEquals(ClassType.STRING, + ClientMethodsReturnDescription.create(operation, false, true).getSyncReturnType()); + + convenienceApi.setResponse(null); + Assertions.assertEquals(ClassType.STRING, + ClientMethodsReturnDescription.create(operation, false, true).getSyncReturnType()); + } + @Test public void testOverloadedSignatures() { List parameters = new ArrayList<>(); From caf0d3db0f922623399816d290a2af0097086365 Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 16:32:41 +0800 Subject: [PATCH 02/10] test(http-client-java): cover response replacements in _specs_ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../ResponseReplacementAsyncClient.java | 13 ++- .../ResponseReplacementClient.java | 10 +-- .../responsereplacement/models/Widget.java | 83 ------------------- .../models/package-info.java | 11 --- ...tor-core-responsereplacement_metadata.json | 2 +- .../ResponseReplacementTests.java | 51 ++++++++++++ 6 files changed, 61 insertions(+), 109 deletions(-) delete mode 100644 packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/Widget.java delete mode 100644 packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/package-info.java create mode 100644 packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java diff --git a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementAsyncClient.java b/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementAsyncClient.java index ec14fd2065a..01fc63ddd74 100644 --- a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementAsyncClient.java +++ b/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementAsyncClient.java @@ -5,7 +5,6 @@ package _specs_.azure.clientgenerator.core.responsereplacement; import _specs_.azure.clientgenerator.core.responsereplacement.implementation.ResponseReplacementClientImpl; -import _specs_.azure.clientgenerator.core.responsereplacement.models.Widget; import com.azure.core.annotation.Generated; import com.azure.core.annotation.ReturnType; import com.azure.core.annotation.ServiceClient; @@ -96,15 +95,14 @@ public Mono> bytesResponseWithResponse(RequestOptions reque * @throws ResourceNotFoundException thrown if the request is rejected by server on status code 404. * @throws ResourceModifiedException thrown if the request is rejected by server on status code 409. * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. - * @return the response body on successful completion of {@link Mono}. + * @return A {@link Mono} that completes when a successful response is received. */ @Generated @ServiceMethod(returns = ReturnType.SINGLE) - public Mono voidResponse() { + public Mono voidResponse() { // Generated convenience method for voidResponseWithResponse RequestOptions requestOptions = new RequestOptions(); - return voidResponseWithResponse(requestOptions).flatMap(FluxUtil::toMono) - .map(protocolMethodData -> protocolMethodData.toObject(Widget.class)); + return voidResponseWithResponse(requestOptions).then(); } /** @@ -119,10 +117,9 @@ public Mono voidResponse() { */ @Generated @ServiceMethod(returns = ReturnType.SINGLE) - public Mono bytesResponse() { + public Mono bytesResponse() { // Generated convenience method for bytesResponseWithResponse RequestOptions requestOptions = new RequestOptions(); - return bytesResponseWithResponse(requestOptions).flatMap(FluxUtil::toMono) - .map(protocolMethodData -> protocolMethodData.toObject(Widget.class)); + return bytesResponseWithResponse(requestOptions).flatMap(FluxUtil::toMono); } } diff --git a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClient.java b/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClient.java index 695a85829fa..87004517b3f 100644 --- a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClient.java +++ b/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClient.java @@ -5,7 +5,6 @@ package _specs_.azure.clientgenerator.core.responsereplacement; import _specs_.azure.clientgenerator.core.responsereplacement.implementation.ResponseReplacementClientImpl; -import _specs_.azure.clientgenerator.core.responsereplacement.models.Widget; import com.azure.core.annotation.Generated; import com.azure.core.annotation.ReturnType; import com.azure.core.annotation.ServiceClient; @@ -94,14 +93,13 @@ public Response bytesResponseWithResponse(RequestOptions requestOpti * @throws ResourceNotFoundException thrown if the request is rejected by server on status code 404. * @throws ResourceModifiedException thrown if the request is rejected by server on status code 409. * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. - * @return the response. */ @Generated @ServiceMethod(returns = ReturnType.SINGLE) - public Widget voidResponse() { + public void voidResponse() { // Generated convenience method for voidResponseWithResponse RequestOptions requestOptions = new RequestOptions(); - return voidResponseWithResponse(requestOptions).getValue().toObject(Widget.class); + voidResponseWithResponse(requestOptions); } /** @@ -116,9 +114,9 @@ public Widget voidResponse() { */ @Generated @ServiceMethod(returns = ReturnType.SINGLE) - public Widget bytesResponse() { + public BinaryData bytesResponse() { // Generated convenience method for bytesResponseWithResponse RequestOptions requestOptions = new RequestOptions(); - return bytesResponseWithResponse(requestOptions).getValue().toObject(Widget.class); + return bytesResponseWithResponse(requestOptions).getValue(); } } diff --git a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/Widget.java b/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/Widget.java deleted file mode 100644 index ed5c8de0443..00000000000 --- a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/Widget.java +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// Code generated by Microsoft (R) TypeSpec Code Generator. - -package _specs_.azure.clientgenerator.core.responsereplacement.models; - -import com.azure.core.annotation.Generated; -import com.azure.core.annotation.Immutable; -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; -import java.io.IOException; - -/** - * The Widget model. - */ -@Immutable -public final class Widget implements JsonSerializable { - /* - * The name property. - */ - @Generated - private final String name; - - /** - * Creates an instance of Widget class. - * - * @param name the name value to set. - */ - @Generated - private Widget(String name) { - this.name = name; - } - - /** - * Get the name property: The name property. - * - * @return the name value. - */ - @Generated - public String getName() { - return this.name; - } - - /** - * {@inheritDoc} - */ - @Generated - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - jsonWriter.writeStringField("name", this.name); - return jsonWriter.writeEndObject(); - } - - /** - * Reads an instance of Widget from the JsonReader. - * - * @param jsonReader The JsonReader being read. - * @return An instance of Widget if the JsonReader was pointing to an instance of it, or null if it was pointing to - * JSON null. - * @throws IllegalStateException If the deserialized JSON object was missing any required properties. - * @throws IOException If an error occurs while reading the Widget. - */ - @Generated - public static Widget fromJson(JsonReader jsonReader) throws IOException { - return jsonReader.readObject(reader -> { - String name = null; - while (reader.nextToken() != JsonToken.END_OBJECT) { - String fieldName = reader.getFieldName(); - reader.nextToken(); - - if ("name".equals(fieldName)) { - name = reader.getString(); - } else { - reader.skipChildren(); - } - } - return new Widget(name); - }); - } -} diff --git a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/package-info.java b/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/package-info.java deleted file mode 100644 index 0ed2436b353..00000000000 --- a/packages/http-client-java/generator/http-client-generator-test/src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/package-info.java +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// Code generated by Microsoft (R) TypeSpec Code Generator. - -/** - * - * Package containing the data models for ResponseReplacement. - * Test scenarios for client response replacement. - * - */ -package _specs_.azure.clientgenerator.core.responsereplacement.models; diff --git a/packages/http-client-java/generator/http-client-generator-test/src/main/resources/META-INF/_specs_-azure-clientgenerator-core-responsereplacement_metadata.json b/packages/http-client-java/generator/http-client-generator-test/src/main/resources/META-INF/_specs_-azure-clientgenerator-core-responsereplacement_metadata.json index eb86813e040..7c406fd933e 100644 --- a/packages/http-client-java/generator/http-client-generator-test/src/main/resources/META-INF/_specs_-azure-clientgenerator-core-responsereplacement_metadata.json +++ b/packages/http-client-java/generator/http-client-generator-test/src/main/resources/META-INF/_specs_-azure-clientgenerator-core-responsereplacement_metadata.json @@ -1 +1 @@ -{"flavor":"Azure","apiVersions":{},"crossLanguagePackageId":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","crossLanguageVersion":"8ede01c1a1b9","crossLanguageDefinitions":{"_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.bytesResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.bytesResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.voidResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.voidResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.bytesResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.bytesResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.voidResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.voidResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClientBuilder":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","_specs_.azure.clientgenerator.core.responsereplacement.models.Widget":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.Widget"},"generatedFiles":["src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementAsyncClient.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClient.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClientBuilder.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/implementation/ResponseReplacementClientImpl.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/implementation/package-info.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/Widget.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/models/package-info.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/package-info.java","src/main/java/module-info.java"]} \ No newline at end of file +{"flavor":"Azure","apiVersions":{},"crossLanguagePackageId":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","crossLanguageVersion":"8ede01c1a1b9","crossLanguageDefinitions":{"_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.bytesResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.bytesResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.voidResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementAsyncClient.voidResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.bytesResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.bytesResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.bytesResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.voidResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClient.voidResponseWithResponse":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement.voidResponse","_specs_.azure.clientgenerator.core.responsereplacement.ResponseReplacementClientBuilder":"_Specs_.Azure.ClientGenerator.Core.ResponseReplacement"},"generatedFiles":["src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementAsyncClient.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClient.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementClientBuilder.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/implementation/ResponseReplacementClientImpl.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/implementation/package-info.java","src/main/java/_specs_/azure/clientgenerator/core/responsereplacement/package-info.java","src/main/java/module-info.java"]} \ No newline at end of file diff --git a/packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java b/packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java new file mode 100644 index 00000000000..a91ccd08922 --- /dev/null +++ b/packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package _specs_.azure.clientgenerator.core.responsereplacement; + +import com.azure.core.util.BinaryData; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +public class ResponseReplacementTests { + private static final byte[] EXPECTED_BODY = "{\"name\":\"widget\"}".getBytes(StandardCharsets.UTF_8); + private final ResponseReplacementClient client = new ResponseReplacementClientBuilder().buildClient(); + private final ResponseReplacementAsyncClient asyncClient + = new ResponseReplacementClientBuilder().buildAsyncClient(); + + @Test + public void testVoidResponse() throws NoSuchMethodException { + Assertions.assertEquals(void.class, ResponseReplacementClient.class.getMethod("voidResponse").getReturnType()); + client.voidResponse(); + } + + @Test + public void testVoidResponseAsync() { + StepVerifier.create(asyncClient.voidResponse()).verifyComplete(); + } + + @Test + public void testBytesResponse() { + BinaryData response = client.bytesResponse(); + Assertions.assertArrayEquals(EXPECTED_BODY, response.toBytes()); + } + + @Test + public void testBytesResponseAsync() { + StepVerifier.create(asyncClient.bytesResponse()) + .assertNext(response -> Assertions.assertArrayEquals(EXPECTED_BODY, response.toBytes())) + .verifyComplete(); + } + + @Test + public void testProtocolResponsesPreserveBody() { + var voidResponse = client.voidResponseWithResponse(null); + Assertions.assertEquals(200, voidResponse.getStatusCode()); + Assertions.assertArrayEquals(EXPECTED_BODY, voidResponse.getValue().toBytes()); + var bytesResponse = client.bytesResponseWithResponse(null); + Assertions.assertEquals(200, bytesResponse.getStatusCode()); + Assertions.assertArrayEquals(EXPECTED_BODY, bytesResponse.getValue().toBytes()); + } +} From 1385d76a8ab041b05760596dd626598d1eb702d4 Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 17:32:35 +0800 Subject: [PATCH 03/10] refactor(http-client-java): clarify response replacement by flavor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../emitter/src/code-model-builder.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index 65abf4e9942..9e999a6c6ce 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -1191,6 +1191,7 @@ export class CodeModelBuilder { lroMetadata = this.processLroMetadata(codeModelOperation, sdkMethod); } + // TCGC only supports response replacement with void or bytes. const responseReplacement = sdkMethod.isOverride && sdkMethod.kind === "basic" && @@ -1200,11 +1201,6 @@ export class CodeModelBuilder { ? "void" : "bytes" : undefined; - if (responseReplacement && this.isAzureV1() && codeModelOperation.convenienceApi) { - codeModelOperation.convenienceApi.response = - responseReplacement === "void" ? createResponse() : createBinaryResponse(); - } - // responses for (const response of sdkMethod.operation.responses) { this.processResponse( @@ -1213,7 +1209,7 @@ export class CodeModelBuilder { response, lroMetadata.longRunning, false, - this.isAzureV1() ? undefined : responseReplacement, + responseReplacement, ); } @@ -2399,6 +2395,17 @@ export class CodeModelBuilder { // let response: Response; const headers: Array = []; + // Azure V1 replaces only the convenience return type, preserving the protocol response. + // Azure V2/ClientCore replace the operation response so the proxy handles void/raw bytes. + const replaceConvenienceResponse = + !isErrorResponse && responseReplacement !== undefined && this.isAzureV1(); + const replaceResponseBodyType = + !isErrorResponse && responseReplacement !== undefined && !this.isAzureV1(); + if (replaceConvenienceResponse && op.convenienceApi) { + op.convenienceApi.response = + responseReplacement === "void" ? createResponse() : createBinaryResponse(); + } + if (sdkResponse.headers) { for (const header of sdkResponse.headers) { const schema = this.processSchema(header.type, header.name); @@ -2436,9 +2443,8 @@ export class CodeModelBuilder { } const bodyType: SdkType | undefined = - responseReplacement === "void" ? undefined : sdkResponse.type; - let trackConvenienceApi: boolean = - Boolean(op.convenienceApi) && (isErrorResponse || !op.convenienceApi?.response); + replaceResponseBodyType && responseReplacement === "void" ? undefined : sdkResponse.type; + let trackConvenienceApi: boolean = Boolean(op.convenienceApi) && !replaceConvenienceResponse; const trackResponseHeadersAsModel = trackConvenienceApi && (op.convenienceApi?.responseHeadersAsModel === true || @@ -2454,7 +2460,8 @@ export class CodeModelBuilder { } } - let responseBodyIsFile: boolean = responseReplacement === "bytes" && bodyType !== undefined; + let responseBodyIsFile: boolean = + replaceResponseBodyType && responseReplacement === "bytes" && bodyType !== undefined; if ( bodyType && bodyType.kind === "model" && @@ -2542,7 +2549,7 @@ export class CodeModelBuilder { http: { statusCodes: this.getStatusCodes(statusCode), headers: headers, - ...(responseReplacement ? { mediaTypes: sdkResponse.contentTypes } : {}), + ...(replaceResponseBodyType ? { mediaTypes: sdkResponse.contentTypes } : {}), }, }, language: { From 964a2716dcb997f3f26b90bde73eb29d4f68b4a5 Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 17:50:28 +0800 Subject: [PATCH 04/10] refactor(http-client-java): centralize response replacement handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- packages/http-client-java/README.md | 2 +- .../emitter/src/code-model-builder.ts | 48 ++++++++++++------- .../emitter/test/code-model.test.ts | 6 ++- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/http-client-java/README.md b/packages/http-client-java/README.md index 77eb97475b4..149f1b6232a 100644 --- a/packages/http-client-java/README.md +++ b/packages/http-client-java/README.md @@ -47,7 +47,7 @@ options: Use TCGC's `@override` with `replaceResponseWithVoid` or `replaceResponseWithBytes` to ignore a basic operation's response body or return its raw `BinaryData`, without JSON decoding. -The HTTP status codes, content types, headers, and error responses are preserved. +The HTTP status codes, headers, and error responses are preserved. For Azure clients, protocol methods still expose the original response body. ## Emitter options diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index 9e999a6c6ce..e2715b7c183 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -2401,9 +2401,28 @@ export class CodeModelBuilder { !isErrorResponse && responseReplacement !== undefined && this.isAzureV1(); const replaceResponseBodyType = !isErrorResponse && responseReplacement !== undefined && !this.isAzureV1(); - if (replaceConvenienceResponse && op.convenienceApi) { - op.convenienceApi.response = - responseReplacement === "void" ? createResponse() : createBinaryResponse(); + let bodyType: SdkType | undefined = sdkResponse.type; + let replaceResponseWithBytes = false; + if (!isErrorResponse && responseReplacement !== undefined) { + switch (responseReplacement) { + case "void": + if (replaceConvenienceResponse && op.convenienceApi) { + op.convenienceApi.response = createResponse(); + } else if (replaceResponseBodyType) { + bodyType = undefined; + } + break; + case "bytes": + if (replaceConvenienceResponse && op.convenienceApi) { + op.convenienceApi.response = createBinaryResponse(); + } else if (replaceResponseBodyType) { + // Raw bytes bypass JSON/base64 decoding even for application/json responses. + replaceResponseWithBytes = true; + } + break; + default: + fail(`Unsupported response replacement: ${responseReplacement satisfies never}`); + } } if (sdkResponse.headers) { @@ -2442,8 +2461,6 @@ export class CodeModelBuilder { } } - const bodyType: SdkType | undefined = - replaceResponseBodyType && responseReplacement === "void" ? undefined : sdkResponse.type; let trackConvenienceApi: boolean = Boolean(op.convenienceApi) && !replaceConvenienceResponse; const trackResponseHeadersAsModel = trackConvenienceApi && @@ -2460,27 +2477,27 @@ export class CodeModelBuilder { } } - let responseBodyIsFile: boolean = - replaceResponseBodyType && responseReplacement === "bytes" && bodyType !== undefined; + let responseBodyIsBinary = false; if ( bodyType && - bodyType.kind === "model" && - bodyType.serializationOptions.binary && - bodyType.serializationOptions.binary.isFile + (replaceResponseWithBytes || + (bodyType.kind === "model" && + bodyType.serializationOptions.binary && + bodyType.serializationOptions.binary.isFile)) ) { - // check for File - responseBodyIsFile = true; + // raw response replacement or File + responseBodyIsBinary = true; } else if (bodyType && bodyType.kind === "bytes") { // check for bytes + unknown content-type const unknownResponseBody = sdkResponse.contentTypes && sdkResponse.contentTypes.length > 0 && !isKnownContentType(sdkResponse.contentTypes); - responseBodyIsFile ||= Boolean(unknownResponseBody); + responseBodyIsBinary = Boolean(unknownResponseBody); } let response: Response; - if (responseBodyIsFile) { + if (responseBodyIsBinary) { // binary/file response = createBinaryResponse({ protocol: { @@ -2508,7 +2525,7 @@ export class CodeModelBuilder { // schema (usually JSON) let schema: Schema | undefined; if ( - !responseBodyIsFile && + !responseBodyIsBinary && sdkResponse.contentTypes && sdkResponse.contentTypes.length === 1 && sdkResponse.contentTypes[0] === "text/plain" && @@ -2549,7 +2566,6 @@ export class CodeModelBuilder { http: { statusCodes: this.getStatusCodes(statusCode), headers: headers, - ...(replaceResponseBodyType ? { mediaTypes: sdkResponse.contentTypes } : {}), }, }, language: { diff --git a/packages/http-client-java/emitter/test/code-model.test.ts b/packages/http-client-java/emitter/test/code-model.test.ts index b01f1d5c0c7..4d7e7821b70 100644 --- a/packages/http-client-java/emitter/test/code-model.test.ts +++ b/packages/http-client-java/emitter/test/code-model.test.ts @@ -318,9 +318,13 @@ describe("code model characterization", () => { expect(operation.responses).toHaveLength(1); expect(operation.responses![0].protocol.http).toMatchObject({ statusCodes: ["200"], - mediaTypes: ["application/json"], headers: [expect.objectContaining({ header: "x-response-id" })], }); + if (operation === voidOperation && flavor !== "azure") { + expect(operation.responses![0].protocol.http).not.toHaveProperty("mediaTypes"); + } else { + expect(operation.responses![0].protocol.http.mediaTypes).toEqual(["application/json"]); + } expect(operation.exceptions).toHaveLength(1); expect(operation.exceptions![0].protocol.http.statusCodes).toEqual(["400"]); expect(isSchemaResponse(operation.exceptions![0])).toBe(true); From 2515d821340992c2b577d8eb2269d11105a0b4ae Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 17:52:09 +0800 Subject: [PATCH 05/10] refactor(http-client-java): separate raw bytes from file detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../emitter/src/code-model-builder.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index e2715b7c183..2a2b7c0e2da 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -2477,27 +2477,28 @@ export class CodeModelBuilder { } } - let responseBodyIsBinary = false; - if ( + let responseBodyIsFile = false; + if (replaceResponseWithBytes && bodyType) { + responseBodyIsFile = true; + } else if ( bodyType && - (replaceResponseWithBytes || - (bodyType.kind === "model" && - bodyType.serializationOptions.binary && - bodyType.serializationOptions.binary.isFile)) + bodyType.kind === "model" && + bodyType.serializationOptions.binary && + bodyType.serializationOptions.binary.isFile ) { - // raw response replacement or File - responseBodyIsBinary = true; + // check for File + responseBodyIsFile = true; } else if (bodyType && bodyType.kind === "bytes") { // check for bytes + unknown content-type const unknownResponseBody = sdkResponse.contentTypes && sdkResponse.contentTypes.length > 0 && !isKnownContentType(sdkResponse.contentTypes); - responseBodyIsBinary = Boolean(unknownResponseBody); + responseBodyIsFile = Boolean(unknownResponseBody); } let response: Response; - if (responseBodyIsBinary) { + if (responseBodyIsFile) { // binary/file response = createBinaryResponse({ protocol: { @@ -2525,7 +2526,7 @@ export class CodeModelBuilder { // schema (usually JSON) let schema: Schema | undefined; if ( - !responseBodyIsBinary && + !responseBodyIsFile && sdkResponse.contentTypes && sdkResponse.contentTypes.length === 1 && sdkResponse.contentTypes[0] === "text/plain" && From c809bc2af5ad470ebf69566f4e321db69a5eb7d2 Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 17:55:43 +0800 Subject: [PATCH 06/10] refactor(http-client-java): inline bytes replacement condition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../http-client-java/emitter/src/code-model-builder.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index 2a2b7c0e2da..36cb8b1d340 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -2402,7 +2402,6 @@ export class CodeModelBuilder { const replaceResponseBodyType = !isErrorResponse && responseReplacement !== undefined && !this.isAzureV1(); let bodyType: SdkType | undefined = sdkResponse.type; - let replaceResponseWithBytes = false; if (!isErrorResponse && responseReplacement !== undefined) { switch (responseReplacement) { case "void": @@ -2415,9 +2414,6 @@ export class CodeModelBuilder { case "bytes": if (replaceConvenienceResponse && op.convenienceApi) { op.convenienceApi.response = createBinaryResponse(); - } else if (replaceResponseBodyType) { - // Raw bytes bypass JSON/base64 decoding even for application/json responses. - replaceResponseWithBytes = true; } break; default: @@ -2477,8 +2473,9 @@ export class CodeModelBuilder { } } - let responseBodyIsFile = false; - if (replaceResponseWithBytes && bodyType) { + let responseBodyIsFile: boolean = false; + if (replaceResponseBodyType && responseReplacement === "bytes" && bodyType) { + // Raw bytes bypass JSON/base64 decoding even for application/json responses. responseBodyIsFile = true; } else if ( bodyType && From 7fb6e9149b3d2d71d5ef6772042160643ba581ef Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 18:55:01 +0800 Subject: [PATCH 07/10] fix(http-client-java): handle optional and bodyless response replacements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- packages/http-client-java/README.md | 1 + .../emitter/src/code-model-builder.ts | 20 ++++++- ...bytes-response-replacement-without-body.md | 13 +++++ packages/http-client-java/emitter/src/lib.ts | 7 +++ .../emitter/test/code-model.test.ts | 57 ++++++++++++++++++- .../test/fixtures/response-replacement.tsp | 48 ++++++++++++++++ 6 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 packages/http-client-java/emitter/src/diagnostics/bytes-response-replacement-without-body.md diff --git a/packages/http-client-java/README.md b/packages/http-client-java/README.md index 149f1b6232a..40d3ef15b42 100644 --- a/packages/http-client-java/README.md +++ b/packages/http-client-java/README.md @@ -49,6 +49,7 @@ Use TCGC's `@override` with `replaceResponseWithVoid` or `replaceResponseWithByt ignore a basic operation's response body or return its raw `BinaryData`, without JSON decoding. The HTTP status codes, headers, and error responses are preserved. For Azure clients, protocol methods still expose the original response body. +A bytes replacement of an operation with no declared HTTP response body is ignored with a warning. ## Emitter options diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index 36cb8b1d340..98a4aa314ad 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -1192,15 +1192,31 @@ export class CodeModelBuilder { } // TCGC only supports response replacement with void or bytes. - const responseReplacement = + // Infer replacement when no declared HTTP body type matches the method return type. + // A no-content variant alone must not classify a parameter-only override as a replacement. + let responseReplacement: "void" | "bytes" | undefined = sdkMethod.isOverride && sdkMethod.kind === "basic" && (sdkMethod.response.type === undefined || sdkMethod.response.type.kind === "bytes") && - httpOperation.responses.some((response) => response.type !== sdkMethod.response.type) + httpOperation.responses.some((response) => response.type !== sdkMethod.response.type) && + !httpOperation.responses.some( + (response) => response.type !== undefined && response.type === sdkMethod.response.type, + ) ? sdkMethod.response.type === undefined ? "void" : "bytes" : undefined; + if ( + responseReplacement === "bytes" && + httpOperation.responses.every((response) => response.type === undefined) + ) { + reportDiagnostic(this.program, { + code: "bytes-response-replacement-without-body", + format: { operationName }, + target: sdkMethod.__raw ?? httpOperation.__raw.operation, + }); + responseReplacement = undefined; + } // responses for (const response of sdkMethod.operation.responses) { this.processResponse( diff --git a/packages/http-client-java/emitter/src/diagnostics/bytes-response-replacement-without-body.md b/packages/http-client-java/emitter/src/diagnostics/bytes-response-replacement-without-body.md new file mode 100644 index 00000000000..d481310dec7 --- /dev/null +++ b/packages/http-client-java/emitter/src/diagnostics/bytes-response-replacement-without-body.md @@ -0,0 +1,13 @@ +This warning is issued when `replaceResponseWithBytes` targets an operation with no declared HTTP response body. + +## Impact + +The emitter cannot fabricate response bytes. It ignores the response type override and preserves the original response type in all Java flavors. + +## How to Fix + +Remove the bytes replacement, or declare the HTTP response body if the service actually returns one. + +## Suppression + +Suppressing this warning does not enable the replacement; the original response type is still used. diff --git a/packages/http-client-java/emitter/src/lib.ts b/packages/http-client-java/emitter/src/lib.ts index 4fbe95db0d4..b2df2b61936 100644 --- a/packages/http-client-java/emitter/src/lib.ts +++ b/packages/http-client-java/emitter/src/lib.ts @@ -159,6 +159,13 @@ export const $lib = createTypeSpecLibrary({ "Spread JSON merge-patch payload is not supported. The reason is that a property in JSON merge-patch payload class can: set a value; not set so that value does not change; set to null to remove the value. A parameter on method cannot distinguish the latter 2 cases.", }, }, + "bytes-response-replacement-without-body": { + ...doc("bytes-response-replacement-without-body"), + severity: "warning", + messages: { + default: paramMessage`Cannot replace the response of operation '${"operationName"}' with bytes: the emitter cannot fabricate bytes when the HTTP response has no body. The response type override is ignored.`, + }, + }, "type-not-supported-on-text-plain": { ...doc("type-not-supported-on-text-plain"), severity: "warning", diff --git a/packages/http-client-java/emitter/test/code-model.test.ts b/packages/http-client-java/emitter/test/code-model.test.ts index 4d7e7821b70..9b0e2680200 100644 --- a/packages/http-client-java/emitter/test/code-model.test.ts +++ b/packages/http-client-java/emitter/test/code-model.test.ts @@ -1,4 +1,5 @@ import { NodeHost, compile } from "@typespec/compiler"; +import { expectDiagnostics } from "@typespec/compiler/testing"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parse, stringify } from "yaml"; @@ -305,7 +306,21 @@ describe("code model characterization", () => { report: () => {}, }, }).build(); - expect(program.diagnostics.filter((d) => d.severity === "error")).toEqual([]); + expectDiagnostics( + program.diagnostics.filter( + (diagnostic) => + diagnostic.code !== + "@azure-tools/typespec-client-generator-core/unsupported-generic-decorator-arg-type", + ), + [ + { + code: "@typespec/http-client-java/bytes-response-replacement-without-body", + severity: "warning", + message: + "Cannot replace the response of operation 'bodylessBytesResponse' with bytes: the emitter cannot fabricate bytes when the HTTP response has no body. The response type override is ignored.", + }, + ], + ); const operations = model.clients.flatMap((client) => client.operationGroups.flatMap((group) => group.operations ?? []), ); @@ -366,6 +381,46 @@ describe("code model characterization", () => { expect(parameterOverrideOperation.responses![0]).toMatchObject({ schema: { type: SchemaType.ByteArray }, }); + const parameterOverrideOptionalOperation = operations.find( + (op) => op.language.default.name === "parameterOverrideOptionalBytesResponse", + )!; + expect(parameterOverrideOptionalOperation.convenienceApi).not.toHaveProperty("response"); + expect(parameterOverrideOptionalOperation.responses![0]).toMatchObject({ + schema: { type: SchemaType.ByteArray }, + }); + expect(parameterOverrideOptionalOperation.responses![1]).not.toHaveProperty("schema"); + expect(parameterOverrideOptionalOperation.responses![1]).not.toHaveProperty("binary"); + + const optionalBytesOperation = operations.find( + (op) => op.language.default.name === "optionalBytesResponse", + )!; + const optionalVoidOperation = operations.find( + (op) => op.language.default.name === "optionalVoidResponse", + )!; + if (flavor === "azure") { + expect(optionalBytesOperation.convenienceApi).toMatchObject({ response: { binary: true } }); + expect(optionalVoidOperation.convenienceApi).toMatchObject({ response: {} }); + expect(isSchemaResponse(optionalVoidOperation.responses![0])).toBe(true); + } else { + expect(optionalBytesOperation.responses![0]).toMatchObject({ binary: true }); + expect(optionalVoidOperation.responses![0]).not.toHaveProperty("schema"); + } + for (const name of ["bodylessBytesResponse", "suppressedBodylessBytesResponse"]) { + const operation = operations.find((op) => op.language.default.name === name)!; + expect(operation.convenienceApi).not.toHaveProperty("response"); + expect(operation.responses![0]).not.toHaveProperty("schema"); + expect(operation.responses![0]).not.toHaveProperty("binary"); + expect(operation.responses![0].protocol.http).toMatchObject({ + statusCodes: ["204"], + headers: [expect.objectContaining({ header: "x-response-id" })], + }); + } + const bodylessOperation = operations.find( + (op) => op.language.default.name === "bodylessBytesResponse", + )!; + expect(bodylessOperation.exceptions![0]).toMatchObject({ + schema: { usage: expect.arrayContaining(["exception"]) }, + }); }, ); }); diff --git a/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp b/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp index 872507c7ae6..ed191a1e205 100644 --- a/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp +++ b/packages/http-client-java/emitter/test/fixtures/response-replacement.tsp @@ -22,6 +22,11 @@ model EncodedBytesResponse { @body body: bytes; } +model NoContentResponse { + @statusCode statusCode: 204; + @header("x-response-id") responseId: string; +} + @error model ErrorResponse { @statusCode statusCode: 400; @@ -51,6 +56,29 @@ op parameterOverrideBytesResponse( @query second: string, ): EncodedBytesResponse; +@get +@route("/parameter-override-optional") +op parameterOverrideOptionalBytesResponse(@query first: string, @query second: string): + | EncodedBytesResponse + | NoContentResponse; + +@get +@route("/optional-bytes") +op optionalBytesResponse(): EncodedBytesResponse | NoContentResponse; + +@get +@route("/optional-void") +op optionalVoidResponse(): WidgetResponse | NoContentResponse; + +@get +@route("/bodyless-bytes") +op bodylessBytesResponse(): NoContentResponse | ErrorResponse; + +#suppress "@typespec/http-client-java/bytes-response-replacement-without-body" "Testing suppression of a bodyless replacement warning." +@get +@route("/suppressed-bodyless-bytes") +op suppressedBodylessBytesResponse(): NoContentResponse; + #suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing a void response replacement." @@override(voidResponse, replaceResponseWithVoid(voidResponse)); @@ -64,3 +92,23 @@ op parameterOverrideBytesResponse( parameterOverrideBytesResponse, reorderParameters(parameterOverrideBytesResponse, #["second", "first"]) ); + +@@override( + parameterOverrideOptionalBytesResponse, + reorderParameters(parameterOverrideOptionalBytesResponse, #["second", "first"]) +); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing replacement of optional encoded bytes." +@@override(optionalBytesResponse, replaceResponseWithBytes(optionalBytesResponse)); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing replacement of an optional response with void." +@@override(optionalVoidResponse, replaceResponseWithVoid(optionalVoidResponse)); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing a bodyless bytes replacement." +@@override(bodylessBytesResponse, replaceResponseWithBytes(bodylessBytesResponse)); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing suppression of a bodyless replacement warning." +@@override( + suppressedBodylessBytesResponse, + replaceResponseWithBytes(suppressedBodylessBytesResponse) +); From 8989a0293fed09469c50a66b7572a45eccc18021 Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 19:05:43 +0800 Subject: [PATCH 08/10] docs(http-client-java): remove manual README changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- packages/http-client-java/README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/http-client-java/README.md b/packages/http-client-java/README.md index 40d3ef15b42..bbac395bced 100644 --- a/packages/http-client-java/README.md +++ b/packages/http-client-java/README.md @@ -43,14 +43,6 @@ options: option: value ``` -### Response replacement - -Use TCGC's `@override` with `replaceResponseWithVoid` or `replaceResponseWithBytes` to -ignore a basic operation's response body or return its raw `BinaryData`, without JSON decoding. -The HTTP status codes, headers, and error responses are preserved. -For Azure clients, protocol methods still expose the original response body. -A bytes replacement of an operation with no declared HTTP response body is ignored with a warning. - ## Emitter options ### `emitter-output-dir` From 45920517f35287d01e75e88ca03968d70ceef0ab Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Mon, 12 Oct 2026 20:12:37 +0800 Subject: [PATCH 09/10] test(http-client-java): remove response replacement Spector test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../ResponseReplacementTests.java | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java diff --git a/packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java b/packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java deleted file mode 100644 index a91ccd08922..00000000000 --- a/packages/http-client-java/generator/http-client-generator-test/src/test/java/_specs_/azure/clientgenerator/core/responsereplacement/ResponseReplacementTests.java +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package _specs_.azure.clientgenerator.core.responsereplacement; - -import com.azure.core.util.BinaryData; -import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import reactor.test.StepVerifier; - -public class ResponseReplacementTests { - private static final byte[] EXPECTED_BODY = "{\"name\":\"widget\"}".getBytes(StandardCharsets.UTF_8); - private final ResponseReplacementClient client = new ResponseReplacementClientBuilder().buildClient(); - private final ResponseReplacementAsyncClient asyncClient - = new ResponseReplacementClientBuilder().buildAsyncClient(); - - @Test - public void testVoidResponse() throws NoSuchMethodException { - Assertions.assertEquals(void.class, ResponseReplacementClient.class.getMethod("voidResponse").getReturnType()); - client.voidResponse(); - } - - @Test - public void testVoidResponseAsync() { - StepVerifier.create(asyncClient.voidResponse()).verifyComplete(); - } - - @Test - public void testBytesResponse() { - BinaryData response = client.bytesResponse(); - Assertions.assertArrayEquals(EXPECTED_BODY, response.toBytes()); - } - - @Test - public void testBytesResponseAsync() { - StepVerifier.create(asyncClient.bytesResponse()) - .assertNext(response -> Assertions.assertArrayEquals(EXPECTED_BODY, response.toBytes())) - .verifyComplete(); - } - - @Test - public void testProtocolResponsesPreserveBody() { - var voidResponse = client.voidResponseWithResponse(null); - Assertions.assertEquals(200, voidResponse.getStatusCode()); - Assertions.assertArrayEquals(EXPECTED_BODY, voidResponse.getValue().toBytes()); - var bytesResponse = client.bytesResponseWithResponse(null); - Assertions.assertEquals(200, bytesResponse.getStatusCode()); - Assertions.assertArrayEquals(EXPECTED_BODY, bytesResponse.getValue().toBytes()); - } -} From e8d76f327b72a5f9e306f53402736a8604236ab2 Mon Sep 17 00:00:00 2001 From: Weidong Xu Date: Tue, 13 Oct 2026 15:33:58 +0800 Subject: [PATCH 10/10] fix(http-client-java): preserve response headers and raw text bytes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 605e4237-1133-48b0-827b-01786c98f301 --- .../http-client-java-response-replacement.md | 2 +- .../emitter/src/code-model-builder.ts | 10 +-- .../emitter/test/code-model.test.ts | 84 ++++++++++++++++++- .../response-replacement-headers-internal.tsp | 10 +++ .../fixtures/response-replacement-headers.tsp | 42 ++++++++++ .../ConvenienceAsyncMethodTemplate.java | 9 +- .../ConvenienceMethodTemplateBase.java | 8 +- .../ConvenienceMethodTemplateBaseTests.java | 71 ++++++++++++++++ 8 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 packages/http-client-java/emitter/test/fixtures/response-replacement-headers-internal.tsp create mode 100644 packages/http-client-java/emitter/test/fixtures/response-replacement-headers.tsp diff --git a/.chronus/changes/http-client-java-response-replacement.md b/.chronus/changes/http-client-java-response-replacement.md index e3f8366f7df..4d755b77e0f 100644 --- a/.chronus/changes/http-client-java-response-replacement.md +++ b/.chronus/changes/http-client-java-response-replacement.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-java" --- -Honor void and raw-byte response replacements in generated Java clients. +Honor void and raw-byte response replacements in generated Java clients, preserving types used by typed response headers. Fix raw-byte conversion for text responses. diff --git a/packages/http-client-java/emitter/src/code-model-builder.ts b/packages/http-client-java/emitter/src/code-model-builder.ts index 98a4aa314ad..911f36da9bd 100644 --- a/packages/http-client-java/emitter/src/code-model-builder.ts +++ b/packages/http-client-java/emitter/src/code-model-builder.ts @@ -2473,9 +2473,9 @@ export class CodeModelBuilder { } } - let trackConvenienceApi: boolean = Boolean(op.convenienceApi) && !replaceConvenienceResponse; + let trackResponseBody: boolean = Boolean(op.convenienceApi) && !replaceConvenienceResponse; const trackResponseHeadersAsModel = - trackConvenienceApi && + Boolean(op.convenienceApi) && (op.convenienceApi?.responseHeadersAsModel === true || (this.isAzureV1() && this.options["max-overload"] === "model")); @@ -2533,7 +2533,7 @@ export class CodeModelBuilder { if (longRunning) { // LRO uses the LroMetadata for poll/final result, not the response of activation request // hence the schema below is not tracked for convenience API - trackConvenienceApi = false; + trackResponseBody = false; } // schema (usually JSON) @@ -2596,7 +2596,7 @@ export class CodeModelBuilder { if (isSchemaResponse(response)) { this.trackSchemaUsage(response.schema, { usage: [SchemaContext.Exception] }); - if (trackConvenienceApi) { + if (trackResponseBody) { let outputErrorModel = false; if (!this.isBranded()) { outputErrorModel = true; @@ -2624,7 +2624,7 @@ export class CodeModelBuilder { if (isSchemaResponse(response)) { this.trackSchemaUsage(response.schema, { usage: [SchemaContext.Output] }); - if (trackConvenienceApi) { + if (trackResponseBody) { this.trackSchemaUsage(response.schema, { usage: [op.internalApi ? SchemaContext.Internal : SchemaContext.Public], }); diff --git a/packages/http-client-java/emitter/test/code-model.test.ts b/packages/http-client-java/emitter/test/code-model.test.ts index 9b0e2680200..e55fddb803b 100644 --- a/packages/http-client-java/emitter/test/code-model.test.ts +++ b/packages/http-client-java/emitter/test/code-model.test.ts @@ -1,5 +1,5 @@ import { NodeHost, compile } from "@typespec/compiler"; -import { expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parse, stringify } from "yaml"; @@ -423,6 +423,88 @@ describe("code model characterization", () => { }); }, ); + it.each([ + { internalApi: false, typedHeaders: false }, + { internalApi: false, typedHeaders: true }, + { internalApi: true, typedHeaders: false }, + { internalApi: true, typedHeaders: true }, + ])( + "preserves replacement header visibility (internal: $internalApi, typed: $typedHeaders)", + async ({ internalApi, typedHeaders }) => { + const program = await compile( + NodeHost, + fileURLToPath( + new URL( + `./fixtures/response-replacement-headers${internalApi ? "-internal" : ""}.tsp`, + import.meta.url, + ), + ), + { noEmit: true }, + ); + expectDiagnosticEmpty(program.diagnostics); + const model = await new CodeModelBuilder(program, { + program, + emitterOutputDir: "/code-model-test-output", + options: { + flavor: "azure", + ...(typedHeaders ? { "max-overload": "model" } : {}), + "dev-options": {}, + }, + perf: { + startTimer: () => ({ end: () => 0 }), + time: (_label, callback) => callback(), + timeAsync: (_label, callback) => callback(), + report: () => {}, + }, + }).build(); + expectDiagnosticEmpty( + program.diagnostics.filter( + (diagnostic) => + diagnostic.code !== + "@azure-tools/typespec-client-generator-core/unsupported-generic-decorator-arg-type", + ), + ); + const bodySchema = model.schemas.objects?.find( + (schema) => schema.language.default.name === "OriginalBody", + ); + const bodyEnum = [ + ...(model.schemas.choices ?? []), + ...(model.schemas.sealedChoices ?? []), + ].find((schema) => schema.language.default.name === "BodyState"); + for (const schema of [bodySchema, bodyEnum]) { + expect(schema).toBeDefined(); + for (const visibility of ["public", "internal"]) { + expect(schema).not.toMatchObject({ usage: expect.arrayContaining([visibility]) }); + } + } + + const operations = model.clients.flatMap((client) => + client.operationGroups.flatMap((group) => group.operations ?? []), + ); + expect(operations).toHaveLength(2); + for (const operation of operations) { + expect(operation.internalApi).toBe(internalApi); + expect(operation.responses).toHaveLength(1); + expect(operation.responses![0]).toMatchObject({ schema: bodySchema }); + const header = operation.responses![0].protocol.http.headers![0]; + expect(header).toMatchObject({ + header: "x-response-state", + schema: { language: { default: { name: "ResponseState" } } }, + }); + if (typedHeaders) { + expect(header.schema).toMatchObject({ + usage: expect.arrayContaining([internalApi ? "internal" : "public"]), + }); + } else { + for (const visibility of ["public", "internal"]) { + expect(header.schema).not.toMatchObject({ + usage: expect.arrayContaining([visibility]), + }); + } + } + } + }, + ); }); function assertPlainGraph(value: unknown, seen = new Set()) { diff --git a/packages/http-client-java/emitter/test/fixtures/response-replacement-headers-internal.tsp b/packages/http-client-java/emitter/test/fixtures/response-replacement-headers-internal.tsp new file mode 100644 index 00000000000..bbf60f3c60e --- /dev/null +++ b/packages/http-client-java/emitter/test/fixtures/response-replacement-headers-internal.tsp @@ -0,0 +1,10 @@ +import "./response-replacement-headers.tsp"; + +@@Azure.ClientGenerator.Core.access( + ResponseReplacementHeaders.voidResponse, + Azure.ClientGenerator.Core.Access.internal +); +@@Azure.ClientGenerator.Core.access( + ResponseReplacementHeaders.bytesResponse, + Azure.ClientGenerator.Core.Access.internal +); diff --git a/packages/http-client-java/emitter/test/fixtures/response-replacement-headers.tsp b/packages/http-client-java/emitter/test/fixtures/response-replacement-headers.tsp new file mode 100644 index 00000000000..f33eb67bd01 --- /dev/null +++ b/packages/http-client-java/emitter/test/fixtures/response-replacement-headers.tsp @@ -0,0 +1,42 @@ +import "@typespec/http"; +import "@azure-tools/typespec-client-generator-core"; + +using TypeSpec.Http; +using Azure.ClientGenerator.Core; + +@service +namespace ResponseReplacementHeaders; + +enum ResponseState { + ready, + waiting, +} + +enum BodyState { + pending, + complete, +} + +model OriginalBody { + state: BodyState; +} + +model OriginalResponse { + @statusCode statusCode: 200; + @header("x-response-state") state: ResponseState; + @body body: OriginalBody; +} + +@get +@route("/void") +op voidResponse(): OriginalResponse; + +@get +@route("/bytes") +op bytesResponse(): OriginalResponse; + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing a void response replacement with typed headers." +@@override(voidResponse, replaceResponseWithVoid(voidResponse)); + +#suppress "@azure-tools/typespec-client-generator-core/override-response-replacement" "Testing a bytes response replacement with typed headers." +@@override(bytesResponse, replaceResponseWithBytes(bytesResponse)); diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java index 49f141bc586..075a660eab5 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceAsyncMethodTemplate.java @@ -187,10 +187,16 @@ private String expressionConvertFromBinaryData(IType responseBodyType, IType raw private String expressionMapFromBinaryData(IType responseBodyType, IType rawType, Set mediaTypes, Set typeReferenceStaticClasses) { + if (responseBodyType == ClassType.BINARY_DATA) { + return null; + } SupportedMimeType mimeType = SupportedMimeType.getResponseKnownMimeType(mediaTypes); String serializerArgument = xmlSerializerArgument(mimeType, responseBodyType); switch (mimeType) { case TEXT: + if (responseBodyType == ArrayType.BYTE_ARRAY) { + return "protocolMethodData -> protocolMethodData.toBytes()"; + } String baseHandling = "protocolMethodData.toString()"; if (!rawType.isNullable()) { // Dealing with a primitive type that needs to be converted. @@ -214,9 +220,6 @@ private String expressionMapFromBinaryData(IType responseBodyType, IType rawType typeReferenceStaticClasses.add((GenericType) responseBodyType); return String.format("protocolMethodData -> protocolMethodData.toObject(%1$s%2$s)", TemplateUtil.getTypeReferenceCreation(responseBodyType), serializerArgument); - } else if (responseBodyType == ClassType.BINARY_DATA) { - // BinaryData, no need to do the map in expressionConvertFromBinaryData - return null; } else if (responseBodyType == ArrayType.BYTE_ARRAY) { // byte[] if (rawType == ClassType.BASE_64_URL) { diff --git a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBase.java b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBase.java index 9e23121ce21..9c8d03e9cfe 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBase.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/main/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBase.java @@ -228,10 +228,16 @@ protected static GenericType getResponseBaseType(IType type) { protected static String expressionConvertFromBinaryData(IType responseBodyType, IType rawType, String invocationExpression, Set mediaTypes, Set typeReferenceStaticClasses) { + if (responseBodyType == ClassType.BINARY_DATA) { + return invocationExpression; + } SupportedMimeType mimeType = SupportedMimeType.getResponseKnownMimeType(mediaTypes); String serializerArgument = xmlSerializerArgument(mimeType, responseBodyType); switch (mimeType) { case TEXT: + if (responseBodyType == ArrayType.BYTE_ARRAY) { + return invocationExpression + ".toBytes()"; + } String basicText = invocationExpression + ".toString()"; if (!rawType.isNullable()) { return wrapPrimitiveMimeTypeText(basicText, (PrimitiveType) rawType); @@ -251,8 +257,6 @@ protected static String expressionConvertFromBinaryData(IType responseBodyType, return String.format("%2$s.toObject(%1$s%3$s)", TemplateUtil.getTypeReferenceCreation(responseBodyType), invocationExpression, serializerArgument); - } else if (responseBodyType == ClassType.BINARY_DATA) { - return invocationExpression; } else if (responseBodyType == ArrayType.BYTE_ARRAY) { if (rawType == ClassType.BASE_64_URL) { return invocationExpression + ".toObject(" + ClassType.BASE_64_URL.getName() + ".class" diff --git a/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBaseTests.java b/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBaseTests.java index dc43e9f71ec..0baaafbc111 100644 --- a/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBaseTests.java +++ b/packages/http-client-java/generator/http-client-generator-core/src/test/java/com/microsoft/typespec/http/client/generator/core/template/ConvenienceMethodTemplateBaseTests.java @@ -6,10 +6,16 @@ import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ArrayType; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ClassType; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ClientMethod; +import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ClientMethodType; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.GenericType; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.IType; +import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ProxyMethod; import com.microsoft.typespec.http.client.generator.core.model.clientmodel.ReturnValue; +import com.microsoft.typespec.http.client.generator.core.model.javamodel.JavaBlock; +import com.microsoft.typespec.http.client.generator.core.model.javamodel.JavaFileContents; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -66,6 +72,71 @@ public void keepsNonGenericTypeUnchanged() { Assertions.assertSame(returnType, result); } + @Test + public void binaryDataResponsesIgnoreContentType() { + for (String contentType : List.of("text/plain", "application/json", "application/xml", + "application/octet-stream")) { + Assertions.assertEquals("return readWithResponse(requestOptions).getValue();", + renderConversion(false, ClassType.BINARY_DATA, ClassType.STRING, contentType)); + Assertions.assertEquals("return readWithResponse(requestOptions).flatMap(FluxUtil::toMono);", + renderConversion(true, ClassType.BINARY_DATA, ClassType.STRING, contentType)); + } + } + + @Test + public void textBytesResponsesUseRawBytes() { + Assertions.assertEquals("return readWithResponse(requestOptions).getValue().toBytes();", + renderConversion(false, ArrayType.BYTE_ARRAY, ArrayType.BYTE_ARRAY, "text/plain")); + Assertions.assertEquals( + "return readWithResponse(requestOptions).flatMap(FluxUtil::toMono).map(protocolMethodData -> protocolMethodData.toBytes());", + renderConversion(true, ArrayType.BYTE_ARRAY, ArrayType.BYTE_ARRAY, "text/plain")); + } + + @Test + public void textStringsKeepTextConversion() { + Assertions.assertEquals("return readWithResponse(requestOptions).getValue().toString();", + renderConversion(false, ClassType.STRING, ClassType.STRING, "text/plain")); + Assertions.assertEquals( + "return readWithResponse(requestOptions).flatMap(FluxUtil::toMono).map(protocolMethodData -> protocolMethodData.toString());", + renderConversion(true, ClassType.STRING, ClassType.STRING, "text/plain")); + } + + @Test + public void jsonBytesKeepDeserialization() { + Assertions.assertEquals("return readWithResponse(requestOptions).getValue().toObject(byte[].class);", + renderConversion(false, ArrayType.BYTE_ARRAY, ArrayType.BYTE_ARRAY, "application/json")); + Assertions.assertEquals( + "return readWithResponse(requestOptions).flatMap(FluxUtil::toMono).map(protocolMethodData -> protocolMethodData.toObject(byte[].class));", + renderConversion(true, ArrayType.BYTE_ARRAY, ArrayType.BYTE_ARRAY, "application/json")); + } + + private static String renderConversion(boolean async, IType responseBodyType, IType rawType, String contentType) { + ProxyMethod proxyMethod = new ProxyMethod.Builder().name("read") + .responseBodyType(ClassType.BINARY_DATA) + .rawResponseBodyType(rawType) + .responseContentTypes(Set.of(contentType)) + .build(); + IType protocolReturnType = GenericType.response(ClassType.BINARY_DATA); + ClientMethod protocolMethod = new ClientMethod.Builder().name("readWithResponse") + .parameters(List.of()) + .proxyMethod(proxyMethod) + .type(async ? ClientMethodType.SimpleAsyncRestResponse : ClientMethodType.SimpleSyncRestResponse) + .returnValue(new ReturnValue("response", async ? GenericType.mono(protocolReturnType) : protocolReturnType)) + .build(); + ClientMethod convenienceMethod = new ClientMethod.Builder().name("read") + .parameters(List.of()) + .proxyMethod(proxyMethod) + .type(async ? ClientMethodType.SimpleAsync : ClientMethodType.SimpleSync) + .returnValue(new ReturnValue("response", async ? GenericType.mono(responseBodyType) : responseBodyType)) + .build(); + JavaFileContents contents = new JavaFileContents(); + ConvenienceMethodTemplateBase template + = async ? ConvenienceAsyncMethodTemplate.getInstance() : ConvenienceSyncMethodTemplate.getInstance(); + template.writeInvocationAndConversion(convenienceMethod, protocolMethod, "requestOptions", + new JavaBlock(contents), new HashSet<>()); + return contents.toString().trim(); + } + private static ClientMethod methodWithReturnType(IType returnType) { return new ClientMethod.Builder().name("test") .description("test")