Skip to content

Fix OpenAPI3 component response conversion to emit reusable response models - #11735

Open
Vincent Biret (baywet) with Copilot wants to merge 7 commits into
mainfrom
copilot/openapi3-convert-component-responses
Open

Fix OpenAPI3 component response conversion to emit reusable response models#11735
Vincent Biret (baywet) with Copilot wants to merge 7 commits into
mainfrom
copilot/openapi3-convert-component-responses

Conversation

Copilot AI commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The OpenAPI3 import path was flattening referenced component responses into ad hoc inline response objects, which duplicated response metadata across operations and prevented reusable response models. This made shared component responses harder to maintain and could produce awkward TypeSpec definitions when the same response was referenced repeatedly.

  • Summary

    • Importing an OpenAPI document now emits reusable TypeSpec models for #/components/responses/... references instead of inlining the response body at each operation.
    • The generated model is namespaced under Responses and preserves the status code, headers, and body schema from the component response.
    • Component response refs are deduplicated so the same shared response resolves to a single model, while still preserving the response metadata for the operation.
  • What changed

    • Added conversion logic to collect component responses during OpenAPI3 import and generate model declarations for them.
    • Routed response refs through the generated model type rather than inline expression generation when the response is a component reference.
    • Kept response headers and body mapping intact, including a preferred application/json payload when multiple content types exist.
    • Added a regression test covering a shared component response referenced from an operation.
  • Example

    namespace Responses {
      model TooManyRequests {
        @statusCode statusCode: 429;
        @header("Retry-After") @minValue(1) RetryAfter?: integer;
        @body body: ErrorResponse;
      }
    }
    
    @route("/endpoint") @get
    op endpoint(): Responses.TooManyRequests;

This keeps component responses reusable and reduces duplication while preserving the semantics of the original OpenAPI description.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
1 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
@microsoft-github-policy-service microsoft-github-policy-service Bot added emitter:openapi3 Issues for @typespec/openapi3 emitter openapi3:converter Issues for @typespec/openapi3 openapi to typespec converter labels Aug 20, 2026
Copilot AI changed the title [WIP] Add model creation for component responses Fix OpenAPI3 component response conversion to emit reusable response models Aug 20, 2026
@baywet

Copy link
Copy Markdown
Member

Copilot add the changelog entry for this fix

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/openapi3@11735

commit: 060c1df

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the OpenAPI 3 importer to treat #/components/responses/* references as first-class reusable TypeSpec models (under a Responses namespace) instead of inlining ad-hoc response objects at each operation.

Changes:

  • Collect referenced component responses during conversion and emit them as reusable Responses.* models.
  • Update response return-type generation to reference those reusable models when an operation response is a component $ref.
  • Add a regression test asserting that a referenced component response produces a Responses namespace/model and is used as the operation return type.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts Adds a regression test for emitting a reusable response model for #/components/responses/* refs.
packages/openapi3/src/cli/actions/convert/transforms/transforms.ts Adds component-response collection and model emission under a Responses namespace.
packages/openapi3/src/cli/actions/convert/generators/generate-types.ts Teaches $ref name generation to scope component response refs under Responses.*.
packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts Routes component response $ref return types to the generated Responses.* models.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +341 to +344
const ref = responseObject.$ref as string;
if (seenResponseRefs.has(ref)) continue;
seenResponseRefs.add(ref);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 66cd2f6. The transform now records the status code each component response model was generated for (Context.registerComponentResponseStatusCode), and the response expression generator only reuses the shared model when the referencing operation response uses that same status code — otherwise the response is generated inline with its own @statusCode. Added a test covering the same $ref under 429 and 503.

Comment on lines +48 to +50
if ("$ref" in props.response && props.response.$ref.startsWith("#/components/responses/")) {
return [context.getRefName(props.response.$ref, props.operationScope)];
}
Comment on lines +407 to +411
function convertStatusCodeToProperty(statusCode: string): TypeSpecModelProperty {
const schema: SupportedOpenAPISchema = { type: "integer", format: "int32" };

if (statusCode === "1XX") {
schema.minimum = 100;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Extracted both helpers (plus StatusCodes and isValidLiteralStatusCode) into a shared utils/response-properties.ts and updated both call sites to use it. 93d84a8

Comment on lines +97 to +101
paths: {
"/endpoint": {
get: {
operationId: "endpoint",
responses: {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a second operation (/other-endpoint) referencing the same #/components/responses/TooManyRequests ref, and asserted both operations return Responses.TooManyRequests and that the model is generated only once. Commit d0f5abf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: the change is in commit 02645c4.

Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 20, 2026 17:31

Copilot AI commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Copilot add the changelog entry for this fix

Added the changelog entry (fix for @typespec/openapi3) in 4716093.

Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (4)

packages/openapi3/src/cli/actions/convert/transforms/transforms.ts:359

  • seenResponseRefs dedupes by component response $ref only, but the emitted model embeds the operation status code (via getResponseProperties(statusCode, ...)). If the same #/components/responses/... is referenced under different status codes (allowed by OpenAPI), the first encountered status code will be baked into the single generated model and subsequent uses will silently get the wrong @statusCode.
      for (const [statusCode, response] of Object.entries(operationResponses)) {
        const responseObject = response as any;
        if (
          !responseObject ||
          typeof responseObject !== "object" ||
          !("$ref" in responseObject) ||
          typeof responseObject.$ref !== "string" ||
          !responseObject.$ref.startsWith("#/components/responses/")
        ) {
          continue;
        }

        const ref = responseObject.$ref as string;
        if (seenResponseRefs.has(ref)) continue;
        seenResponseRefs.add(ref);

        const componentResponse = context.getByRef<OpenAPI3Response>(ref);
        if (!componentResponse) continue;

        const { name, scope } = getScopeAndName(ref.slice("#/components/responses/".length));
        const namespace = [...scope];
        namespace.unshift("Responses");

        dataTypes.push({
          kind: "model",
          name,
          scope: namespace,
          decorators: [],
          doc: componentResponse.description,
          properties: getResponseProperties(statusCode, componentResponse, context),
        });

packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts:50

  • The early return for #/components/responses/... refs bypasses all existing status-code logic in this function, including default handling (GeneratedHelpers.DefaultResponse<...> / @error) and per-media-type expansion (adding a contentType header for non-application/json content). As a result, an operation like responses: { default: { $ref: "#/components/responses/X" } } will no longer produce a default/error response type, and component responses with multiple content types will be flattened to whatever the component model generation chose.
  if ("$ref" in props.response && props.response.$ref.startsWith("#/components/responses/")) {
    return [context.getRefName(props.response.$ref, props.operationScope)];
  }

packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts:157

  • This test covers the basic 429 component response ref case, but it doesn’t exercise edge cases introduced by the new control flow: (1) $ref used under the default response key (should preserve default/error (@error) semantics), and (2) the same component response ref reused across multiple operations/status codes (should not bake in the first-seen status code). Adding coverage for those cases would help prevent regressions.
  it("creates reusable response models for referenced component responses", async () => {
    const tsp = await convertOpenAPI3Document({
      openapi: version,
      info: {
        title: "Example API",
        version: "1.0.0",
      },
      paths: {
        "/endpoint": {
          get: {
            operationId: "endpoint",
            responses: {
              "429": {
                $ref: "#/components/responses/TooManyRequests",
              },
            },
          },
        },
      },
      components: {
        responses: {
          TooManyRequests: {
            description: "The request was rejected because a rate limit was exceeded.",
            headers: {
              "Retry-After": {
                description: "Retry delay seconds.",
                required: false,
                schema: {
                  type: "integer",
                  minimum: 1,
                },
              },
            },
            content: {
              "application/json": {
                schema: {
                  $ref: "#/components/schemas/ErrorResponse",
                },
              },
            },
          },
        },
        schemas: {
          ErrorResponse: {
            type: "object",
            properties: {
              message: {
                type: "string",
                description: "A human-readable message.",
              },
            },
          },
        },
      },
    } as any);

    strictEqual(tsp.includes("namespace Responses"), true, "Expected generated response namespace: " + tsp);
    strictEqual(tsp.includes("model TooManyRequests {"), true, "Expected response model: " + tsp);
    strictEqual(tsp.includes("@statusCode statusCode"), true, "Expected status code property: " + tsp);
    strictEqual(tsp.includes('@header("Retry-After")'), true, "Expected retry-after header: " + tsp);
    strictEqual(tsp.includes("@body body:"), true, "Expected response body property: " + tsp);
    strictEqual(tsp.includes("ErrorResponse"), true, "Expected body schema reference: " + tsp);
    strictEqual(
      tsp.includes("op endpoint(): Responses.TooManyRequests;"),
      true,
      "Expected operation to use the generated response model: " + tsp,
    );
  });

packages/openapi3/src/cli/actions/convert/transforms/transforms.ts:316

  • responses is declared but never used; this adds dead code and can hide future mistakes (e.g. thinking the variable is consulted when it isn’t).
export function transformComponentResponses(context: Context, dataTypes: TypeSpecDataTypes[]): void {
  const responses = context.openApi3Doc.components?.responses;
  if (!responses) return;

Copilot AI review requested due to automatic review settings August 20, 2026 17:41
Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
@azure-sdk-automation

Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

Copilot AI and others added 2 commits August 20, 2026 17:42
Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

packages/openapi3/src/cli/actions/convert/transforms/transforms.ts:322

  • transformComponentResponses declares responses only to null-check it, but never uses the variable afterward. This will trigger unused-local lint/TS checks and can fail CI. Prefer checking the property directly without introducing an unused binding.
  const responses = context.openApi3Doc.components?.responses;
  if (!responses) return;

packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts:201

  • The new regression tests cover reuse/dedupe and the “different status code” fallback, but there’s no coverage for a component response ref used under the default status code. Given default responses have special handling (GeneratedHelpers.DefaultResponse and @error), add a test that a default: { $ref: "#/components/responses/..." } response preserves the default-response emission semantics rather than reusing a Responses.* model.
  it("does not reuse a component response model for a different status code", async () => {
    const tsp = await convertOpenAPI3Document({
      openapi: version,
      info: {
        title: "Example API",
        version: "1.0.0",
      },
      paths: {
        "/endpoint": {
          get: {
            operationId: "endpoint",

Comment on lines +334 to +344
for (const [statusCode, response] of Object.entries(operationResponses)) {
const responseObject = response as any;
if (
!responseObject ||
typeof responseObject !== "object" ||
!("$ref" in responseObject) ||
typeof responseObject.$ref !== "string" ||
!responseObject.$ref.startsWith("#/components/responses/")
) {
continue;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:openapi3 Issues for @typespec/openapi3 emitter openapi3:converter Issues for @typespec/openapi3 openapi to typespec converter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openapi3 convert - make component responses models

3 participants