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 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..51b0b0e5ad2 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 */ @@ -45,6 +41,13 @@ type GenerateReturnTypeForStatusCodeProps = { function generateReturnTypeForStatusCode(props: GenerateReturnTypeForStatusCodeProps): string[] { const { statusCode, context } = props; + if ("$ref" in props.response && props.response.$ref.startsWith("#/components/responses/")) { + const componentResponseName = context.getComponentResponseName(props.response.$ref, statusCode); + if (componentResponseName) { + return [componentResponseName]; + } + } + const response = "$ref" in props.response ? context.getByRef(props.response.$ref) @@ -318,74 +321,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/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..f32b397174a 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -1,15 +1,27 @@ import type { 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 { + 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"; @@ -294,8 +306,129 @@ 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(); + const componentResponseNames = 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; + const responseKey = `${ref}:${statusCode}`; + if (seenResponseRefs.has(responseKey)) continue; + seenResponseRefs.add(responseKey); + + const componentResponse = context.getByRef(ref); + if (!componentResponse) continue; + + const { name, scope } = getScopeAndName(ref.slice("#/components/responses/".length)); + const namespace = [...scope]; + namespace.unshift("Responses"); + const nameSuffix = statusCode === "default" ? "Default" : statusCode; + let componentResponseName = name; + let index = 0; + while (componentResponseNames.has([...namespace, componentResponseName].join("."))) { + componentResponseName = `${name}${nameSuffix}${index || ""}`; + index++; + } + componentResponseNames.add([...namespace, componentResponseName].join(".")); + context.registerComponentResponse( + ref, + statusCode, + [...namespace, componentResponseName].join("."), + ); + + dataTypes.push({ + kind: "model", + name: componentResponseName, + scope: namespace, + decorators: statusCode === "default" ? [{ name: "error", args: [] }] : [], + 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 preferredContent = [ + contentEntries.find(([mediaType]) => mediaType === "application/json"), + contentEntries[0], + ].find((entry): entry is [string, any] => !!entry); + + const bodySchema = + preferredContent && typeof preferredContent[1] === "object" && "schema" in preferredContent[1] + ? (preferredContent[1].schema as Refable) + : undefined; + + if (preferredContent && preferredContent[0] !== "application/json") { + properties.push({ + name: "contentType", + decorators: [{ name: "header", args: ["Content-Type"] }], + isOptional: false, + schema: { type: "string", enum: [preferredContent[0]] }, + }); + } + + if (bodySchema) { + properties.push({ + name: "body", + decorators: [{ name: "body", args: [] }], + isOptional: false, + schema: bodySchema, + }); + } + + return properties; +} diff --git a/packages/openapi3/src/cli/actions/convert/utils/context.ts b/packages/openapi3/src/cli/actions/convert/utils/context.ts index 74c82c5f651..6bfe7167f65 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 a generated component response model. + */ + registerComponentResponse(ref: string, statusCode: string, name: string): void; + + /** + * Get the generated model name for a component response and status code, if any. + */ + getComponentResponseName(ref: string, statusCode: 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 generated component response models by response reference and status code + const componentResponseNames = 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); }, + registerComponentResponse(ref: string, statusCode: string, name: string) { + componentResponseNames.set(`${ref}:${statusCode}`, name); + }, + getComponentResponseName(ref: string, statusCode: string): string | undefined { + return componentResponseNames.get(`${ref}:${statusCode}`); + }, markSSEUsage() { sseUsed = true; }, 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..71d9f62b24f --- /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 LiteralStatusCode = `${number}`; +export type WildcardStatusCode = "1XX" | "2XX" | "3XX" | "4XX" | "5XX"; +export type StatusCodes = LiteralStatusCode | WildcardStatusCode | "default" | (string & {}); + +export function isValidLiteralStatusCode(statusCode: string): statusCode is LiteralStatusCode { + if (statusCode === "default" || statusCode.endsWith("X")) return false; + + const literalStatusCode = parseInt(statusCode, 10); + return isFinite(literalStatusCode) && literalStatusCode >= 100 && literalStatusCode <= 599; +} + +export function convertStatusCodeToProperty(statusCode: string): 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 ?? {}, + }; +} 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..547b12caf4c 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,209 @@ 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", + }, + }, + }, + }, + "/other-endpoint": { + get: { + operationId: "otherEndpoint", + 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, + ); + 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, + ); + }); + + it("creates reusable component response models for all status code kinds", 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: { + "4XX": { + $ref: "#/components/responses/Rejected", + }, + }, + }, + }, + "/default-endpoint": { + get: { + operationId: "defaultEndpoint", + responses: { + default: { + $ref: "#/components/responses/Rejected", + }, + }, + }, + }, + "/unavailable-endpoint": { + get: { + operationId: "unavailableEndpoint", + responses: { + "503": { + $ref: "#/components/responses/Rejected", + }, + }, + }, + }, + }, + components: { + responses: { + Rejected: { + description: "The request was rejected.", + content: { + "application/xml": { + 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.Rejected4XX;"), + true, + "Expected range response to use a generated component response model: " + tsp, + ); + strictEqual( + tsp.includes("op defaultEndpoint(): Responses.RejectedDefault;"), + true, + "Expected default response to use a generated component response model: " + tsp, + ); + strictEqual( + tsp.includes("op unavailableEndpoint(): Responses.Rejected503;"), + true, + "Expected literal response to use a generated component response model: " + tsp, + ); + strictEqual( + /@statusCode\s+@minValue\(400\)\s+@maxValue\(499\)/.test(tsp), + true, + "Expected range model to retain its status code: " + tsp, + ); + strictEqual( + tsp.includes("@error\n model RejectedDefault"), + true, + "Expected default response model to be marked as an error: " + tsp, + ); + strictEqual( + tsp.includes('@header("Content-Type") contentType: "application/xml";'), + true, + "Expected component response models to retain the content type: " + tsp, + ); + }); + describe("Union types with multiple defaults", () => { it("should select first default for union types with multiple defaults", async () => { const tsp = await convertOpenAPI3Document({ diff --git a/packages/openapi3/test/tsp-openapi3/paths.test.ts b/packages/openapi3/test/tsp-openapi3/paths.test.ts index c48e93ddbf4..3afed26ee89 100644 --- a/packages/openapi3/test/tsp-openapi3/paths.test.ts +++ b/packages/openapi3/test/tsp-openapi3/paths.test.ts @@ -1221,7 +1221,15 @@ model Foo { message?: string; } - @route("/") @get op getFoo(): Foo; + @route("/") @get op getFoo(): Responses.TestResponse; + + namespace Responses { + /** test response */ + model TestResponse { + @statusCode statusCode: 200; + @body body: TestService.Foo; + } + } " `); @@ -1292,16 +1300,17 @@ model Foo { message?: string; } - @route("/") @get op getFoo(): GeneratedHelpers.DefaultResponse< - Description = "Overwritten description", - Body = Foo - >; + @route("/") @get op getFoo(): Responses.TestResponse; - @route("/") @head op headFoo(): GeneratedHelpers.DefaultResponse< - Description = "Base description", - Body = Foo - >; + @route("/") @head op headFoo(): Responses.TestResponse; + namespace Responses { + /** Base description */ + @error + model TestResponse { + @body body: TestService.Foo; + } + } namespace GeneratedHelpers { @doc(Description) @error @@ -1384,13 +1393,20 @@ model Foo { message?: string; } - @route("/") @get op getFoo(): { - /** my test header */ - @header("x-test") xTest?: string; + @route("/") @get op getFoo(): Responses.TestResponse; + + namespace Responses { + /** test response */ + model TestResponse { + @statusCode statusCode: 200; + + /** my test header */ + @header("x-test") xTest?: string; - @header("x-test2") xTest2?: string; - @body body: Foo; - }; + @header("x-test2") xTest2?: string; + @body body: TestService.Foo; + } + } " `);