From 5efaf7651eac152691a5db5d6c2c3df34d89f2b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:54 +0000 Subject: [PATCH 1/7] Initial plan From 060c1dff7a8f2bc4e6328b103b8801fe43d89b7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:02:59 +0000 Subject: [PATCH 2/7] Fix OpenAPI3 conversion for component response models Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../generate-response-expressions.ts | 4 + .../convert/generators/generate-types.ts | 3 + .../actions/convert/transforms/transforms.ts | 161 +++++++++++++++++- .../tsp-openapi3/convert-openapi3-doc.test.ts | 69 ++++++++ 4 files changed, 236 insertions(+), 1 deletion(-) diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts index d2287a82bc1..64c1a119504 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts @@ -45,6 +45,10 @@ type GenerateReturnTypeForStatusCodeProps = { function generateReturnTypeForStatusCode(props: GenerateReturnTypeForStatusCodeProps): string[] { const { statusCode, context } = props; + if ("$ref" in props.response && props.response.$ref.startsWith("#/components/responses/")) { + return [context.getRefName(props.response.$ref, props.operationScope)]; + } + const response = "$ref" in props.response ? context.getByRef(props.response.$ref) diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts index df205efec73..85f5a22209b 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts @@ -98,6 +98,9 @@ export class SchemaToExpressionGenerator { case "parameters": scopeAndName.scope.unshift("Parameters"); break; + case "responses": + scopeAndName.scope.unshift("Responses"); + break; } return scopeAndName; diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts index 3ccac332d13..fa6a609d089 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -1,15 +1,25 @@ import type { + OpenAPI3Header, OpenAPI3PathItem, OpenAPI3RequestBody, + OpenAPI3Response, OpenAPI3Responses, OpenAPIPathItem3_2, OpenAPIRequestBody3_2, OpenAPIResponses3_2, Refable, SupportedOpenAPIDocuments, + SupportedOpenAPISchema, } from "../../../../types.js"; -import type { TypeSpecModel, TypeSpecProgram } from "../interfaces.js"; +import type { + TypeSpecDataTypes, + TypeSpecModel, + TypeSpecModelProperty, + TypeSpecProgram, +} from "../interfaces.js"; import type { Context } from "../utils/context.js"; +import { getScopeAndName } from "../utils/get-scope-and-name.js"; +import { convertHeaderName } from "../utils/convert-header-name.js"; import { transformComponentParameters } from "./transform-component-parameters.js"; import { transformComponentSchemas } from "./transform-component-schemas.js"; import { transformNamespaces } from "./transform-namespaces.js"; @@ -294,8 +304,157 @@ function collectDataTypes(context: Context): TypeSpecModel[] { const models: TypeSpecModel[] = []; // get models from `#/components/schema transformComponentSchemas(context, models); + transformComponentResponses(context, models); // get models from `#/components/parameters transformComponentParameters(context, models); return models; } + +export function transformComponentResponses(context: Context, dataTypes: TypeSpecDataTypes[]): void { + const responses = context.openApi3Doc.components?.responses; + if (!responses) return; + + const seenResponseRefs = new Set(); + + for (const path of Object.values(context.openApi3Doc.paths ?? {})) { + if (!path) continue; + for (const method of methods) { + const operation = path[method]; + if (!operation?.responses) continue; + + const operationResponses = (operation as any).responses as Record | undefined; + if (!operationResponses) continue; + + 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(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), + }); + } + } + } +} + +function getResponseProperties( + statusCode: string, + response: OpenAPI3Response, + context: Context, +): TypeSpecModelProperty[] { + const properties: TypeSpecModelProperty[] = []; + const resolvedStatus = statusCode === "default" ? "default" : statusCode; + + if (resolvedStatus !== "default") { + properties.push(convertStatusCodeToProperty(resolvedStatus)); + } + + for (const [headerName, header] of Object.entries(response.headers ?? {})) { + const property = convertHeaderToProperty({ name: headerName, header, context }); + if (property) { + properties.push(property); + } + } + + const contentEntries = Object.entries(response.content ?? {}); + const preferredBodySchema = [ + contentEntries.find(([mediaType]) => mediaType === "application/json"), + contentEntries[0], + ].find((entry): entry is [string, any] => !!entry)?.[1]; + + const bodySchema = + preferredBodySchema && typeof preferredBodySchema === "object" && "schema" in preferredBodySchema + ? (preferredBodySchema.schema as Refable) + : undefined; + + if (bodySchema) { + properties.push({ + name: "body", + decorators: [{ name: "body", args: [] }], + isOptional: false, + schema: bodySchema, + }); + } + + return properties; +} + +function convertStatusCodeToProperty(statusCode: string): TypeSpecModelProperty { + const schema: SupportedOpenAPISchema = { type: "integer", format: "int32" }; + + if (statusCode === "1XX") { + schema.minimum = 100; + schema.maximum = 199; + } else if (statusCode === "2XX") { + schema.minimum = 200; + schema.maximum = 299; + } else if (statusCode === "3XX") { + schema.minimum = 300; + schema.maximum = 399; + } else if (statusCode === "4XX") { + schema.minimum = 400; + schema.maximum = 499; + } else if (statusCode === "5XX") { + schema.minimum = 500; + schema.maximum = 599; + } else if (/^[1-5][0-9]{2}$/.test(statusCode)) { + schema.enum = [Number.parseInt(statusCode, 10)]; + } + + return { + name: "statusCode", + schema, + decorators: [{ name: "statusCode", args: [] }], + isOptional: false, + }; +} + +function convertHeaderToProperty(props: { + name: string; + header: Refable; + context: Context; +}): TypeSpecModelProperty | undefined { + const { name, context } = props; + const header = "$ref" in props.header ? context.getByRef(props.header.$ref) : props.header; + + if (!header) return undefined; + + const normalizedName = convertHeaderName(name); + const decorator = { name: "header", args: [] as (string | number | object)[] }; + if (normalizedName !== name) { + decorator.args.push(name); + } + + return { + name: normalizedName, + decorators: [decorator], + doc: header.description, + isOptional: !header.required, + schema: header.schema ?? {}, + }; +} diff --git a/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts b/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts index f4e6dd83380..8ced0eff85f 100644 --- a/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts +++ b/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts @@ -87,6 +87,75 @@ describe.each(versions)("convertOpenAPI3Document v%s", (version) => { ); }); + 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, + ); + }); + describe("Union types with multiple defaults", () => { it("should select first default for union types with multiple defaults", async () => { const tsp = await convertOpenAPI3Document({ From 471609368bf7fe8e2845d6295a7c7115831a4674 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:31:38 +0000 Subject: [PATCH 3/7] Add changelog entry for openapi3 component response conversion fix Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../changes/fix-openapi3-component-responses-2026-8-20.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/fix-openapi3-component-responses-2026-8-20.md diff --git a/.chronus/changes/fix-openapi3-component-responses-2026-8-20.md b/.chronus/changes/fix-openapi3-component-responses-2026-8-20.md new file mode 100644 index 00000000000..33d671040ab --- /dev/null +++ b/.chronus/changes/fix-openapi3-component-responses-2026-8-20.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +[converter] Emit reusable models under a `Responses` namespace for `#/components/responses/...` references instead of inlining the response at each operation From 02645c46ce93f13d9f4738c9e1c0a2594b91dcaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:41:21 +0000 Subject: [PATCH 4/7] test: cover component response dedupe across multiple operations Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../tsp-openapi3/convert-openapi3-doc.test.ts | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts b/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts index 8ced0eff85f..f97b000bcca 100644 --- a/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts +++ b/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts @@ -105,6 +105,16 @@ describe.each(versions)("convertOpenAPI3Document v%s", (version) => { }, }, }, + "/other-endpoint": { + get: { + operationId: "otherEndpoint", + responses: { + "429": { + $ref: "#/components/responses/TooManyRequests", + }, + }, + }, + }, }, components: { responses: { @@ -143,10 +153,22 @@ describe.each(versions)("convertOpenAPI3Document v%s", (version) => { }, } as any); - strictEqual(tsp.includes("namespace Responses"), true, "Expected generated response namespace: " + tsp); + 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("@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( @@ -154,6 +176,16 @@ describe.each(versions)("convertOpenAPI3Document v%s", (version) => { true, "Expected operation to use the generated response model: " + tsp, ); + strictEqual( + tsp.includes("op otherEndpoint(): Responses.TooManyRequests;"), + true, + "Expected second operation to use the generated response model: " + tsp, + ); + strictEqual( + tsp.split("model TooManyRequests {").length - 1, + 1, + "Expected the shared component response model to be generated once: " + tsp, + ); }); describe("Union types with multiple defaults", () => { From 93d84a8389dc0feeb7faa414403d419ddf4f0723 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:41:30 +0000 Subject: [PATCH 5/7] refactor: share status code/header property conversion helpers Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../generate-response-expressions.ts | 88 ++----------------- .../actions/convert/transforms/transforms.ts | 70 +++------------ .../convert/utils/response-properties.ts | 76 ++++++++++++++++ 3 files changed, 95 insertions(+), 139 deletions(-) create mode 100644 packages/openapi3/src/cli/actions/convert/utils/response-properties.ts diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts index 64c1a119504..d97b3fa8e83 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts @@ -1,18 +1,14 @@ -import type { - OpenAPI3Header, - OpenAPI3MediaType, - OpenAPI3Response, - OpenAPI3Schema, - Refable, -} from "../../../../types.js"; -import type { TypeSpecDecorator, TypeSpecModelProperty, TypeSpecOperation } from "../interfaces.js"; +import type { OpenAPI3MediaType, OpenAPI3Response, Refable } from "../../../../types.js"; +import type { TypeSpecModelProperty, TypeSpecOperation } from "../interfaces.js"; import type { Context } from "../utils/context.js"; -import { convertHeaderName } from "../utils/convert-header-name.js"; -import { getDecoratorsForSchema } from "../utils/decorators.js"; +import type { StatusCodes } from "../utils/response-properties.js"; +import { + convertHeaderToProperty, + convertStatusCodeToProperty, + isValidLiteralStatusCode, +} from "../utils/response-properties.js"; import { generateModelExpression } from "./generate-model.js"; -type StatusCodes = string | "1XX" | "2XX" | "3XX" | "4XX" | "5XX" | "default"; - /** * Generates a union expression of all possible responses for an operation */ @@ -322,74 +318,6 @@ function generateDefaultResponse({ return `GeneratedHelpers.DefaultResponse<${description}${headers}${body}>`; } -function convertStatusCodeToProperty( - statusCode: Exclude, -): TypeSpecModelProperty { - const schema: OpenAPI3Schema = { type: "integer", format: "int32" }; - if (statusCode === "1XX") { - schema.minimum = 100; - schema.maximum = 199; - } else if (statusCode === "2XX") { - schema.minimum = 200; - schema.maximum = 299; - } else if (statusCode === "3XX") { - schema.minimum = 300; - schema.maximum = 399; - } else if (statusCode === "4XX") { - schema.minimum = 400; - schema.maximum = 499; - } else if (statusCode === "5XX") { - schema.minimum = 500; - schema.maximum = 599; - } else if (isValidLiteralStatusCode(statusCode)) { - const literalStatusCode = parseInt(statusCode, 10); - schema.enum = [literalStatusCode]; - } - return { - name: "statusCode", - schema, - decorators: [{ name: "statusCode", args: [] }], - isOptional: false, - }; -} - -function isValidLiteralStatusCode(statusCode: StatusCodes): boolean { - if (statusCode === "default" || statusCode.endsWith("X")) return false; - - const literalStatusCode = parseInt(statusCode, 10); - return isFinite(literalStatusCode) && literalStatusCode >= 100 && literalStatusCode <= 599; -} - -type ConvertHeaderToPropertyProps = { - name: string; - header: Refable; - context: Context; -}; -function convertHeaderToProperty( - props: ConvertHeaderToPropertyProps, -): TypeSpecModelProperty | undefined { - const { name, context } = props; - const header = - "$ref" in props.header ? context.getByRef(props.header.$ref) : props.header; - - if (!header) return; - - const normalizedName = convertHeaderName(name); - // TODO: handle style - const headerDecorator: TypeSpecDecorator = { name: "header", args: [] }; - if (normalizedName !== name) { - headerDecorator.args.push(name); - } - - return { - name: normalizedName, - decorators: [headerDecorator, ...(header.schema ? getDecoratorsForSchema(header.schema) : [])], - doc: props.header.description ?? header.description ?? header.schema?.description, - isOptional: !header.required, - schema: header.schema ?? {}, - }; -} - // Map of statusCodes to their Response const statusCodeToResponse = new Map([ [200, "OkResponse"], diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts index fa6a609d089..5b0be9f5568 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -1,5 +1,4 @@ import type { - OpenAPI3Header, OpenAPI3PathItem, OpenAPI3RequestBody, OpenAPI3Response, @@ -19,7 +18,10 @@ import type { } from "../interfaces.js"; import type { Context } from "../utils/context.js"; import { getScopeAndName } from "../utils/get-scope-and-name.js"; -import { convertHeaderName } from "../utils/convert-header-name.js"; +import { + convertHeaderToProperty, + convertStatusCodeToProperty, +} from "../utils/response-properties.js"; import { transformComponentParameters } from "./transform-component-parameters.js"; import { transformComponentSchemas } from "./transform-component-schemas.js"; import { transformNamespaces } from "./transform-namespaces.js"; @@ -311,7 +313,10 @@ function collectDataTypes(context: Context): TypeSpecModel[] { return models; } -export function transformComponentResponses(context: Context, dataTypes: TypeSpecDataTypes[]): void { +export function transformComponentResponses( + context: Context, + dataTypes: TypeSpecDataTypes[], +): void { const responses = context.openApi3Doc.components?.responses; if (!responses) return; @@ -388,7 +393,9 @@ function getResponseProperties( ].find((entry): entry is [string, any] => !!entry)?.[1]; const bodySchema = - preferredBodySchema && typeof preferredBodySchema === "object" && "schema" in preferredBodySchema + preferredBodySchema && + typeof preferredBodySchema === "object" && + "schema" in preferredBodySchema ? (preferredBodySchema.schema as Refable) : undefined; @@ -403,58 +410,3 @@ function getResponseProperties( return properties; } - -function convertStatusCodeToProperty(statusCode: string): TypeSpecModelProperty { - const schema: SupportedOpenAPISchema = { type: "integer", format: "int32" }; - - if (statusCode === "1XX") { - schema.minimum = 100; - schema.maximum = 199; - } else if (statusCode === "2XX") { - schema.minimum = 200; - schema.maximum = 299; - } else if (statusCode === "3XX") { - schema.minimum = 300; - schema.maximum = 399; - } else if (statusCode === "4XX") { - schema.minimum = 400; - schema.maximum = 499; - } else if (statusCode === "5XX") { - schema.minimum = 500; - schema.maximum = 599; - } else if (/^[1-5][0-9]{2}$/.test(statusCode)) { - schema.enum = [Number.parseInt(statusCode, 10)]; - } - - return { - name: "statusCode", - schema, - decorators: [{ name: "statusCode", args: [] }], - isOptional: false, - }; -} - -function convertHeaderToProperty(props: { - name: string; - header: Refable; - context: Context; -}): TypeSpecModelProperty | undefined { - const { name, context } = props; - const header = "$ref" in props.header ? context.getByRef(props.header.$ref) : props.header; - - if (!header) return undefined; - - const normalizedName = convertHeaderName(name); - const decorator = { name: "header", args: [] as (string | number | object)[] }; - if (normalizedName !== name) { - decorator.args.push(name); - } - - return { - name: normalizedName, - decorators: [decorator], - doc: header.description, - isOptional: !header.required, - schema: header.schema ?? {}, - }; -} diff --git a/packages/openapi3/src/cli/actions/convert/utils/response-properties.ts b/packages/openapi3/src/cli/actions/convert/utils/response-properties.ts new file mode 100644 index 00000000000..6d9d2d5f634 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/response-properties.ts @@ -0,0 +1,76 @@ +import type { OpenAPI3Header, OpenAPI3Schema, Refable } from "../../../../types.js"; +import type { TypeSpecDecorator, TypeSpecModelProperty } from "../interfaces.js"; +import type { Context } from "./context.js"; +import { convertHeaderName } from "./convert-header-name.js"; +import { getDecoratorsForSchema } from "./decorators.js"; + +export type StatusCodes = string | "1XX" | "2XX" | "3XX" | "4XX" | "5XX" | "default"; + +export function isValidLiteralStatusCode(statusCode: StatusCodes): boolean { + if (statusCode === "default" || statusCode.endsWith("X")) return false; + + const literalStatusCode = parseInt(statusCode, 10); + return isFinite(literalStatusCode) && literalStatusCode >= 100 && literalStatusCode <= 599; +} + +export function convertStatusCodeToProperty( + statusCode: Exclude, +): TypeSpecModelProperty { + const schema: OpenAPI3Schema = { type: "integer", format: "int32" }; + if (statusCode === "1XX") { + schema.minimum = 100; + schema.maximum = 199; + } else if (statusCode === "2XX") { + schema.minimum = 200; + schema.maximum = 299; + } else if (statusCode === "3XX") { + schema.minimum = 300; + schema.maximum = 399; + } else if (statusCode === "4XX") { + schema.minimum = 400; + schema.maximum = 499; + } else if (statusCode === "5XX") { + schema.minimum = 500; + schema.maximum = 599; + } else if (isValidLiteralStatusCode(statusCode)) { + const literalStatusCode = parseInt(statusCode, 10); + schema.enum = [literalStatusCode]; + } + return { + name: "statusCode", + schema, + decorators: [{ name: "statusCode", args: [] }], + isOptional: false, + }; +} + +export type ConvertHeaderToPropertyProps = { + name: string; + header: Refable; + context: Context; +}; + +export function convertHeaderToProperty( + props: ConvertHeaderToPropertyProps, +): TypeSpecModelProperty | undefined { + const { name, context } = props; + const header = + "$ref" in props.header ? context.getByRef(props.header.$ref) : props.header; + + if (!header) return; + + const normalizedName = convertHeaderName(name); + // TODO: handle style + const headerDecorator: TypeSpecDecorator = { name: "header", args: [] }; + if (normalizedName !== name) { + headerDecorator.args.push(name); + } + + return { + name: normalizedName, + decorators: [headerDecorator, ...(header.schema ? getDecoratorsForSchema(header.schema) : [])], + doc: props.header.description ?? header.description ?? header.schema?.description, + isOptional: !header.required, + schema: header.schema ?? {}, + }; +} From 8cb544b5da6b475c6af1301ee82b8f8c4d893423 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:41:53 +0000 Subject: [PATCH 6/7] Only reuse component response models when status code matches Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../generate-response-expressions.ts | 6 +++++- .../actions/convert/transforms/transforms.ts | 5 +++++ .../src/cli/actions/convert/utils/context.ts | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts index d97b3fa8e83..9f48b6d908c 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts @@ -41,7 +41,11 @@ type GenerateReturnTypeForStatusCodeProps = { function generateReturnTypeForStatusCode(props: GenerateReturnTypeForStatusCodeProps): string[] { const { statusCode, context } = props; - if ("$ref" in props.response && props.response.$ref.startsWith("#/components/responses/")) { + if ( + "$ref" in props.response && + props.response.$ref.startsWith("#/components/responses/") && + context.getComponentResponseStatusCode(props.response.$ref) === statusCode + ) { return [context.getRefName(props.response.$ref, props.operationScope)]; } diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts index 5b0be9f5568..874e8e1191d 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -350,6 +350,11 @@ export function transformComponentResponses( const componentResponse = context.getByRef(ref); if (!componentResponse) continue; + // The generated model bakes in the status code of the operation response it was + // first encountered with. Record it so responses using the same component under a + // different status code can be generated inline instead of reusing this model. + context.registerComponentResponseStatusCode(ref, statusCode); + const { name, scope } = getScopeAndName(ref.slice("#/components/responses/".length)); const namespace = [...scope]; namespace.unshift("Responses"); diff --git a/packages/openapi3/src/cli/actions/convert/utils/context.ts b/packages/openapi3/src/cli/actions/convert/utils/context.ts index 74c82c5f651..bf8da2de30c 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/context.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/context.ts @@ -72,6 +72,16 @@ export interface Context { */ isErrorResponseSchema(ref: string): boolean; + /** + * Register the status code a component response model was generated for. + */ + registerComponentResponseStatusCode(ref: string, statusCode: string): void; + + /** + * Get the status code a component response model was generated for, if any. + */ + getComponentResponseStatusCode(ref: string): string | undefined; + /** * Mark that SSE features are being used, which will trigger including SSE-related imports. */ @@ -102,6 +112,9 @@ export function createContext( // Track schemas that are used as error response bodies const errorResponseSchemas = new Set(); + // Track the status code each generated component response model was created for + const componentResponseStatusCodes = new Map(); + // Track if SSE features are used let sseUsed = false; @@ -205,6 +218,12 @@ export function createContext( isErrorResponseSchema(ref: string): boolean { return errorResponseSchemas.has(ref); }, + registerComponentResponseStatusCode(ref: string, statusCode: string) { + componentResponseStatusCodes.set(ref, statusCode); + }, + getComponentResponseStatusCode(ref: string): string | undefined { + return componentResponseStatusCodes.get(ref); + }, markSSEUsage() { sseUsed = true; }, From b1fe1a856af4864e7d3bbea9f4b4b99401303c79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:44:11 +0000 Subject: [PATCH 7/7] Add test for component response reused under a different status code Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../tsp-openapi3/convert-openapi3-doc.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts b/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts index f97b000bcca..b76c07dc4c5 100644 --- a/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts +++ b/packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts @@ -188,6 +188,68 @@ describe.each(versions)("convertOpenAPI3Document v%s", (version) => { ); }); + 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", + responses: { + "429": { + $ref: "#/components/responses/Rejected", + }, + }, + }, + }, + "/other-endpoint": { + get: { + operationId: "otherEndpoint", + responses: { + "503": { + $ref: "#/components/responses/Rejected", + }, + }, + }, + }, + }, + components: { + responses: { + Rejected: { + description: "The request was rejected.", + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + }, + }, + } as any); + + strictEqual( + tsp.includes("op endpoint(): Responses.Rejected;"), + true, + "Expected first operation to use the generated response model: " + tsp, + ); + strictEqual( + tsp.includes("op otherEndpoint(): Responses.Rejected;"), + false, + "Expected second operation to not reuse the 429 response model: " + tsp, + ); + strictEqual( + tsp.includes("@statusCode statusCode: 503;"), + true, + "Expected second operation to be generated inline with its own status code: " + tsp, + ); + }); + describe("Union types with multiple defaults", () => { it("should select first default for union types with multiple defaults", async () => { const tsp = await convertOpenAPI3Document({