Skip to content

feat: add wrap-non-model-return option to prevent breaking changes for non-model types#3805

Open
Copilot wants to merge 29 commits intomainfrom
copilot/fix-tsp-migration-issues
Open

feat: add wrap-non-model-return option to prevent breaking changes for non-model types#3805
Copilot wants to merge 29 commits intomainfrom
copilot/fix-tsp-migration-issues

Conversation

Copy link
Contributor

Copilot AI commented Mar 2, 2026

PR Summary

This PR adds a new emitter option, wrap-non-model-return, to prevent breaking changes when migrating TypeSpec clients that return non-model response body types. For Azure services, by default we would expose the wrapper return to mitigate the breakings.

Why

During TSP migration, operations returning non-model payloads (especially scalars, arrays, and binary payloads) could produce shape differences that broke downstream consumers. This change aligns return-shape behavior and provides explicit control via configuration.

What changed

  • Added wrapNonModelReturn to RLC options in packages/rlc-common/src/interfaces.ts.
  • Added wrap-non-model-return emitter option in packages/typespec-ts/src/lib.ts.
  • Wired option transformation (Azure flavor defaults to true) in packages/typespec-ts/src/transform/transfromRLCOptions.ts.
  • Implemented non-model wrapping behavior in operation generation:
    • Scalar and array responses wrap as { body: T }
    • Binary responses wrap as { blobBody?, readableStreamBody? }
    • Main logic in packages/typespec-ts/src/modular/helpers/operationHelpers.ts
  • Added emitNonModelResponseTypes() to generate and expose XxxResponse aliases in models:
    • packages/typespec-ts/src/modular/emit/emitModels.ts
    • Types emitted into models/models.ts and publicly re-exported
  • Updated generated imports/usages to consume response aliases from models.

Fixes during PR iteration

  • Resolved Azure modular integration breakages (notably array response access in overload tests).
  • Fixed binary deserialization edge cases across browser and Node environments.
  • Corrected helper usage (getBinaryResponse as helper function, not StreamableMethod method).
  • Restored binary wrapping flow in operation function generation to avoid wrong runtime type paths.
  • Applied formatting and regenerated impacted snapshots/generated artifacts.

Validation

  • Smoke tests refreshed and passing (All specs succeeded! reported in thread).
  • Modular unit tests refreshed and passing (545 passing reported in thread).
  • Integration failures found in CI were addressed through follow-up commits and regenerated outputs.

Impact

  • Reduces migration risk for non-model response payloads.
  • Improves consistency with expected HLC response wrapping semantics.
  • Makes generated XxxResponse aliases discoverable and publicly consumable from models.

Linked issue

  • Fixes #3655 ([migration tsp] A non-model response body type will introduce breakings during tsp migration)
Original prompt

This section details on the original issue you should resolve

<issue_title>[migration tsp] A non-model response body type will introduce breakings during tsp migration</issue_title>
<issue_description>1: This breaking is found in verify breakings in tsp conversion.
Breaking message:

- Operation EmailServices.listVerifiedExchangeOnlineDomains has a new signature

Details:
The service has an operation named listVerifiedExchangeOnlineDomains and its response is a string array:
Swagger:

"responses": {
    "200": {
      "description": "Success. The response describe a list of verified domains from Exchange Online.",
      "schema": {
        "$ref": "#/definitions/VerifiedExchangeOnlineDomainList"
      }
    },
    ...
}
...
"VerifiedExchangeOnlineDomainList": {
  "description": "List of FQDNs of verified domains in Exchange Online.",
  "type": "array",
  "items": {
    "type": "string"
  }
},

In HLC, we will separately package the response into a model called xxxResponse, and based on this code, and assign a body property to a non-model response:

export interface EmailServices {
     listVerifiedExchangeOnlineDomains(options?: EmailServicesListVerifiedExchangeOnlineDomainsOptionalParams):Promise<EmailServicesListVerifiedExchangeOnlineDomainsResponse>;
}

// @public
export type EmailServicesListVerifiedExchangeOnlineDomainsResponse = {
    body: string[];
};

However, in Modular, the string[] type will be used as the promise response type directly.
TypeSpec:

listVerifiedExchangeOnlineDomains is ArmProviderActionSync<
  Response = ArmResponse<VerifiedExchangeOnlineDomainList>,
  Scope = SubscriptionActionScope
>;

model VerifiedExchangeOnlineDomainList is string[];

Modular:

export interface EmailServicesOperations {
     listVerifiedExchangeOnlineDomains: (options?: EmailServicesListVerifiedExchangeOnlineDomainsOptionalParams) =>Promise<string[]>;
}

2: The same breakings are introduced for file/bytes type.
Related issue: #3635 (Modular fails to generate if the response type is File<"application/octet-stream">).
Original Swagger:

"operationId": "WebApps_GetWebSiteContainerLogs",
"responses": {
  "200": {
    "description": "Azure operation completed successfully.",
    "schema": {
      "type": "file"
    }
  },
}

HLC:

export type WebAppsGetWebSiteContainerLogsResponse = {
  blobBody?: Promise<Blob>;

  readableStreamBody?: NodeJS.ReadableStream;
};

  getWebSiteContainerLogs(
    resourceGroupName: string,
    name: string,
    options?: WebAppsGetWebSiteContainerLogsOptionalParams,
  ): Promise<WebAppsGetWebSiteContainerLogsResponse> {
    return this.client.sendOperationRequest(
      { resourceGroupName, name, options },
      getWebSiteContainerLogsOperationSpec,
    );
  }

blobBody and readableStreamBody are also generated from codegen, not source Swagger.

In modular, it will be

export async function _testDeserialize(result: PathUncheckedResponse): Promise<Uint8Array> {
   ...
}

Do we accept this break or need to make the behavior the same as HLC?
I create a pr to investigate this

Proposal

We decided not accepting this breaking from HLC and Modular so we decide to have a feature flag named wrap-non-model-return in Modular and by default enabling this flag for all services, when the flag is set as false, we will still generate the old modular ways.

For normal operation(non paging or LRO operations), when the return type is non-model type, HLC would genereate a wrapper interface XXXResponse not directly returning the model type, so to not introduce any breakings in Modular, we would generate the same interfaces in these cases.

The XXXResponse would be like OperationGroupNameMethodNameResponse.

Case 1: If the return type is bytes type &the mediaType is KnownMediaType.Binary

Here is the code to define the binary type. Practical examples that map to binary in the content-type classifier are:application/octet-stream, image/*, audio...


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 2 commits March 2, 2026 04:46
…ring TSP migration

Co-authored-by: MaryGao <9943211+MaryGao@users.noreply.github.com>
…ments

Co-authored-by: MaryGao <9943211+MaryGao@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix breaking change in TSP migration response handling feat: add wrap-non-model-return option to prevent breaking changes during TSP migration Mar 2, 2026
@MaryGao
Copy link
Member

MaryGao commented Mar 2, 2026

@copilot two issues found in our CI

  • smoke testing failed and we need to refresh smoke testing
  • integration testing failed with following message and pls take a look and then add failed integration case into UT.
Exception during run: test/azureModularIntegration/azureClientOverload.spec.ts(18,31): error TS2339: Property 'length' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(19,24): error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'ListResponse'.
  Property '0' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(20,24): error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'ListResponse'.
  Property '0' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(21,24): error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'ListResponse'.
  Property '0' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(22,24): error TS7053: Element implicitly has an 'any' type because expression of type '1' can't be used to index type 'ListResponse'.
  Property '1' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(23,24): error TS7053: Element implicitly has an 'any' type because expression of type '1' can't be used to index type 'ListResponse'.
  Property '1' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(24,24): error TS7053: Element implicitly has an 'any' type because expression of type '1' can't be used to index type 'ListResponse'.
  Property '1' does not exist on type 'ListResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(29,31): error TS2339: Property 'length' does not exist on type 'ListByScopeResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(30,24): error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'ListByScopeResponse'.
  Property '0' does not exist on type 'ListByScopeResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(31,24): error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'ListByScopeResponse'.
  Property '0' does not exist on type 'ListByScopeResponse'.
test/azureModularIntegration/azureClientOverload.spec.ts(32,24): error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'ListByScopeResponse'.
  Property '0' does not exist on type 'ListByScopeResponse

…or wrap-non-model-return

Co-authored-by: MaryGao <9943211+MaryGao@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 2, 2026

@copilot two issues found in our CI

  • smoke testing failed and we need to refresh smoke testing
  • integration testing failed with following message and pls take a look and then add...

Fixed in commit 6c87836.

  • Integration test failure: Updated azureClientOverload.spec.ts to use result.body.length / result.body[0] pattern since Resource[] is now correctly wrapped in { body: Resource[] } to match HLC behavior (arrays are PropertyKind.Array in HLC, not PropertyKind.Composite, so they get the body wrapper). Regenerated the client/overload generated client.

  • Smoke tests: Ran all smoke tests locally — all pass. The generated files have been refreshed to reflect the new wrapping behavior (binary responses get { blobBody?, readableStreamBody? }, scalar/array responses get { body: T }). Updated source files in batch_modular, ai, schemaRegistry, openai_generic, openai_modular, parametrizedHost, and widget_dpg.

  • Unit test added: Added a new scenario in wrapNonModelReturn.md for the Resource[] (array-of-models) case to cover the integration test failure case.

@MaryGao
Copy link
Member

MaryGao commented Mar 2, 2026

@copilot please genereate XXXResponse model in models.ts file and re-export it to public.

Co-authored-by: MaryGao <9943211+MaryGao@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 2, 2026

@copilot please genereate XXXResponse model in models.ts file and re-export it to public.

Done in commit 67f6a44. Added emitNonModelResponseTypes() in emitModels.ts that writes all XxxResponse type aliases into models/models.ts. It's called between emitTypes and buildSubpathIndexFile("models") so the types are automatically picked up by models/index.ts and re-exported publicly. Operations files now import the type from ../models/models.js (binder handles the import). Updated unit test scenarios to include ts models alias blocks verifying the types appear in models.

@MaryGao
Copy link
Member

MaryGao commented Mar 2, 2026

@copilot two issues found

  • One is in smoke testing and we need to regen for fix
  • Second is code format
> @azure-tools/typespec-ts@0.50.0 check-format
> npm run prettier -- --check


> @azure-tools/typespec-ts@0.50.0 prettier
> prettier --config ./.prettierrc "{src,test,static}/**/*.ts" "!test/**/generated/**/*.ts" --check

Checking formatting...
[warn] src/modular/helpers/operationHelpers.ts
[warn] test/util/emitUtil.ts
[warn] Code style issues found in 2 files. Run Prettier with --write to fix.

##[error]Bash exited with code '1'.

Co-authored-by: MaryGao <9943211+MaryGao@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 2, 2026

@copilot two issues found

  • One is in smoke testing and we need to regen for fix
  • Second is code format
    ...

Fixed in commit ddec895. Applied prettier formatting to operationHelpers.ts and emitUtil.ts. All smoke tests pass locally.

@MaryGao
Copy link
Member

MaryGao commented Mar 2, 2026

@copilot the pr should be ready for review and pls update the pr description accordingly.

@MaryGao MaryGao marked this pull request as ready for review March 2, 2026 14:03
Copy link
Contributor Author

Copilot AI commented Mar 4, 2026

@copilot two issues found in our CI.

  • smoke testing failed
  • integration testing failed with following message.
    ...

Fixed in commit bb3be37.

The root cause was that commit 6738777 mistakenly called getBinaryResponse as a method on StreamableMethod (.getBinaryResponse()), but getBinaryResponse is a standalone static helper function, not a method on StreamableMethod.

The fix uses resolveReference(SerializationHelpers.getBinaryResponse) to get the correct import reference and calls it as a function: getBinaryResponse(result as StreamableMethod).then((r) => new Blob([r.body ?? new Uint8Array()])). This ensures:

  • Browser: asBrowserStream() is used for blobBody (try branch)
  • Node.js: getBinaryResponse(result) reads the node stream and converts to Blob (catch branch)
  • blobBody is always populated in both environments

@MaryGao MaryGao force-pushed the copilot/fix-tsp-migration-issues branch from bb3be37 to 6738777 Compare March 4, 2026 02:37
@v-jiaodi
Copy link
Member

v-jiaodi commented Mar 4, 2026

@copilot

  • run pnpm smoke-test under typespec-test floder to refresh smoke testing
  • refresh modular UT

Copilot AI and others added 2 commits March 4, 2026 09:36
…e tests and modular UT

Co-authored-by: v-jiaodi <80496810+v-jiaodi@users.noreply.github.com>
…t any types

Co-authored-by: v-jiaodi <80496810+v-jiaodi@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 4, 2026

@copilot

  • run pnpm smoke-test under typespec-test floder to refresh smoke testing
  • refresh modular UT

Fixed in commits 3977f63 and 9764598.

  • Smoke tests: Refreshed — All specs succeeded!. Root cause was getOperationFunction missing wrapReturnIsBinary check — when shouldWrap && isBinary, the function was awaiting the send result (producing PathUncheckedResponse) instead of passing StreamableMethod directly to the deserializer. Restored wrapReturnIsBinary and added the binary wrap early-return path.
  • Modular UT: Refreshed — 545 passing. Updated wrapNonModelReturn.md to reflect the latest try-catch pattern (early-return per branch, no let uninitialized variables) and apiOperations.md to remove the stale getBinaryResponse import.

}

export async function _getTaskFileDeserialize(result: PathUncheckedResponse): Promise<Uint8Array> {
const expectedStatuses = ["200"];
Copy link
Member

Choose a reason for hiding this comment

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

Does this mean that an error will not be returned if there is any? I see that the status is stored in the HttpBrowserStreamResponse or HttpNodeStreamResponse from the asBrowserStream or asNodeStream methods, along with the headers and request. Should we return all of these, similar to how the getBinaryResponse helper works?

Copy link
Member

@MaryGao MaryGao Mar 6, 2026

Choose a reason for hiding this comment

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

An error body will also be returned since now customers use the streaming way to handle responses. I don't think we will return them all: https://github.com/Azure/autorest.typescript/pull/3805/changes#diff-645b7a8aa61f119778507670f7a76b30227f006ff3640d714d5b8814f6f7f298R417-R420, similar to getBinaryResponse only response body will take into consideration.

That's why our integration case as below

image

Copy link
Member

Choose a reason for hiding this comment

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

Do you mean returning only the error body is enough for customers? In addition, getBinaryResponse preserves the status:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[migration tsp] A non-model response body type will introduce breakings during tsp migration

5 participants