Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down Expand Up @@ -45,6 +41,14 @@ type GenerateReturnTypeForStatusCodeProps = {
function generateReturnTypeForStatusCode(props: GenerateReturnTypeForStatusCodeProps): string[] {
const { statusCode, context } = props;

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)];
}

const response =
"$ref" in props.response
? context.getByRef<OpenAPI3Response>(props.response.$ref)
Expand Down Expand Up @@ -318,74 +322,6 @@ function generateDefaultResponse({
return `GeneratedHelpers.DefaultResponse<${description}${headers}${body}>`;
}

function convertStatusCodeToProperty(
statusCode: Exclude<StatusCodes, "default">,
): 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<OpenAPI3Header>;
context: Context;
};
function convertHeaderToProperty(
props: ConvertHeaderToPropertyProps,
): TypeSpecModelProperty | undefined {
const { name, context } = props;
const header =
"$ref" in props.header ? context.getByRef<OpenAPI3Header>(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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export class SchemaToExpressionGenerator {
case "parameters":
scopeAndName.scope.unshift("Parameters");
break;
case "responses":
scopeAndName.scope.unshift("Responses");
break;
}

return scopeAndName;
Expand Down
118 changes: 117 additions & 1 deletion packages/openapi3/src/cli/actions/convert/transforms/transforms.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -294,8 +306,112 @@ 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<string>();

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<string, any> | 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;
}
Comment on lines +334 to +344

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

Comment on lines +346 to +349

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.

const componentResponse = context.getByRef<OpenAPI3Response>(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");

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<SupportedOpenAPISchema>)
: undefined;

if (bodySchema) {
properties.push({
name: "body",
decorators: [{ name: "body", args: [] }],
isOptional: false,
schema: bodySchema,
});
}

return properties;
}
19 changes: 19 additions & 0 deletions packages/openapi3/src/cli/actions/convert/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -102,6 +112,9 @@ export function createContext(
// Track schemas that are used as error response bodies
const errorResponseSchemas = new Set<string>();

// Track the status code each generated component response model was created for
const componentResponseStatusCodes = new Map<string, string>();

// Track if SSE features are used
let sseUsed = false;

Expand Down Expand Up @@ -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;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StatusCodes, "default">,
): 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<OpenAPI3Header>;
context: Context;
};

export function convertHeaderToProperty(
props: ConvertHeaderToPropertyProps,
): TypeSpecModelProperty | undefined {
const { name, context } = props;
const header =
"$ref" in props.header ? context.getByRef<OpenAPI3Header>(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 ?? {},
};
}
Loading