diff --git a/.changeset/add-itemschema-support.md b/.changeset/add-itemschema-support.md new file mode 100644 index 000000000..f3c96fcb6 --- /dev/null +++ b/.changeset/add-itemschema-support.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +Add OpenAPI 3.2 `itemSchema` support for SSE and streaming responses diff --git a/packages/openapi-typescript/src/transform/media-type-object.ts b/packages/openapi-typescript/src/transform/media-type-object.ts index 647febbb0..cab1f5074 100644 --- a/packages/openapi-typescript/src/transform/media-type-object.ts +++ b/packages/openapi-typescript/src/transform/media-type-object.ts @@ -6,13 +6,19 @@ import transformSchemaObject from "./schema-object.js"; /** * Transform MediaTypeObject nodes (4.8.14) * @see https://spec.openapis.org/oas/v3.1.0#media-type-object + * + * OpenAPI 3.2 adds `itemSchema` for sequential/streaming media types (e.g. text/event-stream). + * Both `schema` (complete payload) and `itemSchema` (per-item) can coexist on the same object. + * For type generation we prefer `itemSchema` when present, as it describes the shape consumers + * will actually parse per event/chunk. */ export default function transformMediaTypeObject( mediaTypeObject: MediaTypeObject, options: TransformNodeOptions, ): ts.TypeNode { - if (!mediaTypeObject.schema) { + const targetSchema = mediaTypeObject.itemSchema ?? mediaTypeObject.schema; + if (!targetSchema) { return UNKNOWN; } - return transformSchemaObject(mediaTypeObject.schema, options); + return transformSchemaObject(targetSchema, options); } diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index d19185cc6..606d60bb3 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -286,6 +286,8 @@ export interface RequestBodyObject extends Extensable { export interface MediaTypeObject extends Extensable { /** The schema defining the content of the request, response, or parameter. */ schema?: SchemaObject | ReferenceObject; + /** OAS 3.2: The schema defining the content of individual items in a streaming response (e.g. text/event-stream). When present, takes precedence over schema for type generation. */ + itemSchema?: SchemaObject | ReferenceObject; /** Example of the media type. The example object SHOULD be in the correct format as specified by the media type. The example field is mutually exclusive of the examples field. Furthermore, if referencing a schema which contains an example, the example value SHALL override the example provided by the schema. */ example?: any; /** Examples of the media type. Each example object SHOULD match the media type and specified schema if present. The examples field is mutually exclusive of the example field. Furthermore, if referencing a schema which contains an example, the examples value SHALL override the example provided by the schema. */ diff --git a/packages/openapi-typescript/test/fixtures/sse-stream-test.yaml b/packages/openapi-typescript/test/fixtures/sse-stream-test.yaml new file mode 100644 index 000000000..7d2a0dc0d --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/sse-stream-test.yaml @@ -0,0 +1,101 @@ +# itemSchema is an OpenAPI 3.2 feature, but we use 3.1 here because +# @redocly/openapi-core v1 does not support 3.2 validation yet. +openapi: "3.1" +info: + title: SSE Streaming API + version: "1.0" +paths: + /events: + get: + operationId: streamEvents + summary: Stream server-sent events + responses: + "200": + description: SSE event stream + content: + text/event-stream: + itemSchema: + type: object + properties: + event: + type: string + enum: + - message + - heartbeat + - error + data: + type: string + id: + type: integer + timestamp: + type: string + format: date-time + required: + - event + - data + /chat: + post: + operationId: chatStream + summary: Chat with streaming response + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + model: + type: string + required: + - message + responses: + "200": + description: Streaming chat response + content: + text/event-stream: + itemSchema: + oneOf: + - type: object + properties: + type: + type: string + enum: + - content + text: + type: string + required: + - type + - text + - type: object + properties: + type: + type: string + enum: + - done + usage: + type: object + properties: + input_tokens: + type: integer + output_tokens: + type: integer + required: + - input_tokens + - output_tokens + required: + - type + - usage +components: + schemas: + SSEEvent: + type: object + properties: + event: + type: string + data: + type: string + required: + - event + - data diff --git a/packages/openapi-typescript/test/index.test.ts b/packages/openapi-typescript/test/index.test.ts index ea8f55426..0c334b915 100644 --- a/packages/openapi-typescript/test/index.test.ts +++ b/packages/openapi-typescript/test/index.test.ts @@ -1111,6 +1111,224 @@ export type $defs = Record; export type operations = Record;`, }, ], + [ + "SSE > itemSchema with $ref to component schema", + { + given: { + openapi: "3.1", + info: { title: "SSE Ref Test", version: "1.0" }, + paths: { + "/notifications": { + get: { + responses: { + 200: { + description: "Notification stream", + content: { + "text/event-stream": { + itemSchema: { + $ref: "#/components/schemas/Notification", + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Notification: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + read: { type: "boolean" }, + }, + required: ["id", "title"], + }, + }, + }, + }, + want: `export interface paths { + "/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Notification stream */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["Notification"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Notification: { + id: string; + title: string; + read?: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + }, + ], + [ + "OpenAPI 3.2 > SSE streaming with itemSchema", + { + given: new URL("./fixtures/sse-stream-test.yaml", import.meta.url), + want: `export interface paths { + "/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stream server-sent events */ + get: operations["streamEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Chat with streaming response */ + post: operations["chatStream"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + SSEEvent: { + event: string; + data: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + streamEvents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SSE event stream */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": { + /** @enum {string} */ + event: "message" | "heartbeat" | "error"; + data: string; + id?: number; + /** Format: date-time */ + timestamp?: string; + }; + }; + }; + }; + }; + chatStream: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + message: string; + model?: string; + }; + }; + }; + responses: { + /** @description Streaming chat response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": { + /** @enum {string} */ + type: "content"; + text: string; + } | { + /** @enum {string} */ + type: "done"; + usage: { + input_tokens: number; + output_tokens: number; + }; + }; + }; + }; + }; + }; +}`, + }, + ], ]; for (const [testName, { given, want, options, ci }] of tests) { diff --git a/packages/openapi-typescript/test/transform/media-type-object.test.ts b/packages/openapi-typescript/test/transform/media-type-object.test.ts new file mode 100644 index 000000000..84bd4f1e4 --- /dev/null +++ b/packages/openapi-typescript/test/transform/media-type-object.test.ts @@ -0,0 +1,100 @@ +import { astToString } from "../../src/lib/ts.js"; +import transformMediaTypeObject from "../../src/transform/media-type-object.js"; +import { DEFAULT_CTX, type TestCase } from "../test-helpers.js"; + +const DEFAULT_OPTIONS = { + path: "#/paths/~1get-item/responses/200/content/application~1json", + ctx: { ...DEFAULT_CTX }, +}; + +describe("transformMediaTypeObject", () => { + const tests: TestCase[] = [ + [ + "schema only", + { + given: { + schema: { + type: "object", + properties: { + id: { type: "string" }, + }, + required: ["id"], + }, + }, + want: `{ + id: string; +}`, + }, + ], + [ + "itemSchema only", + { + given: { + itemSchema: { + type: "object", + properties: { + event: { type: "string" }, + data: { type: "number" }, + }, + required: ["event"], + }, + }, + want: `{ + event: string; + data?: number; +}`, + }, + ], + [ + "itemSchema takes precedence over schema", + { + given: { + schema: { type: "string" }, + itemSchema: { + type: "object", + properties: { + id: { type: "integer" }, + message: { type: "string" }, + }, + required: ["id", "message"], + }, + }, + want: `{ + id: number; + message: string; +}`, + }, + ], + [ + "neither schema nor itemSchema returns unknown", + { + given: {}, + want: "unknown", + }, + ], + [ + "schema with no itemSchema still works (backward compat)", + { + given: { + schema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + }, + want: `{ + name: string; +}`, + }, + ], + ]; + + for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { + test.skipIf(ci?.skipIf)(testName, async () => { + const result = astToString(transformMediaTypeObject(given, options)); + expect(result).toBe(`${want}\n`); + }); + } +}); diff --git a/packages/openapi-typescript/test/transform/request-body-object.test.ts b/packages/openapi-typescript/test/transform/request-body-object.test.ts index 64fbb676f..3caf10fb7 100644 --- a/packages/openapi-typescript/test/transform/request-body-object.test.ts +++ b/packages/openapi-typescript/test/transform/request-body-object.test.ts @@ -155,6 +155,59 @@ describe("transformRequestBodyObject", () => { }, }, ], + [ + "itemSchema in request body (streaming upload)", + { + given: { + content: { + "application/x-ndjson": { + schema: { type: "string" }, + itemSchema: { + type: "object", + properties: { + action: { type: "string" }, + payload: { type: "object" }, + }, + required: ["action"], + }, + }, + }, + }, + want: `{ + content: { + "application/x-ndjson": { + action: string; + payload?: Record; + }; + }; +}`, + }, + ], + [ + "itemSchema only in request body", + { + given: { + content: { + "text/event-stream": { + itemSchema: { + type: "object", + properties: { + message: { type: "string" }, + }, + required: ["message"], + }, + }, + }, + }, + want: `{ + content: { + "text/event-stream": { + message: string; + }; + }; +}`, + }, + ], ]; for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { diff --git a/packages/openapi-typescript/test/transform/response-object.test.ts b/packages/openapi-typescript/test/transform/response-object.test.ts index dbb0a3ecb..39d07e34f 100644 --- a/packages/openapi-typescript/test/transform/response-object.test.ts +++ b/packages/openapi-typescript/test/transform/response-object.test.ts @@ -79,6 +79,113 @@ describe("transformResponseObject", () => { [name: string]: unknown; }; content?: never; +}`, + }, + ], + [ + "text/event-stream with itemSchema", + { + given: { + description: "SSE stream", + content: { + "text/event-stream": { + schema: { type: "string" }, + itemSchema: { + type: "object", + properties: { + event: { type: "string" }, + data: { type: "string" }, + }, + required: ["event", "data"], + }, + }, + }, + }, + want: `{ + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": { + event: string; + data: string; + }; + }; +}`, + }, + ], + [ + "itemSchema without schema", + { + given: { + description: "SSE stream with only itemSchema", + content: { + "text/event-stream": { + itemSchema: { + type: "object", + properties: { + id: { type: "string" }, + payload: { type: "number" }, + }, + required: ["id"], + }, + }, + }, + }, + want: `{ + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": { + id: string; + payload?: number; + }; + }; +}`, + }, + ], + [ + "mixed content types: SSE with itemSchema and JSON with schema", + { + given: { + description: "Mixed response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + results: { type: "array", items: { type: "string" } }, + }, + required: ["results"], + }, + }, + "text/event-stream": { + schema: { type: "string" }, + itemSchema: { + type: "object", + properties: { + event: { type: "string" }, + data: { type: "string" }, + }, + required: ["event", "data"], + }, + }, + }, + }, + want: `{ + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + results: string[]; + }; + "text/event-stream": { + event: string; + data: string; + }; + }; }`, }, ],