@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,14 @@
|
|
|
1
|
+
import type { JSONSchema7 } from "json-schema";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize an OpenAPI schema to pure JSON Schema 7 that json-schema-faker understands.
|
|
4
|
+
*
|
|
5
|
+
* Transforms applied:
|
|
6
|
+
* - nullable: true -> oneOf with null type
|
|
7
|
+
* - discriminator -> required + enum on branches
|
|
8
|
+
* - readOnly/writeOnly -> strip based on direction
|
|
9
|
+
* - example -> default (if default not set)
|
|
10
|
+
* - exclusiveMinimum/exclusiveMaximum boolean -> number format
|
|
11
|
+
* - x-* extensions -> stripped
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeSchema(schema: Record<string, unknown>, direction: "request" | "response"): JSONSchema7;
|
|
14
|
+
//# sourceMappingURL=normalizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalizer.d.ts","sourceRoot":"","sources":["../src/normalizer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAa/C;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,SAAS,GAAG,UAAU,GAChC,WAAW,CAEb"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function toJsonSchema(node) {
|
|
6
|
+
// Object.assign merges unknown-keyed properties into JSONSchema7.
|
|
7
|
+
// This is safe because the normalizer has already ensured the shape
|
|
8
|
+
// is valid JSON Schema 7 before calling this function.
|
|
9
|
+
return Object.assign({}, node);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Normalize an OpenAPI schema to pure JSON Schema 7 that json-schema-faker understands.
|
|
13
|
+
*
|
|
14
|
+
* Transforms applied:
|
|
15
|
+
* - nullable: true -> oneOf with null type
|
|
16
|
+
* - discriminator -> required + enum on branches
|
|
17
|
+
* - readOnly/writeOnly -> strip based on direction
|
|
18
|
+
* - example -> default (if default not set)
|
|
19
|
+
* - exclusiveMinimum/exclusiveMaximum boolean -> number format
|
|
20
|
+
* - x-* extensions -> stripped
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeSchema(schema, direction) {
|
|
23
|
+
return normalizeNode(structuredClone(schema), direction, new WeakSet());
|
|
24
|
+
}
|
|
25
|
+
function normalizeNode(node, direction, visited) {
|
|
26
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
27
|
+
return toJsonSchema({});
|
|
28
|
+
}
|
|
29
|
+
// Circular reference detection — break cycles
|
|
30
|
+
if (visited.has(node)) {
|
|
31
|
+
return toJsonSchema({});
|
|
32
|
+
}
|
|
33
|
+
visited.add(node);
|
|
34
|
+
// Strip x-* extensions
|
|
35
|
+
for (const key of Object.keys(node)) {
|
|
36
|
+
if (key.startsWith("x-")) {
|
|
37
|
+
delete node[key];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Handle nullable: true -> oneOf with null
|
|
41
|
+
if (node.nullable === true) {
|
|
42
|
+
delete node.nullable;
|
|
43
|
+
// Only wrap if not already a composition with null
|
|
44
|
+
const copy = { ...node };
|
|
45
|
+
delete copy.nullable;
|
|
46
|
+
return {
|
|
47
|
+
oneOf: [normalizeNode(copy, direction, visited), { type: "null" }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
delete node.nullable;
|
|
51
|
+
// Handle discriminator
|
|
52
|
+
if (node.discriminator && isRecord(node.discriminator)) {
|
|
53
|
+
const disc = node.discriminator;
|
|
54
|
+
const propName = disc.propertyName;
|
|
55
|
+
if (typeof propName === "string" && Array.isArray(node.oneOf)) {
|
|
56
|
+
const mappingRaw = disc.mapping ?? {};
|
|
57
|
+
const mapping = isRecord(mappingRaw) ? mappingRaw : {};
|
|
58
|
+
// mapping keys ARE the discriminator values (e.g. "dog", "cat")
|
|
59
|
+
// mapping values are $ref strings — after dereference they can't be matched to branches
|
|
60
|
+
// Use key order to correspond to oneOf branch order
|
|
61
|
+
const discriminatorValues = Object.keys(mapping);
|
|
62
|
+
node.oneOf = node.oneOf
|
|
63
|
+
.filter((branch) => isRecord(branch))
|
|
64
|
+
.map((branch, index) => {
|
|
65
|
+
const normalized = normalizeNode(branch, direction, visited);
|
|
66
|
+
// Ensure discriminator property is required
|
|
67
|
+
if (isRecord(normalized)) {
|
|
68
|
+
const required = Array.isArray(normalized.required)
|
|
69
|
+
? [...normalized.required]
|
|
70
|
+
: [];
|
|
71
|
+
if (!required.includes(propName)) {
|
|
72
|
+
required.push(propName);
|
|
73
|
+
}
|
|
74
|
+
normalized.required = required;
|
|
75
|
+
// Add enum constraint for the discriminator value
|
|
76
|
+
const mappingValue = discriminatorValues[index];
|
|
77
|
+
if (mappingValue && isRecord(normalized.properties)) {
|
|
78
|
+
const props = normalized.properties;
|
|
79
|
+
const existingRaw = props[propName] ?? {};
|
|
80
|
+
const existing = isRecord(existingRaw) ? existingRaw : {};
|
|
81
|
+
props[propName] = { ...existing, enum: [mappingValue] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return normalized;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
delete node.discriminator;
|
|
88
|
+
}
|
|
89
|
+
// Handle readOnly/writeOnly on properties
|
|
90
|
+
if (isRecord(node.properties)) {
|
|
91
|
+
const props = node.properties;
|
|
92
|
+
const required = Array.isArray(node.required)
|
|
93
|
+
? node.required.filter((r) => typeof r === "string")
|
|
94
|
+
: [];
|
|
95
|
+
const keysToRemove = [];
|
|
96
|
+
for (const [propName, propSchemaRaw] of Object.entries(props)) {
|
|
97
|
+
if (!isRecord(propSchemaRaw))
|
|
98
|
+
continue;
|
|
99
|
+
const propSchema = propSchemaRaw;
|
|
100
|
+
// readOnly fields: remove from request schemas
|
|
101
|
+
if (direction === "request" && propSchema.readOnly === true) {
|
|
102
|
+
keysToRemove.push(propName);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// writeOnly fields: remove from response schemas
|
|
106
|
+
if (direction === "response" && propSchema.writeOnly === true) {
|
|
107
|
+
keysToRemove.push(propName);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// Clean up the flags after handling
|
|
111
|
+
delete propSchema.readOnly;
|
|
112
|
+
delete propSchema.writeOnly;
|
|
113
|
+
// Recurse into property
|
|
114
|
+
props[propName] = normalizeNode(propSchema, direction, visited);
|
|
115
|
+
}
|
|
116
|
+
for (const key of keysToRemove) {
|
|
117
|
+
delete props[key];
|
|
118
|
+
const reqIdx = required.indexOf(key);
|
|
119
|
+
if (reqIdx !== -1) {
|
|
120
|
+
required.splice(reqIdx, 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (required.length > 0) {
|
|
124
|
+
node.required = required;
|
|
125
|
+
}
|
|
126
|
+
else if (keysToRemove.length > 0 && Array.isArray(node.required)) {
|
|
127
|
+
// If we removed all required fields, clean up
|
|
128
|
+
if (required.length === 0) {
|
|
129
|
+
delete node.required;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Handle example -> default
|
|
134
|
+
if ("example" in node && !("default" in node)) {
|
|
135
|
+
node.default = node.example;
|
|
136
|
+
}
|
|
137
|
+
delete node.example;
|
|
138
|
+
// Handle exclusiveMinimum/exclusiveMaximum boolean -> number
|
|
139
|
+
if (node.exclusiveMinimum === true && typeof node.minimum === "number") {
|
|
140
|
+
node.exclusiveMinimum = node.minimum;
|
|
141
|
+
delete node.minimum;
|
|
142
|
+
}
|
|
143
|
+
else if (node.exclusiveMinimum === false) {
|
|
144
|
+
delete node.exclusiveMinimum;
|
|
145
|
+
}
|
|
146
|
+
if (node.exclusiveMaximum === true && typeof node.maximum === "number") {
|
|
147
|
+
node.exclusiveMaximum = node.maximum;
|
|
148
|
+
delete node.maximum;
|
|
149
|
+
}
|
|
150
|
+
else if (node.exclusiveMaximum === false) {
|
|
151
|
+
delete node.exclusiveMaximum;
|
|
152
|
+
}
|
|
153
|
+
// Recurse into items (array schema)
|
|
154
|
+
if (node.items) {
|
|
155
|
+
if (Array.isArray(node.items)) {
|
|
156
|
+
node.items = node.items.map((item) => isRecord(item) ? normalizeNode(item, direction, visited) : item);
|
|
157
|
+
}
|
|
158
|
+
else if (isRecord(node.items)) {
|
|
159
|
+
node.items = normalizeNode(node.items, direction, visited);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Recurse into additionalProperties
|
|
163
|
+
if (isRecord(node.additionalProperties)) {
|
|
164
|
+
node.additionalProperties = normalizeNode(node.additionalProperties, direction, visited);
|
|
165
|
+
}
|
|
166
|
+
// Recurse into composition keywords
|
|
167
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
168
|
+
const keywordValue = node[keyword];
|
|
169
|
+
if (Array.isArray(keywordValue)) {
|
|
170
|
+
node[keyword] = keywordValue.map((branch) => isRecord(branch) ? normalizeNode(branch, direction, visited) : branch);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Recurse into not
|
|
174
|
+
if (isRecord(node.not)) {
|
|
175
|
+
node.not = normalizeNode(node.not, direction, visited);
|
|
176
|
+
}
|
|
177
|
+
// Recurse into conditional
|
|
178
|
+
for (const keyword of ["if", "then", "else"]) {
|
|
179
|
+
const keywordValue = node[keyword];
|
|
180
|
+
if (isRecord(keywordValue)) {
|
|
181
|
+
node[keyword] = normalizeNode(keywordValue, direction, visited);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Recurse into patternProperties
|
|
185
|
+
if (isRecord(node.patternProperties)) {
|
|
186
|
+
const pp = node.patternProperties;
|
|
187
|
+
for (const [pattern, schema] of Object.entries(pp)) {
|
|
188
|
+
if (isRecord(schema)) {
|
|
189
|
+
pp[pattern] = normalizeNode(schema, direction, visited);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return toJsonSchema(node);
|
|
194
|
+
}
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { JSONSchema7 } from "json-schema";
|
|
2
|
+
export interface ParsedSpec {
|
|
3
|
+
title: string;
|
|
4
|
+
version: string;
|
|
5
|
+
basePath: string;
|
|
6
|
+
paths: ParsedPath[];
|
|
7
|
+
}
|
|
8
|
+
export interface ParsedPath {
|
|
9
|
+
/** Express-style path e.g. "/pets/:petId" */
|
|
10
|
+
path: string;
|
|
11
|
+
method: Schmock.HttpMethod;
|
|
12
|
+
operationId?: string;
|
|
13
|
+
parameters: ParsedParameter[];
|
|
14
|
+
requestBody?: JSONSchema7;
|
|
15
|
+
responses: Map<number, {
|
|
16
|
+
schema?: JSONSchema7;
|
|
17
|
+
description: string;
|
|
18
|
+
}>;
|
|
19
|
+
tags: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface ParsedParameter {
|
|
22
|
+
name: string;
|
|
23
|
+
in: "path" | "query" | "header";
|
|
24
|
+
required: boolean;
|
|
25
|
+
schema?: JSONSchema7;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse an OpenAPI/Swagger spec into a normalized internal model.
|
|
29
|
+
* Supports Swagger 2.0, OpenAPI 3.0, and 3.1.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseSpec(source: string | object): Promise<ParsedSpec>;
|
|
32
|
+
//# sourceMappingURL=parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAI/C,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtE,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAkDD;;;GAGG;AACH,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAuH5E"}
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
import SwaggerParser from "@apidevtools/swagger-parser";
|
|
3
|
+
import { toHttpMethod } from "@schmock/core";
|
|
4
|
+
import { normalizeSchema } from "./normalizer.js";
|
|
5
|
+
const HTTP_METHOD_KEYS = new Set([
|
|
6
|
+
"get",
|
|
7
|
+
"post",
|
|
8
|
+
"put",
|
|
9
|
+
"delete",
|
|
10
|
+
"patch",
|
|
11
|
+
"head",
|
|
12
|
+
"options",
|
|
13
|
+
]);
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function isOpenApiDocument(value) {
|
|
18
|
+
return isRecord(value) && ("swagger" in value || "openapi" in value);
|
|
19
|
+
}
|
|
20
|
+
function getString(value) {
|
|
21
|
+
return typeof value === "string" ? value : undefined;
|
|
22
|
+
}
|
|
23
|
+
function getBoolean(value, fallback) {
|
|
24
|
+
return typeof value === "boolean" ? value : fallback;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Strip root-level x-* extensions from a spec object.
|
|
28
|
+
* These may contain $ref to external docs (e.g. markdown files)
|
|
29
|
+
* that swagger-parser cannot resolve.
|
|
30
|
+
*/
|
|
31
|
+
function stripRootExtensions(spec) {
|
|
32
|
+
for (const key of Object.keys(spec)) {
|
|
33
|
+
if (key.startsWith("x-")) {
|
|
34
|
+
Reflect.deleteProperty(spec, key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Ensure a paths key exists on a spec object (required by swagger-parser validation).
|
|
40
|
+
*/
|
|
41
|
+
function ensurePathsKey(spec) {
|
|
42
|
+
if (!("paths" in spec)) {
|
|
43
|
+
Object.assign(spec, { paths: {} });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse an OpenAPI/Swagger spec into a normalized internal model.
|
|
48
|
+
* Supports Swagger 2.0, OpenAPI 3.0, and 3.1.
|
|
49
|
+
*/
|
|
50
|
+
export async function parseSpec(source) {
|
|
51
|
+
let api;
|
|
52
|
+
if (typeof source === "string") {
|
|
53
|
+
// Parse raw YAML/JSON first (no ref resolution)
|
|
54
|
+
const raw = await SwaggerParser.parse(source);
|
|
55
|
+
stripRootExtensions(raw);
|
|
56
|
+
ensurePathsKey(raw);
|
|
57
|
+
api = await SwaggerParser.dereference(raw);
|
|
58
|
+
}
|
|
59
|
+
else if (isOpenApiDocument(source)) {
|
|
60
|
+
const copy = structuredClone(source);
|
|
61
|
+
stripRootExtensions(copy);
|
|
62
|
+
ensurePathsKey(copy);
|
|
63
|
+
api = await SwaggerParser.dereference(copy);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
throw new Error("Invalid OpenAPI spec: must be a string path or an OpenAPI document object");
|
|
67
|
+
}
|
|
68
|
+
const isSwagger2 = "swagger" in api && typeof api.swagger === "string";
|
|
69
|
+
const title = api.info?.title ?? "Untitled";
|
|
70
|
+
const version = api.info?.version ?? "0.0.0";
|
|
71
|
+
let basePath = "";
|
|
72
|
+
if (isSwagger2 && "basePath" in api) {
|
|
73
|
+
const bp = api.basePath;
|
|
74
|
+
basePath = typeof bp === "string" ? bp : "";
|
|
75
|
+
}
|
|
76
|
+
else if ("servers" in api &&
|
|
77
|
+
Array.isArray(api.servers) &&
|
|
78
|
+
api.servers.length > 0) {
|
|
79
|
+
const firstServer = api.servers[0];
|
|
80
|
+
if (isRecord(firstServer) && typeof firstServer.url === "string") {
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(firstServer.url, "http://localhost");
|
|
83
|
+
basePath = url.pathname === "/" ? "" : url.pathname;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
basePath = "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Strip trailing slash from basePath
|
|
91
|
+
if (basePath.endsWith("/") && basePath !== "/") {
|
|
92
|
+
basePath = basePath.slice(0, -1);
|
|
93
|
+
}
|
|
94
|
+
const paths = [];
|
|
95
|
+
const rawPaths = "paths" in api && isRecord(api.paths) ? api.paths : undefined;
|
|
96
|
+
if (!rawPaths) {
|
|
97
|
+
return { title, version, basePath, paths };
|
|
98
|
+
}
|
|
99
|
+
for (const [pathTemplate, pathItemRaw] of Object.entries(rawPaths)) {
|
|
100
|
+
if (!isRecord(pathItemRaw))
|
|
101
|
+
continue;
|
|
102
|
+
const pathItem = pathItemRaw;
|
|
103
|
+
// Extract path-level parameters
|
|
104
|
+
const pathLevelParams = extractParameters(Array.isArray(pathItem.parameters) ? pathItem.parameters : undefined, isSwagger2);
|
|
105
|
+
for (const methodKey of Object.keys(pathItem)) {
|
|
106
|
+
if (!HTTP_METHOD_KEYS.has(methodKey))
|
|
107
|
+
continue;
|
|
108
|
+
const operation = pathItem[methodKey];
|
|
109
|
+
if (!isRecord(operation))
|
|
110
|
+
continue;
|
|
111
|
+
const method = toHttpMethod(methodKey.toUpperCase());
|
|
112
|
+
// Merge path-level + operation-level parameters (operation wins)
|
|
113
|
+
const operationParams = extractParameters(Array.isArray(operation.parameters) ? operation.parameters : undefined, isSwagger2);
|
|
114
|
+
const mergedParams = mergeParameters(pathLevelParams, operationParams);
|
|
115
|
+
// Extract request body
|
|
116
|
+
let requestBody;
|
|
117
|
+
if (isSwagger2) {
|
|
118
|
+
requestBody = extractSwagger2RequestBody(mergedParams);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
requestBody = extractOpenApi3RequestBody(isRecord(operation.requestBody) ? operation.requestBody : undefined);
|
|
122
|
+
}
|
|
123
|
+
// Extract responses
|
|
124
|
+
const responses = extractResponses(isRecord(operation.responses) ? operation.responses : undefined, isSwagger2);
|
|
125
|
+
// Convert path template: {petId} -> :petId
|
|
126
|
+
const expressPath = convertPathTemplate(pathTemplate);
|
|
127
|
+
const tags = Array.isArray(operation.tags)
|
|
128
|
+
? operation.tags.filter((t) => typeof t === "string")
|
|
129
|
+
: [];
|
|
130
|
+
// Filter out body parameters from the final parameter list (Swagger 2.0)
|
|
131
|
+
const filteredParams = mergedParams.filter(isNotBodyParam);
|
|
132
|
+
paths.push({
|
|
133
|
+
path: expressPath,
|
|
134
|
+
method,
|
|
135
|
+
operationId: getString(operation.operationId),
|
|
136
|
+
parameters: filteredParams,
|
|
137
|
+
requestBody,
|
|
138
|
+
responses,
|
|
139
|
+
tags,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { title, version, basePath, paths };
|
|
144
|
+
}
|
|
145
|
+
function isValidParamLocation(location, isSwagger2) {
|
|
146
|
+
const validLocations = isSwagger2
|
|
147
|
+
? ["path", "query", "header", "body"]
|
|
148
|
+
: ["path", "query", "header"];
|
|
149
|
+
return validLocations.includes(location);
|
|
150
|
+
}
|
|
151
|
+
function isNotBodyParam(param) {
|
|
152
|
+
return param.in !== "body";
|
|
153
|
+
}
|
|
154
|
+
function extractParameters(params, isSwagger2) {
|
|
155
|
+
if (!params || !Array.isArray(params))
|
|
156
|
+
return [];
|
|
157
|
+
return params
|
|
158
|
+
.filter((p) => isRecord(p))
|
|
159
|
+
.map((p) => {
|
|
160
|
+
const location = getString(p.in);
|
|
161
|
+
if (!location || !isValidParamLocation(location, isSwagger2)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let schema;
|
|
165
|
+
if (isSwagger2) {
|
|
166
|
+
// Swagger 2.0: schema is inline on the parameter (type, format, etc.)
|
|
167
|
+
if (location === "body") {
|
|
168
|
+
schema = isRecord(p.schema)
|
|
169
|
+
? normalizeSchema(p.schema, "request")
|
|
170
|
+
: undefined;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
schema = p.type
|
|
174
|
+
? normalizeSchema({ type: p.type, format: p.format, enum: p.enum }, "request")
|
|
175
|
+
: undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// OpenAPI 3.x: schema is nested
|
|
180
|
+
schema = isRecord(p.schema)
|
|
181
|
+
? normalizeSchema(p.schema, "request")
|
|
182
|
+
: undefined;
|
|
183
|
+
}
|
|
184
|
+
const name = getString(p.name);
|
|
185
|
+
if (!name)
|
|
186
|
+
return null;
|
|
187
|
+
return {
|
|
188
|
+
name,
|
|
189
|
+
in: location,
|
|
190
|
+
required: getBoolean(p.required, false),
|
|
191
|
+
schema,
|
|
192
|
+
};
|
|
193
|
+
})
|
|
194
|
+
.filter((p) => p !== null);
|
|
195
|
+
}
|
|
196
|
+
function mergeParameters(pathLevel, operationLevel) {
|
|
197
|
+
const merged = new Map();
|
|
198
|
+
// Path-level first
|
|
199
|
+
for (const p of pathLevel) {
|
|
200
|
+
merged.set(`${p.in}:${p.name}`, p);
|
|
201
|
+
}
|
|
202
|
+
// Operation-level overwrites
|
|
203
|
+
for (const p of operationLevel) {
|
|
204
|
+
merged.set(`${p.in}:${p.name}`, p);
|
|
205
|
+
}
|
|
206
|
+
return [...merged.values()];
|
|
207
|
+
}
|
|
208
|
+
function extractSwagger2RequestBody(params) {
|
|
209
|
+
const bodyParam = params.find((p) => p.in === "body");
|
|
210
|
+
return bodyParam?.schema;
|
|
211
|
+
}
|
|
212
|
+
function extractOpenApi3RequestBody(requestBody) {
|
|
213
|
+
if (!requestBody)
|
|
214
|
+
return undefined;
|
|
215
|
+
const content = isRecord(requestBody.content)
|
|
216
|
+
? requestBody.content
|
|
217
|
+
: undefined;
|
|
218
|
+
if (!content)
|
|
219
|
+
return undefined;
|
|
220
|
+
const jsonEntry = findJsonContent(content);
|
|
221
|
+
if (!jsonEntry)
|
|
222
|
+
return undefined;
|
|
223
|
+
const schema = isRecord(jsonEntry.schema) ? jsonEntry.schema : undefined;
|
|
224
|
+
if (!schema)
|
|
225
|
+
return undefined;
|
|
226
|
+
return normalizeSchema(schema, "request");
|
|
227
|
+
}
|
|
228
|
+
function extractResponses(responses, isSwagger2) {
|
|
229
|
+
const result = new Map();
|
|
230
|
+
if (!responses)
|
|
231
|
+
return result;
|
|
232
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
233
|
+
if (statusCode === "default")
|
|
234
|
+
continue;
|
|
235
|
+
if (!isRecord(response))
|
|
236
|
+
continue;
|
|
237
|
+
const code = Number.parseInt(statusCode, 10);
|
|
238
|
+
if (Number.isNaN(code))
|
|
239
|
+
continue;
|
|
240
|
+
const description = getString(response.description) ?? "";
|
|
241
|
+
let schema;
|
|
242
|
+
if (isSwagger2) {
|
|
243
|
+
// Swagger 2.0: schema is directly on the response
|
|
244
|
+
if (isRecord(response.schema)) {
|
|
245
|
+
schema = normalizeSchema(response.schema, "response");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
// OpenAPI 3.x: schema is nested in content
|
|
250
|
+
const content = isRecord(response.content) ? response.content : undefined;
|
|
251
|
+
if (content) {
|
|
252
|
+
const jsonEntry = findJsonContent(content);
|
|
253
|
+
if (jsonEntry && isRecord(jsonEntry.schema)) {
|
|
254
|
+
schema = normalizeSchema(jsonEntry.schema, "response");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
result.set(code, { schema, description });
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Find the best JSON-like content type entry from an OpenAPI content map.
|
|
264
|
+
* Prefers application/json, then any *+json or *json* type.
|
|
265
|
+
*/
|
|
266
|
+
function findJsonContent(content) {
|
|
267
|
+
// Prefer exact application/json
|
|
268
|
+
if (isRecord(content["application/json"])) {
|
|
269
|
+
return content["application/json"];
|
|
270
|
+
}
|
|
271
|
+
// Try any JSON-like content type (application/problem+json, etc.)
|
|
272
|
+
for (const [type, value] of Object.entries(content)) {
|
|
273
|
+
if (type.includes("json") && isRecord(value)) {
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Fallback to first content type
|
|
278
|
+
return Object.values(content).find((v) => isRecord(v));
|
|
279
|
+
}
|
|
280
|
+
function convertPathTemplate(path) {
|
|
281
|
+
return path.replace(/\{([^}]+)\}/g, ":$1");
|
|
282
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SeedConfig, SeedSource } from "./seed.js";
|
|
2
|
+
export type { SeedConfig, SeedSource };
|
|
3
|
+
export interface OpenApiOptions {
|
|
4
|
+
/** File path or inline spec object */
|
|
5
|
+
spec: string | object;
|
|
6
|
+
/** Optional seed data per resource */
|
|
7
|
+
seed?: SeedConfig;
|
|
8
|
+
/** Validate request bodies (default: true) */
|
|
9
|
+
validateRequests?: boolean;
|
|
10
|
+
/** Validate response bodies (default: false) */
|
|
11
|
+
validateResponses?: boolean;
|
|
12
|
+
/** Query features for list endpoints */
|
|
13
|
+
queryFeatures?: {
|
|
14
|
+
pagination?: boolean;
|
|
15
|
+
sorting?: boolean;
|
|
16
|
+
filtering?: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Create an OpenAPI plugin that auto-registers CRUD routes from a spec.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const mock = schmock();
|
|
25
|
+
* mock.pipe(await openapi({
|
|
26
|
+
* spec: "./petstore.yaml",
|
|
27
|
+
* seed: { pets: { count: 10 } },
|
|
28
|
+
* }));
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function openapi(options: OpenApiOptions): Promise<Schmock.Plugin>;
|
|
32
|
+
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAGxD,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;AAMvC,MAAM,WAAW,cAAc;IAC7B,sCAAsC;IACtC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,sCAAsC;IACtC,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,8CAA8C;IAC9C,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,wCAAwC;IACxC,aAAa,CAAC,EAAE;QACd,UAAU,CAAC,EAAE,OAAO,CAAC;QACrB,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,SAAS,CAAC,EAAE,OAAO,CAAC;KACrB,CAAC;CACH;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAwCzB"}
|