Skip to content

Commit 3cd059c

Browse files
committed
feat: support OpenAPI 3.2 itemSchema (SSE/streaming)
1 parent 3f07d0e commit 3cd059c

28 files changed

Lines changed: 745 additions & 140 deletions

packages/openapi-fetch/test/middleware/schemas/middleware.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
openapi: "3.1"
1+
openapi: "3.1.0"
22
info:
33
title: openapi-fetch
44
version: "1.0"

packages/openapi-typescript/bin/cli.js

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,32 @@ async function main() {
196196
}
197197
const redocly = redocConfigPath
198198
? await loadConfig({ configPath: redocConfigPath })
199-
: await createConfig({}, { extends: ["minimal"] });
199+
: await createConfig({
200+
extends: ["minimal"],
201+
rules: {
202+
struct: "warn",
203+
"no-server-trailing-slash": "warn",
204+
},
205+
});
200206

201207
// handle Redoc APIs
202-
const hasRedoclyApis = Object.keys(redocly?.apis ?? {}).length > 0;
208+
const redoclyApis = redocly?.resolvedConfig?.apis ?? redocly?.apis ?? {};
209+
const hasRedoclyApis = Object.keys(redoclyApis).length > 0;
203210
if (hasRedoclyApis) {
204211
if (input) {
205212
warn("APIs are specified both in Redocly Config and CLI argument. Only using Redocly config.");
206213
}
207214
await Promise.all(
208-
Object.entries(redocly.apis).map(async ([name, api]) => {
215+
Object.entries(redoclyApis).map(async ([name, api]) => {
209216
let configRoot = CWD;
210217

211218
const config = { ...flags, redocly };
212-
if (redocly.configFile) {
219+
const configFile = redocly.configPath ?? redocly.configFile;
220+
if (configFile) {
213221
// note: this will be absolute if --redoc is passed; otherwise, relative
214-
configRoot = path.isAbsolute(redocly.configFile)
215-
? new URL(`file://${redocly.configFile}`)
216-
: new URL(redocly.configFile, `file://${process.cwd()}/`);
222+
configRoot = path.isAbsolute(configFile)
223+
? new URL(`file://${configFile}`)
224+
: new URL(configFile, `file://${process.cwd()}/`);
217225
}
218226
if (!api[REDOC_CONFIG_KEY]?.output) {
219227
errorAndExit(

packages/openapi-typescript/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
},
4444
"scripts": {
4545
"build": "unbuild",
46-
"dev": "tsc -p tsconfig.build.json --watch",
46+
"dev": "unbuild --stub",
4747
"download:schemas": "vite-node ./scripts/download-schemas.ts",
4848
"format": "biome format . --write",
4949
"lint": "pnpm run lint:js && pnpm run lint:ts",
@@ -62,7 +62,7 @@
6262
"typescript": "^5.x"
6363
},
6464
"dependencies": {
65-
"@redocly/openapi-core": "^1.34.6",
65+
"@redocly/openapi-core": "^2.21.1",
6666
"ansi-colors": "^4.1.3",
6767
"change-case": "^5.4.4",
6868
"parse-json": "^8.3.0",

packages/openapi-typescript/src/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ export default async function openapiTS(
5151

5252
const redoc =
5353
options.redocly ??
54-
(await createConfig(
55-
{
56-
rules: {
57-
"operation-operationId-unique": { severity: "error" }, // throw error on duplicate operationIDs
58-
},
54+
(await createConfig({
55+
extends: ["minimal"],
56+
rules: {
57+
"operation-operationId-unique": { severity: "error" }, // throw error on duplicate operationIDs
58+
struct: "warn", // downgrade struct rule to warning to allow incomplete schemas
59+
"no-server-trailing-slash": "warn", // downgrade to warning to handle real-world specs
5960
},
60-
{ extends: ["minimal"] },
61-
));
61+
}));
6262

6363
const schema = await validateAndBundle(source, {
6464
redoc,

packages/openapi-typescript/src/lib/redoc.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
BaseResolver,
66
bundle,
77
type Document,
8+
isPlainObject,
89
lintDocument,
910
makeDocumentFromString,
1011
type NormalizedProblem,
@@ -123,19 +124,17 @@ export async function validateAndBundle(
123124
debug("Parsed schema", "redoc", performance.now() - redocParseT);
124125

125126
// 1. check for OpenAPI 3 or greater
126-
const openapiVersion = Number.parseFloat(document.parsed.openapi);
127-
if (
128-
document.parsed.swagger ||
129-
!document.parsed.openapi ||
130-
Number.isNaN(openapiVersion) ||
131-
openapiVersion < 3 ||
132-
openapiVersion >= 4
133-
) {
134-
if (document.parsed.swagger) {
127+
if (!isPlainObject(document.parsed)) {
128+
throw new Error("Unsupported schema format, expected `openapi: 3.x`");
129+
}
130+
const parsed = document.parsed;
131+
const openapiVersion = Number.parseFloat(String(parsed.openapi ?? ""));
132+
if (parsed.swagger || !parsed.openapi || Number.isNaN(openapiVersion) || openapiVersion < 3 || openapiVersion >= 4) {
133+
if (parsed.swagger) {
135134
throw new Error("Unsupported Swagger version: 2.x. Use OpenAPI 3.x instead.");
136135
}
137-
if (document.parsed.openapi || openapiVersion < 3 || openapiVersion >= 4) {
138-
throw new Error(`Unsupported OpenAPI version: ${document.parsed.openapi}`);
136+
if (parsed.openapi || openapiVersion < 3 || openapiVersion >= 4) {
137+
throw new Error(`Unsupported OpenAPI version: ${parsed.openapi}`);
139138
}
140139
throw new Error("Unsupported schema format, expected `openapi: 3.x`");
141140
}
@@ -144,7 +143,7 @@ export async function validateAndBundle(
144143
const redocLintT = performance.now();
145144
const problems = await lintDocument({
146145
document,
147-
config: options.redoc.styleguide,
146+
config: options.redoc,
148147
externalRefResolver: resolver,
149148
});
150149
_processProblems(problems, options);
@@ -160,5 +159,5 @@ export async function validateAndBundle(
160159
_processProblems(bundled.problems, options);
161160
debug("Bundled schema", "bundle", performance.now() - redocBundleT);
162161

163-
return bundled.bundle.parsed;
162+
return bundled.bundle.parsed as OpenAPI3;
164163
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { unescapePointerFragment } from "@redocly/openapi-core";
2+
3+
/** Parse a $ref string into its URI and JSON Pointer parts */
4+
export function parseRef(ref: string): { uri: string | null; pointer: string[] } {
5+
const hashIndex = ref.indexOf("#");
6+
const uri = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
7+
const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex + 1);
8+
return {
9+
uri: uri || null,
10+
pointer: fragment
11+
.split("/")
12+
.map(unescapePointerFragment)
13+
.filter((s) => s !== ""),
14+
};
15+
}

packages/openapi-typescript/src/lib/ts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { OasRef, Referenced } from "@redocly/openapi-core";
2-
import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
32
import ts, { type LiteralTypeNode, type TypeLiteralNode } from "typescript";
43
import type { ParameterObject } from "../types.js";
4+
import { parseRef } from "./ref-utils.js";
55

66
export const JS_PROPERTY_INDEX_RE = /^[A-Za-z_$][A-Za-z_$0-9]*$/;
77
export const JS_ENUM_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+(.)?/g;

packages/openapi-typescript/src/lib/utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { escapePointer, parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
1+
import { escapePointerFragment } from "@redocly/openapi-core";
22
import c from "ansi-colors";
33
import supportsColor from "supports-color";
44
import ts from "typescript";
@@ -16,6 +16,8 @@ const DEBUG_GROUPS: Record<string, c.StyleFunction | undefined> = {
1616
ts: c.blueBright,
1717
};
1818

19+
import { parseRef } from "./ref-utils.js";
20+
1921
export { c };
2022

2123
/** Given a discriminator object, get the property name */
@@ -55,10 +57,10 @@ export function createRef(parts: (number | string | undefined | null)[]): string
5557
const maybeRef = parseRef(String(part)).pointer;
5658
if (maybeRef.length) {
5759
for (const refPart of maybeRef) {
58-
pointer += `/${escapePointer(refPart)}`;
60+
pointer += `/${escapePointerFragment(refPart)}`;
5961
}
6062
} else {
61-
pointer += `/${escapePointer(part)}`;
63+
pointer += `/${escapePointerFragment(part)}`;
6264
}
6365
}
6466
return pointer;

packages/openapi-typescript/src/transform/header-object.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { escapePointer } from "@redocly/openapi-core/lib/ref-utils.js";
1+
import { escapePointerFragment } from "@redocly/openapi-core";
22
import ts from "typescript";
33
import { addJSDocComment, tsModifiers, tsPropertyIndex, UNKNOWN } from "../lib/ts.js";
44
import { getEntries } from "../lib/utils.js";
@@ -18,7 +18,7 @@ export default function transformHeaderObject(headerObject: HeaderObject, option
1818
if (headerObject.content) {
1919
const type: ts.TypeElement[] = [];
2020
for (const [contentType, mediaTypeObject] of getEntries(headerObject.content ?? {}, options.ctx)) {
21-
const nextPath = `${options.path ?? "#"}/${escapePointer(contentType)}`;
21+
const nextPath = `${options.path ?? "#"}/${escapePointerFragment(contentType)}`;
2222
const mediaType =
2323
"$ref" in mediaTypeObject
2424
? transformSchemaObject(mediaTypeObject, {

packages/openapi-typescript/src/transform/media-type-object.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ export default function transformMediaTypeObject(
1111
mediaTypeObject: MediaTypeObject,
1212
options: TransformNodeOptions,
1313
): ts.TypeNode {
14-
if (!mediaTypeObject.schema) {
14+
const targetSchema = mediaTypeObject.itemSchema ?? mediaTypeObject.schema;
15+
if (!targetSchema) {
1516
return UNKNOWN;
1617
}
17-
return transformSchemaObject(mediaTypeObject.schema, options);
18+
return transformSchemaObject(targetSchema, options);
1819
}

0 commit comments

Comments
 (0)