From 8236e77d059962c1c39640e0c37cb448a3720837 Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:17:26 +0800 Subject: [PATCH] feat: describe a streamed response's items with itemSchema A media type takes either schema or itemSchema, OpenAPI 3.2's schema for each item of a sequential type such as text/event-stream. Api accepts openapi 3.2.0, and the operation, path and content objects are typed against oas32, which 3.1 documents also satisfy. Schema stays on oas31. Co-Authored-By: LLM --- .../__snapshots__/openapi-schema.test.ts.snap | 75 +++++++++++++++++++ __tests__/fixtures/apis/event-stream.ts | 40 ++++++++++ __tests__/openapi-schema.test.ts | 20 +++++ lib/api.ts | 8 +- lib/media-type.ts | 28 ++++--- lib/operation.ts | 4 +- lib/path.ts | 4 +- lib/request-body.ts | 4 +- lib/response.ts | 4 +- 9 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 __tests__/fixtures/apis/event-stream.ts diff --git a/__tests__/__snapshots__/openapi-schema.test.ts.snap b/__tests__/__snapshots__/openapi-schema.test.ts.snap index 752660f..76c90a3 100644 --- a/__tests__/__snapshots__/openapi-schema.test.ts.snap +++ b/__tests__/__snapshots__/openapi-schema.test.ts.snap @@ -1,5 +1,80 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Event Stream > OpenAPI 1`] = ` +{ + "components": { + "headers": {}, + "parameters": {}, + "schemas": { + "Tick": { + "additionalProperties": false, + "properties": { + "count": { + "type": "integer", + }, + }, + "required": [ + "count", + ], + "type": "object", + }, + "TickEvent": { + "additionalProperties": false, + "properties": { + "data": { + "contentMediaType": "application/json", + "contentSchema": { + "$ref": "#/components/schemas/Tick", + }, + "type": "string", + }, + "event": { + "const": "tick", + }, + "id": { + "type": "string", + }, + }, + "required": [ + "event", + "data", + ], + "type": "object", + }, + }, + "securitySchemes": {}, + }, + "info": { + "title": "Event Stream API", + "version": "1.0.0", + }, + "openapi": "3.2.0", + "paths": { + "/ticks": { + "get": { + "operationId": "streamTicksCommand", + "responses": { + "200": { + "content": { + "text/event-stream": { + "itemSchema": { + "$ref": "#/components/schemas/TickEvent", + }, + }, + }, + "description": "One tick per second", + }, + }, + "tags": [], + }, + }, + }, + "security": [], + "servers": [], + "tags": [], +} +`; + exports[`Example > OpenAPI 1`] = ` { "components": { diff --git a/__tests__/fixtures/apis/event-stream.ts b/__tests__/fixtures/apis/event-stream.ts new file mode 100644 index 0000000..d6827ac --- /dev/null +++ b/__tests__/fixtures/apis/event-stream.ts @@ -0,0 +1,40 @@ +import { Api, Path, Response, Schema } from "@block65/openapi-constructs"; + +export const eventStreamApi = new Api({ + openapi: "3.2.0", + info: { title: "Event Stream API", version: "1.0.0" }, +}); + +const tick = new Schema(eventStreamApi, "Tick", { + schema: { + type: "object", + properties: { count: { type: "integer" } }, + required: ["count"], + }, +}); + +const tickEvent = new Schema(eventStreamApi, "TickEvent", { + schema: { + type: "object", + properties: { + event: { const: "tick" }, + data: { + type: "string", + contentMediaType: "application/json", + contentSchema: tick.referenceObject(), + }, + id: { type: "string" }, + }, + required: ["event", "data"], + }, +}); + +new Path(eventStreamApi, { path: "/ticks" }).addOperation("get", { + operationId: "streamTicksCommand", + responses: { + 200: new Response(eventStreamApi, "StreamTicksResponse", { + description: "One tick per second", + content: { contentType: "text/event-stream", itemSchema: tickEvent }, + }), + }, +}); diff --git a/__tests__/openapi-schema.test.ts b/__tests__/openapi-schema.test.ts index 5dcdbee..f111e74 100644 --- a/__tests__/openapi-schema.test.ts +++ b/__tests__/openapi-schema.test.ts @@ -1,5 +1,7 @@ import { validate } from "@hyperjump/json-schema/openapi-3-1"; +import { validate as validate32 } from "@hyperjump/json-schema/openapi-3-2"; import { describe, expect, test } from "vitest"; +import { eventStreamApi } from "./fixtures/apis/event-stream.ts"; import { exampleApi } from "./fixtures/apis/example.ts"; import { noteTakingApi } from "./fixtures/apis/note-taking.ts"; import { toJson } from "./json.ts"; @@ -33,3 +35,21 @@ describe.each([ expect(output.valid).toBe(false); }); }); + +describe("Event Stream", () => { + const document = eventStreamApi.synth(); + + test("OpenAPI", () => { + expect(document).toMatchSnapshot(); + }); + + test("OpenAPI 3.2 schema validate", async () => { + const output = await validate32( + "https://spec.openapis.org/oas/3.2/schema-base", + toJson(document), + "BASIC", + ); + + expect(output).toStrictEqual({ valid: true }); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index 8c12545..012f12a 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,5 +1,5 @@ import type { JSONSchema7 } from "json-schema"; -import type { oas31 } from "openapi3-ts"; +import type { oas32 } from "openapi3-ts"; import { ApiLowLevel } from "./api-low-level.ts"; import { Header } from "./header.ts"; import { Parameter } from "./parameter.ts"; @@ -11,11 +11,11 @@ import { SecurityScheme } from "./security-scheme.ts"; import { Server } from "./server.ts"; import { Tag } from "./tag.ts"; -export type OpenApiVersion = "3.0" | "3.1.0"; +export type OpenApiVersion = "3.0" | "3.1.0" | "3.2.0"; export type ApiOptions = { openapi: OpenApiVersion; - info: oas31.InfoObject; + info: oas32.InfoObject; }; export class Api extends ApiLowLevel { @@ -76,7 +76,7 @@ export class Api extends ApiLowLevel { child instanceof SecurityRequirement, ) .map((child) => child.synth()), - } satisfies oas31.OpenAPIObject; + } satisfies oas32.OpenAPIObject; } public synthJsonSchema() { diff --git a/lib/media-type.ts b/lib/media-type.ts index ed21847..32cb975 100644 --- a/lib/media-type.ts +++ b/lib/media-type.ts @@ -1,5 +1,4 @@ import { Construct } from "constructs"; -import type { oas31 } from "openapi3-ts"; import { Reference } from "./reference.ts"; import type { Schema } from "./schema.ts"; @@ -12,10 +11,18 @@ type ContentType = | "image/*" | (string & {}); -export type MediaTypeOptions = { - contentType: ContentType; - schema: Schema | Reference; -}; +export type MediaTypeOptions = { contentType: ContentType } & ( + | { schema: Schema | Reference } + + // OpenAPI 3.2 describes each item of a sequential type, such as an event + | { itemSchema: Schema | Reference } +); + +function refOf(schema: Schema | Reference) { + return schema instanceof Reference + ? schema.synth() + : schema.referenceObject(); +} export class MediaType extends Construct { private options: MediaTypeOptions; @@ -29,12 +36,9 @@ export class MediaType extends Construct { this.options = options; } - public synth(): oas31.MediaTypeObject { - return { - schema: - this.options.schema instanceof Reference - ? this.options.schema.synth() - : this.options.schema.referenceObject(), - }; + public synth() { + return "itemSchema" in this.options + ? { itemSchema: refOf(this.options.itemSchema) } + : { schema: refOf(this.options.schema) }; } } diff --git a/lib/operation.ts b/lib/operation.ts index c1b1c23..1177782 100644 --- a/lib/operation.ts +++ b/lib/operation.ts @@ -1,5 +1,5 @@ import { Construct } from "constructs"; -import type { oas31 } from "openapi3-ts"; +import type { oas32 } from "openapi3-ts"; import type { HttpMethod } from "./http-method.ts"; import { RequestBody, type RequestBodyOptions } from "./request-body.ts"; import type { Response } from "./response.ts"; @@ -99,6 +99,6 @@ export class Operation extends Construct { ]), ), }), - } satisfies oas31.OperationObject; + } satisfies oas32.OperationObject; } } diff --git a/lib/path.ts b/lib/path.ts index 7bbd331..7b49b6f 100644 --- a/lib/path.ts +++ b/lib/path.ts @@ -1,5 +1,5 @@ import { Construct } from "constructs"; -import type { oas31 } from "openapi3-ts"; +import type { oas32 } from "openapi3-ts"; import type { Api } from "./api.ts"; import type { HttpMethod } from "./http-method.ts"; import { Operation, type OperationOptions } from "./operation.ts"; @@ -59,6 +59,6 @@ export class Path extends Construct { ), }), ...(this.options.summary && { summary: this.options.summary }), - } satisfies oas31.PathItemObject; + } satisfies oas32.PathItemObject; } } diff --git a/lib/request-body.ts b/lib/request-body.ts index f15f833..cd78fb6 100644 --- a/lib/request-body.ts +++ b/lib/request-body.ts @@ -1,5 +1,5 @@ import { Construct } from "constructs"; -import type { oas31 } from "openapi3-ts"; +import type { oas32 } from "openapi3-ts"; import { MediaType, type MediaTypeOptions } from "./media-type.ts"; export type RequestBodyOptions = { @@ -31,7 +31,7 @@ export class RequestBody extends Construct { ); } - public synth(): oas31.RequestBodyObject { + public synth(): oas32.RequestBodyObject { return { description: this.options.description || "", content: Object.fromEntries( diff --git a/lib/response.ts b/lib/response.ts index 0dd91b3..22042e8 100644 --- a/lib/response.ts +++ b/lib/response.ts @@ -1,5 +1,5 @@ import { Construct } from "constructs"; -import type { oas31 } from "openapi3-ts"; +import type { oas32 } from "openapi3-ts"; import type { Header } from "./header.ts"; import { MediaType, type MediaTypeOptions } from "./media-type.ts"; @@ -42,6 +42,6 @@ export class Response extends Construct { ]), ), }), - } satisfies oas31.ResponseObject; + } satisfies oas32.ResponseObject; } }