@schmock/openapi 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crud-detector.d.ts +35 -0
- package/dist/crud-detector.d.ts.map +1 -0
- package/dist/crud-detector.js +153 -0
- package/dist/generators.d.ts +14 -0
- package/dist/generators.d.ts.map +1 -0
- package/dist/generators.js +158 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +221 -0
- package/dist/normalizer.d.ts +14 -0
- package/dist/normalizer.d.ts.map +1 -0
- package/dist/normalizer.js +194 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +282 -0
- package/dist/plugin.d.ts +32 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +129 -0
- package/dist/seed.d.ts +15 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +41 -0
- package/package.json +45 -0
- package/src/__fixtures__/faker-stress-test.openapi.yaml +1030 -0
- package/src/__fixtures__/openapi31.json +34 -0
- package/src/__fixtures__/petstore-openapi3.json +168 -0
- package/src/__fixtures__/petstore-swagger2.json +141 -0
- package/src/__fixtures__/scalar-galaxy.yaml +1314 -0
- package/src/__fixtures__/stripe-fixtures3.json +6542 -0
- package/src/__fixtures__/stripe-spec3.yaml +161621 -0
- package/src/__fixtures__/train-travel.yaml +1264 -0
- package/src/crud-detector.test.ts +150 -0
- package/src/crud-detector.ts +194 -0
- package/src/generators.test.ts +214 -0
- package/src/generators.ts +212 -0
- package/src/index.ts +4 -0
- package/src/normalizer.test.ts +253 -0
- package/src/normalizer.ts +233 -0
- package/src/parser.test.ts +181 -0
- package/src/parser.ts +389 -0
- package/src/plugin.test.ts +205 -0
- package/src/plugin.ts +185 -0
- package/src/seed.ts +62 -0
- package/src/steps/openapi-crud.steps.ts +132 -0
- package/src/steps/openapi-parsing.steps.ts +111 -0
- package/src/steps/openapi-seed.steps.ts +94 -0
- package/src/stress.test.ts +2814 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { parseSpec } from "./parser";
|
|
4
|
+
|
|
5
|
+
const fixturesDir = resolve(import.meta.dirname, "__fixtures__");
|
|
6
|
+
|
|
7
|
+
describe("parseSpec", () => {
|
|
8
|
+
describe("Swagger 2.0", () => {
|
|
9
|
+
it("parses Swagger 2.0 Petstore spec", async () => {
|
|
10
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
11
|
+
|
|
12
|
+
expect(spec.title).toBe("Petstore");
|
|
13
|
+
expect(spec.version).toBe("1.0.0");
|
|
14
|
+
expect(spec.basePath).toBe("/api");
|
|
15
|
+
expect(spec.paths.length).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("extracts path parameters from Swagger 2.0", async () => {
|
|
19
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
20
|
+
const getPet = spec.paths.find(
|
|
21
|
+
(p) => p.method === "GET" && p.path === "/pets/:petId",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(getPet).toBeDefined();
|
|
25
|
+
expect(getPet?.parameters).toContainEqual(
|
|
26
|
+
expect.objectContaining({
|
|
27
|
+
name: "petId",
|
|
28
|
+
in: "path",
|
|
29
|
+
required: true,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("extracts query parameters", async () => {
|
|
35
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
36
|
+
const listPets = spec.paths.find(
|
|
37
|
+
(p) => p.method === "GET" && p.path === "/pets",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(listPets).toBeDefined();
|
|
41
|
+
expect(listPets?.parameters).toContainEqual(
|
|
42
|
+
expect.objectContaining({
|
|
43
|
+
name: "limit",
|
|
44
|
+
in: "query",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("extracts request body from Swagger 2.0 body parameter", async () => {
|
|
50
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
51
|
+
const createPet = spec.paths.find(
|
|
52
|
+
(p) => p.method === "POST" && p.path === "/pets",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(createPet).toBeDefined();
|
|
56
|
+
expect(createPet?.requestBody).toBeDefined();
|
|
57
|
+
expect(createPet?.requestBody?.type).toBe("object");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("extracts response schemas with multiple status codes", async () => {
|
|
61
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
62
|
+
const listPets = spec.paths.find(
|
|
63
|
+
(p) => p.method === "GET" && p.path === "/pets",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(listPets).toBeDefined();
|
|
67
|
+
expect(listPets?.responses.has(200)).toBe(true);
|
|
68
|
+
expect(listPets?.responses.get(200)?.schema?.type).toBe("array");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("converts {param} to :param in paths", async () => {
|
|
72
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
73
|
+
const getPet = spec.paths.find(
|
|
74
|
+
(p) => p.method === "GET" && p.path === "/pets/:petId",
|
|
75
|
+
);
|
|
76
|
+
expect(getPet).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("OpenAPI 3.0", () => {
|
|
81
|
+
it("parses OpenAPI 3.0 spec", async () => {
|
|
82
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
|
|
83
|
+
|
|
84
|
+
expect(spec.title).toBe("Petstore");
|
|
85
|
+
expect(spec.version).toBe("2.0.0");
|
|
86
|
+
expect(spec.basePath).toBe("/v2");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("extracts request body from OpenAPI 3.x requestBody", async () => {
|
|
90
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
|
|
91
|
+
const createPet = spec.paths.find(
|
|
92
|
+
(p) => p.method === "POST" && p.path === "/pets",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(createPet).toBeDefined();
|
|
96
|
+
expect(createPet?.requestBody).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("resolves $ref pointers via dereference", async () => {
|
|
100
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
|
|
101
|
+
const getPet = spec.paths.find(
|
|
102
|
+
(p) => p.method === "GET" && p.path === "/pets/:petId",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Should be fully resolved, no $ref
|
|
106
|
+
expect(getPet?.responses.get(200)?.schema).toBeDefined();
|
|
107
|
+
expect(getPet?.responses.get(200)?.schema).not.toHaveProperty("$ref");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("merges path-level and operation-level parameters", async () => {
|
|
111
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
|
|
112
|
+
const getPet = spec.paths.find(
|
|
113
|
+
(p) => p.method === "GET" && p.path === "/pets/:petId",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// petId is defined at path level, should be merged into operation
|
|
117
|
+
expect(getPet?.parameters).toContainEqual(
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
name: "petId",
|
|
120
|
+
in: "path",
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("extracts tags", async () => {
|
|
126
|
+
const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
|
|
127
|
+
const listPets = spec.paths.find(
|
|
128
|
+
(p) => p.method === "GET" && p.path === "/pets",
|
|
129
|
+
);
|
|
130
|
+
expect(listPets?.tags).toContain("pets");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("OpenAPI 3.1", () => {
|
|
135
|
+
it("parses OpenAPI 3.1 spec", async () => {
|
|
136
|
+
const spec = await parseSpec(`${fixturesDir}/openapi31.json`);
|
|
137
|
+
|
|
138
|
+
expect(spec.title).toBe("Simple API");
|
|
139
|
+
expect(spec.version).toBe("3.1.0");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("inline spec objects", () => {
|
|
144
|
+
it("accepts inline spec object", async () => {
|
|
145
|
+
const spec = await parseSpec({
|
|
146
|
+
openapi: "3.0.3",
|
|
147
|
+
info: { title: "Inline", version: "1.0.0" },
|
|
148
|
+
paths: {
|
|
149
|
+
"/hello": {
|
|
150
|
+
get: {
|
|
151
|
+
responses: {
|
|
152
|
+
"200": {
|
|
153
|
+
description: "Hello",
|
|
154
|
+
content: {
|
|
155
|
+
"application/json": {
|
|
156
|
+
schema: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: { msg: { type: "string" } },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(spec.title).toBe("Inline");
|
|
170
|
+
expect(spec.paths).toHaveLength(1);
|
|
171
|
+
expect(spec.paths[0].method).toBe("GET");
|
|
172
|
+
expect(spec.paths[0].path).toBe("/hello");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("error handling", () => {
|
|
177
|
+
it("throws on invalid spec", async () => {
|
|
178
|
+
await expect(parseSpec({ invalid: true })).rejects.toThrow();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
import SwaggerParser from "@apidevtools/swagger-parser";
|
|
4
|
+
import { toHttpMethod } from "@schmock/core";
|
|
5
|
+
import type { JSONSchema7 } from "json-schema";
|
|
6
|
+
import type { OpenAPI } from "openapi-types";
|
|
7
|
+
import { normalizeSchema } from "./normalizer.js";
|
|
8
|
+
|
|
9
|
+
export interface ParsedSpec {
|
|
10
|
+
title: string;
|
|
11
|
+
version: string;
|
|
12
|
+
basePath: string;
|
|
13
|
+
paths: ParsedPath[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ParsedPath {
|
|
17
|
+
/** Express-style path e.g. "/pets/:petId" */
|
|
18
|
+
path: string;
|
|
19
|
+
method: Schmock.HttpMethod;
|
|
20
|
+
operationId?: string;
|
|
21
|
+
parameters: ParsedParameter[];
|
|
22
|
+
requestBody?: JSONSchema7;
|
|
23
|
+
responses: Map<number, { schema?: JSONSchema7; description: string }>;
|
|
24
|
+
tags: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ParsedParameter {
|
|
28
|
+
name: string;
|
|
29
|
+
in: "path" | "query" | "header";
|
|
30
|
+
required: boolean;
|
|
31
|
+
schema?: JSONSchema7;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HTTP_METHOD_KEYS = new Set([
|
|
35
|
+
"get",
|
|
36
|
+
"post",
|
|
37
|
+
"put",
|
|
38
|
+
"delete",
|
|
39
|
+
"patch",
|
|
40
|
+
"head",
|
|
41
|
+
"options",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isOpenApiDocument(value: unknown): value is OpenAPI.Document {
|
|
49
|
+
return isRecord(value) && ("swagger" in value || "openapi" in value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getString(value: unknown): string | undefined {
|
|
53
|
+
return typeof value === "string" ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getBoolean(value: unknown, fallback: boolean): boolean {
|
|
57
|
+
return typeof value === "boolean" ? value : fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Strip root-level x-* extensions from a spec object.
|
|
62
|
+
* These may contain $ref to external docs (e.g. markdown files)
|
|
63
|
+
* that swagger-parser cannot resolve.
|
|
64
|
+
*/
|
|
65
|
+
function stripRootExtensions(spec: object): void {
|
|
66
|
+
for (const key of Object.keys(spec)) {
|
|
67
|
+
if (key.startsWith("x-")) {
|
|
68
|
+
Reflect.deleteProperty(spec, key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ensure a paths key exists on a spec object (required by swagger-parser validation).
|
|
75
|
+
*/
|
|
76
|
+
function ensurePathsKey(spec: object): void {
|
|
77
|
+
if (!("paths" in spec)) {
|
|
78
|
+
Object.assign(spec, { paths: {} });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse an OpenAPI/Swagger spec into a normalized internal model.
|
|
84
|
+
* Supports Swagger 2.0, OpenAPI 3.0, and 3.1.
|
|
85
|
+
*/
|
|
86
|
+
export async function parseSpec(source: string | object): Promise<ParsedSpec> {
|
|
87
|
+
let api: OpenAPI.Document;
|
|
88
|
+
if (typeof source === "string") {
|
|
89
|
+
// Parse raw YAML/JSON first (no ref resolution)
|
|
90
|
+
const raw = await SwaggerParser.parse(source);
|
|
91
|
+
stripRootExtensions(raw);
|
|
92
|
+
ensurePathsKey(raw);
|
|
93
|
+
api = await SwaggerParser.dereference(raw);
|
|
94
|
+
} else if (isOpenApiDocument(source)) {
|
|
95
|
+
const copy = structuredClone(source);
|
|
96
|
+
stripRootExtensions(copy);
|
|
97
|
+
ensurePathsKey(copy);
|
|
98
|
+
api = await SwaggerParser.dereference(copy);
|
|
99
|
+
} else {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"Invalid OpenAPI spec: must be a string path or an OpenAPI document object",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isSwagger2 = "swagger" in api && typeof api.swagger === "string";
|
|
106
|
+
const title = api.info?.title ?? "Untitled";
|
|
107
|
+
const version = api.info?.version ?? "0.0.0";
|
|
108
|
+
|
|
109
|
+
let basePath = "";
|
|
110
|
+
if (isSwagger2 && "basePath" in api) {
|
|
111
|
+
const bp = api.basePath;
|
|
112
|
+
basePath = typeof bp === "string" ? bp : "";
|
|
113
|
+
} else if (
|
|
114
|
+
"servers" in api &&
|
|
115
|
+
Array.isArray(api.servers) &&
|
|
116
|
+
api.servers.length > 0
|
|
117
|
+
) {
|
|
118
|
+
const firstServer = api.servers[0];
|
|
119
|
+
if (isRecord(firstServer) && typeof firstServer.url === "string") {
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(firstServer.url, "http://localhost");
|
|
122
|
+
basePath = url.pathname === "/" ? "" : url.pathname;
|
|
123
|
+
} catch {
|
|
124
|
+
basePath = "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Strip trailing slash from basePath
|
|
129
|
+
if (basePath.endsWith("/") && basePath !== "/") {
|
|
130
|
+
basePath = basePath.slice(0, -1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const paths: ParsedPath[] = [];
|
|
134
|
+
const rawPaths =
|
|
135
|
+
"paths" in api && isRecord(api.paths) ? api.paths : undefined;
|
|
136
|
+
|
|
137
|
+
if (!rawPaths) {
|
|
138
|
+
return { title, version, basePath, paths };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const [pathTemplate, pathItemRaw] of Object.entries(rawPaths)) {
|
|
142
|
+
if (!isRecord(pathItemRaw)) continue;
|
|
143
|
+
const pathItem = pathItemRaw;
|
|
144
|
+
|
|
145
|
+
// Extract path-level parameters
|
|
146
|
+
const pathLevelParams = extractParameters(
|
|
147
|
+
Array.isArray(pathItem.parameters) ? pathItem.parameters : undefined,
|
|
148
|
+
isSwagger2,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
for (const methodKey of Object.keys(pathItem)) {
|
|
152
|
+
if (!HTTP_METHOD_KEYS.has(methodKey)) continue;
|
|
153
|
+
|
|
154
|
+
const operation = pathItem[methodKey];
|
|
155
|
+
if (!isRecord(operation)) continue;
|
|
156
|
+
|
|
157
|
+
const method = toHttpMethod(methodKey.toUpperCase());
|
|
158
|
+
|
|
159
|
+
// Merge path-level + operation-level parameters (operation wins)
|
|
160
|
+
const operationParams = extractParameters(
|
|
161
|
+
Array.isArray(operation.parameters) ? operation.parameters : undefined,
|
|
162
|
+
isSwagger2,
|
|
163
|
+
);
|
|
164
|
+
const mergedParams = mergeParameters(pathLevelParams, operationParams);
|
|
165
|
+
|
|
166
|
+
// Extract request body
|
|
167
|
+
let requestBody: JSONSchema7 | undefined;
|
|
168
|
+
if (isSwagger2) {
|
|
169
|
+
requestBody = extractSwagger2RequestBody(mergedParams);
|
|
170
|
+
} else {
|
|
171
|
+
requestBody = extractOpenApi3RequestBody(
|
|
172
|
+
isRecord(operation.requestBody) ? operation.requestBody : undefined,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Extract responses
|
|
177
|
+
const responses = extractResponses(
|
|
178
|
+
isRecord(operation.responses) ? operation.responses : undefined,
|
|
179
|
+
isSwagger2,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Convert path template: {petId} -> :petId
|
|
183
|
+
const expressPath = convertPathTemplate(pathTemplate);
|
|
184
|
+
|
|
185
|
+
const tags = Array.isArray(operation.tags)
|
|
186
|
+
? operation.tags.filter((t): t is string => typeof t === "string")
|
|
187
|
+
: [];
|
|
188
|
+
|
|
189
|
+
// Filter out body parameters from the final parameter list (Swagger 2.0)
|
|
190
|
+
const filteredParams = mergedParams.filter(isNotBodyParam);
|
|
191
|
+
|
|
192
|
+
paths.push({
|
|
193
|
+
path: expressPath,
|
|
194
|
+
method,
|
|
195
|
+
operationId: getString(operation.operationId),
|
|
196
|
+
parameters: filteredParams,
|
|
197
|
+
requestBody,
|
|
198
|
+
responses,
|
|
199
|
+
tags,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { title, version, basePath, paths };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface InternalParameter {
|
|
208
|
+
name: string;
|
|
209
|
+
in: "path" | "query" | "header" | "body";
|
|
210
|
+
required: boolean;
|
|
211
|
+
schema?: JSONSchema7;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isValidParamLocation(
|
|
215
|
+
location: string,
|
|
216
|
+
isSwagger2: boolean,
|
|
217
|
+
): location is "path" | "query" | "header" | "body" {
|
|
218
|
+
const validLocations = isSwagger2
|
|
219
|
+
? ["path", "query", "header", "body"]
|
|
220
|
+
: ["path", "query", "header"];
|
|
221
|
+
return validLocations.includes(location);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isNotBodyParam(param: InternalParameter): param is ParsedParameter {
|
|
225
|
+
return param.in !== "body";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function extractParameters(
|
|
229
|
+
params: unknown[] | undefined,
|
|
230
|
+
isSwagger2: boolean,
|
|
231
|
+
): InternalParameter[] {
|
|
232
|
+
if (!params || !Array.isArray(params)) return [];
|
|
233
|
+
|
|
234
|
+
return params
|
|
235
|
+
.filter((p): p is Record<string, unknown> => isRecord(p))
|
|
236
|
+
.map((p): InternalParameter | null => {
|
|
237
|
+
const location = getString(p.in);
|
|
238
|
+
if (!location || !isValidParamLocation(location, isSwagger2)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let schema: JSONSchema7 | undefined;
|
|
243
|
+
if (isSwagger2) {
|
|
244
|
+
// Swagger 2.0: schema is inline on the parameter (type, format, etc.)
|
|
245
|
+
if (location === "body") {
|
|
246
|
+
schema = isRecord(p.schema)
|
|
247
|
+
? normalizeSchema(p.schema, "request")
|
|
248
|
+
: undefined;
|
|
249
|
+
} else {
|
|
250
|
+
schema = p.type
|
|
251
|
+
? normalizeSchema(
|
|
252
|
+
{ type: p.type, format: p.format, enum: p.enum },
|
|
253
|
+
"request",
|
|
254
|
+
)
|
|
255
|
+
: undefined;
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
// OpenAPI 3.x: schema is nested
|
|
259
|
+
schema = isRecord(p.schema)
|
|
260
|
+
? normalizeSchema(p.schema, "request")
|
|
261
|
+
: undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const name = getString(p.name);
|
|
265
|
+
if (!name) return null;
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
name,
|
|
269
|
+
in: location,
|
|
270
|
+
required: getBoolean(p.required, false),
|
|
271
|
+
schema,
|
|
272
|
+
};
|
|
273
|
+
})
|
|
274
|
+
.filter((p): p is InternalParameter => p !== null);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function mergeParameters(
|
|
278
|
+
pathLevel: InternalParameter[],
|
|
279
|
+
operationLevel: InternalParameter[],
|
|
280
|
+
): InternalParameter[] {
|
|
281
|
+
const merged = new Map<string, InternalParameter>();
|
|
282
|
+
|
|
283
|
+
// Path-level first
|
|
284
|
+
for (const p of pathLevel) {
|
|
285
|
+
merged.set(`${p.in}:${p.name}`, p);
|
|
286
|
+
}
|
|
287
|
+
// Operation-level overwrites
|
|
288
|
+
for (const p of operationLevel) {
|
|
289
|
+
merged.set(`${p.in}:${p.name}`, p);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return [...merged.values()];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function extractSwagger2RequestBody(
|
|
296
|
+
params: InternalParameter[],
|
|
297
|
+
): JSONSchema7 | undefined {
|
|
298
|
+
const bodyParam = params.find((p) => p.in === "body");
|
|
299
|
+
return bodyParam?.schema;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function extractOpenApi3RequestBody(
|
|
303
|
+
requestBody: Record<string, unknown> | undefined,
|
|
304
|
+
): JSONSchema7 | undefined {
|
|
305
|
+
if (!requestBody) return undefined;
|
|
306
|
+
|
|
307
|
+
const content = isRecord(requestBody.content)
|
|
308
|
+
? requestBody.content
|
|
309
|
+
: undefined;
|
|
310
|
+
if (!content) return undefined;
|
|
311
|
+
|
|
312
|
+
const jsonEntry = findJsonContent(content);
|
|
313
|
+
if (!jsonEntry) return undefined;
|
|
314
|
+
|
|
315
|
+
const schema = isRecord(jsonEntry.schema) ? jsonEntry.schema : undefined;
|
|
316
|
+
if (!schema) return undefined;
|
|
317
|
+
|
|
318
|
+
return normalizeSchema(schema, "request");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function extractResponses(
|
|
322
|
+
responses: Record<string, unknown> | undefined,
|
|
323
|
+
isSwagger2: boolean,
|
|
324
|
+
): Map<number, { schema?: JSONSchema7; description: string }> {
|
|
325
|
+
const result = new Map<
|
|
326
|
+
number,
|
|
327
|
+
{ schema?: JSONSchema7; description: string }
|
|
328
|
+
>();
|
|
329
|
+
|
|
330
|
+
if (!responses) return result;
|
|
331
|
+
|
|
332
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
333
|
+
if (statusCode === "default") continue;
|
|
334
|
+
if (!isRecord(response)) continue;
|
|
335
|
+
|
|
336
|
+
const code = Number.parseInt(statusCode, 10);
|
|
337
|
+
if (Number.isNaN(code)) continue;
|
|
338
|
+
|
|
339
|
+
const description = getString(response.description) ?? "";
|
|
340
|
+
|
|
341
|
+
let schema: JSONSchema7 | undefined;
|
|
342
|
+
if (isSwagger2) {
|
|
343
|
+
// Swagger 2.0: schema is directly on the response
|
|
344
|
+
if (isRecord(response.schema)) {
|
|
345
|
+
schema = normalizeSchema(response.schema, "response");
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
// OpenAPI 3.x: schema is nested in content
|
|
349
|
+
const content = isRecord(response.content) ? response.content : undefined;
|
|
350
|
+
if (content) {
|
|
351
|
+
const jsonEntry = findJsonContent(content);
|
|
352
|
+
if (jsonEntry && isRecord(jsonEntry.schema)) {
|
|
353
|
+
schema = normalizeSchema(jsonEntry.schema, "response");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
result.set(code, { schema, description });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Find the best JSON-like content type entry from an OpenAPI content map.
|
|
366
|
+
* Prefers application/json, then any *+json or *json* type.
|
|
367
|
+
*/
|
|
368
|
+
function findJsonContent(
|
|
369
|
+
content: Record<string, unknown>,
|
|
370
|
+
): Record<string, unknown> | undefined {
|
|
371
|
+
// Prefer exact application/json
|
|
372
|
+
if (isRecord(content["application/json"])) {
|
|
373
|
+
return content["application/json"];
|
|
374
|
+
}
|
|
375
|
+
// Try any JSON-like content type (application/problem+json, etc.)
|
|
376
|
+
for (const [type, value] of Object.entries(content)) {
|
|
377
|
+
if (type.includes("json") && isRecord(value)) {
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Fallback to first content type
|
|
382
|
+
return Object.values(content).find((v): v is Record<string, unknown> =>
|
|
383
|
+
isRecord(v),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function convertPathTemplate(path: string): string {
|
|
388
|
+
return path.replace(/\{([^}]+)\}/g, ":$1");
|
|
389
|
+
}
|