@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,212 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
import { generateFromSchema } from "@schmock/schema";
|
|
4
|
+
import type { JSONSchema7 } from "json-schema";
|
|
5
|
+
import type { CrudResource } from "./crud-detector.js";
|
|
6
|
+
import type { ParsedPath } from "./parser.js";
|
|
7
|
+
|
|
8
|
+
const COLLECTION_STATE_PREFIX = "openapi:collections:";
|
|
9
|
+
|
|
10
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toTuple(status: number, body: unknown): [number, unknown] {
|
|
15
|
+
return [status, body];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectionKey(resourceName: string): string {
|
|
19
|
+
return `${COLLECTION_STATE_PREFIX}${resourceName}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getCollection(
|
|
23
|
+
state: Record<string, unknown>,
|
|
24
|
+
resourceName: string,
|
|
25
|
+
): unknown[] {
|
|
26
|
+
const key = collectionKey(resourceName);
|
|
27
|
+
if (!Array.isArray(state[key])) {
|
|
28
|
+
state[key] = [];
|
|
29
|
+
}
|
|
30
|
+
const value = state[key];
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getNextId(
|
|
38
|
+
state: Record<string, unknown>,
|
|
39
|
+
resourceName: string,
|
|
40
|
+
): number {
|
|
41
|
+
const counterKey = `openapi:counter:${resourceName}`;
|
|
42
|
+
const current = state[counterKey];
|
|
43
|
+
const base = typeof current === "number" ? current : 0;
|
|
44
|
+
const next = base + 1;
|
|
45
|
+
state[counterKey] = next;
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createListGenerator(
|
|
50
|
+
resource: CrudResource,
|
|
51
|
+
): Schmock.GeneratorFunction {
|
|
52
|
+
return (ctx: Schmock.RequestContext) => {
|
|
53
|
+
const collection = getCollection(ctx.state, resource.name);
|
|
54
|
+
return [...collection];
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createCreateGenerator(
|
|
59
|
+
resource: CrudResource,
|
|
60
|
+
): Schmock.GeneratorFunction {
|
|
61
|
+
return (ctx: Schmock.RequestContext) => {
|
|
62
|
+
const collection = getCollection(ctx.state, resource.name);
|
|
63
|
+
const id = getNextId(ctx.state, resource.name);
|
|
64
|
+
|
|
65
|
+
let item: Record<string, unknown>;
|
|
66
|
+
if (isRecord(ctx.body)) {
|
|
67
|
+
item = {
|
|
68
|
+
...ctx.body,
|
|
69
|
+
[resource.idParam]: id,
|
|
70
|
+
};
|
|
71
|
+
} else {
|
|
72
|
+
item = { [resource.idParam]: id };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
collection.push(item);
|
|
76
|
+
return toTuple(201, item);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createReadGenerator(
|
|
81
|
+
resource: CrudResource,
|
|
82
|
+
): Schmock.GeneratorFunction {
|
|
83
|
+
return (ctx: Schmock.RequestContext) => {
|
|
84
|
+
const collection = getCollection(ctx.state, resource.name);
|
|
85
|
+
const idValue = ctx.params[resource.idParam];
|
|
86
|
+
const item = findById(collection, resource.idParam, idValue);
|
|
87
|
+
|
|
88
|
+
if (!item) {
|
|
89
|
+
return toTuple(404, { error: "Not found", code: "NOT_FOUND" });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return item;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createUpdateGenerator(
|
|
97
|
+
resource: CrudResource,
|
|
98
|
+
): Schmock.GeneratorFunction {
|
|
99
|
+
return (ctx: Schmock.RequestContext) => {
|
|
100
|
+
const collection = getCollection(ctx.state, resource.name);
|
|
101
|
+
const idValue = ctx.params[resource.idParam];
|
|
102
|
+
const index = findIndexById(collection, resource.idParam, idValue);
|
|
103
|
+
|
|
104
|
+
if (index === -1) {
|
|
105
|
+
return toTuple(404, { error: "Not found", code: "NOT_FOUND" });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const existingRaw = collection[index];
|
|
109
|
+
const existing = isRecord(existingRaw) ? existingRaw : {};
|
|
110
|
+
const updated = {
|
|
111
|
+
...existing,
|
|
112
|
+
...(isRecord(ctx.body) ? ctx.body : {}),
|
|
113
|
+
[resource.idParam]: existing[resource.idParam], // Preserve ID
|
|
114
|
+
};
|
|
115
|
+
collection[index] = updated;
|
|
116
|
+
return updated;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createDeleteGenerator(
|
|
121
|
+
resource: CrudResource,
|
|
122
|
+
): Schmock.GeneratorFunction {
|
|
123
|
+
return (ctx: Schmock.RequestContext) => {
|
|
124
|
+
const collection = getCollection(ctx.state, resource.name);
|
|
125
|
+
const idValue = ctx.params[resource.idParam];
|
|
126
|
+
const index = findIndexById(collection, resource.idParam, idValue);
|
|
127
|
+
|
|
128
|
+
if (index === -1) {
|
|
129
|
+
return toTuple(404, { error: "Not found", code: "NOT_FOUND" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
collection.splice(index, 1);
|
|
133
|
+
return toTuple(204, undefined);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createStaticGenerator(
|
|
138
|
+
parsedPath: ParsedPath,
|
|
139
|
+
): Schmock.GeneratorFunction {
|
|
140
|
+
// Get the success response schema
|
|
141
|
+
let responseSchema: JSONSchema7 | undefined;
|
|
142
|
+
for (const code of [200, 201]) {
|
|
143
|
+
const resp = parsedPath.responses.get(code);
|
|
144
|
+
if (resp?.schema) {
|
|
145
|
+
responseSchema = resp.schema;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!responseSchema) {
|
|
150
|
+
for (const [code, resp] of parsedPath.responses) {
|
|
151
|
+
if (code >= 200 && code < 300 && resp.schema) {
|
|
152
|
+
responseSchema = resp.schema;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
if (responseSchema) {
|
|
160
|
+
try {
|
|
161
|
+
return generateFromSchema({ schema: responseSchema });
|
|
162
|
+
} catch {
|
|
163
|
+
// Complex schemas (deep anyOf, circular refs) may fail generation.
|
|
164
|
+
// Fall back to an empty object rather than crashing.
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return toTuple(200, {});
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Generate seed items for a resource using its schema.
|
|
174
|
+
*/
|
|
175
|
+
export function generateSeedItems(
|
|
176
|
+
schema: JSONSchema7,
|
|
177
|
+
count: number,
|
|
178
|
+
idParam: string,
|
|
179
|
+
): unknown[] {
|
|
180
|
+
const items: unknown[] = [];
|
|
181
|
+
for (let i = 0; i < count; i++) {
|
|
182
|
+
const generated = generateFromSchema({ schema });
|
|
183
|
+
const item: Record<string, unknown> = isRecord(generated)
|
|
184
|
+
? generated
|
|
185
|
+
: { value: generated };
|
|
186
|
+
item[idParam] = i + 1;
|
|
187
|
+
items.push(item);
|
|
188
|
+
}
|
|
189
|
+
return items;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function findById(
|
|
193
|
+
collection: unknown[],
|
|
194
|
+
idParam: string,
|
|
195
|
+
idValue: string,
|
|
196
|
+
): unknown | undefined {
|
|
197
|
+
return collection.find((item) => {
|
|
198
|
+
if (!isRecord(item)) return false;
|
|
199
|
+
return String(item[idParam]) === String(idValue);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findIndexById(
|
|
204
|
+
collection: unknown[],
|
|
205
|
+
idParam: string,
|
|
206
|
+
idValue: string,
|
|
207
|
+
): number {
|
|
208
|
+
return collection.findIndex((item) => {
|
|
209
|
+
if (!isRecord(item)) return false;
|
|
210
|
+
return String(item[idParam]) === String(idValue);
|
|
211
|
+
});
|
|
212
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeSchema } from "./normalizer";
|
|
3
|
+
|
|
4
|
+
describe("normalizeSchema", () => {
|
|
5
|
+
describe("nullable", () => {
|
|
6
|
+
it("converts nullable: true to oneOf with null type", () => {
|
|
7
|
+
const result = normalizeSchema(
|
|
8
|
+
{ type: "string", nullable: true },
|
|
9
|
+
"response",
|
|
10
|
+
);
|
|
11
|
+
expect(result).toEqual({
|
|
12
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("removes nullable: false without adding null type", () => {
|
|
17
|
+
const result = normalizeSchema(
|
|
18
|
+
{ type: "string", nullable: false },
|
|
19
|
+
"response",
|
|
20
|
+
);
|
|
21
|
+
expect(result).toEqual({ type: "string" });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("readOnly / writeOnly", () => {
|
|
26
|
+
it("strips readOnly fields from request schemas", () => {
|
|
27
|
+
const result = normalizeSchema(
|
|
28
|
+
{
|
|
29
|
+
type: "object",
|
|
30
|
+
required: ["id", "name"],
|
|
31
|
+
properties: {
|
|
32
|
+
id: { type: "integer", readOnly: true },
|
|
33
|
+
name: { type: "string" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
"request",
|
|
37
|
+
);
|
|
38
|
+
expect(result.properties).not.toHaveProperty("id");
|
|
39
|
+
expect(result.properties).toHaveProperty("name");
|
|
40
|
+
expect(result.required).toEqual(["name"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps readOnly fields in response schemas", () => {
|
|
44
|
+
const result = normalizeSchema(
|
|
45
|
+
{
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
id: { type: "integer", readOnly: true },
|
|
49
|
+
name: { type: "string" },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"response",
|
|
53
|
+
);
|
|
54
|
+
expect(result.properties).toHaveProperty("id");
|
|
55
|
+
expect(result.properties).toHaveProperty("name");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("strips writeOnly fields from response schemas", () => {
|
|
59
|
+
const result = normalizeSchema(
|
|
60
|
+
{
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
password: { type: "string", writeOnly: true },
|
|
64
|
+
name: { type: "string" },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
"response",
|
|
68
|
+
);
|
|
69
|
+
expect(result.properties).not.toHaveProperty("password");
|
|
70
|
+
expect(result.properties).toHaveProperty("name");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps writeOnly fields in request schemas", () => {
|
|
74
|
+
const result = normalizeSchema(
|
|
75
|
+
{
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
password: { type: "string", writeOnly: true },
|
|
79
|
+
name: { type: "string" },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"request",
|
|
83
|
+
);
|
|
84
|
+
expect(result.properties).toHaveProperty("password");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("example → default", () => {
|
|
89
|
+
it("copies example to default when default is not set", () => {
|
|
90
|
+
const result = normalizeSchema(
|
|
91
|
+
{ type: "string", example: "hello" },
|
|
92
|
+
"response",
|
|
93
|
+
);
|
|
94
|
+
expect(result.default).toBe("hello");
|
|
95
|
+
expect(result).not.toHaveProperty("example");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("preserves existing default when example is present", () => {
|
|
99
|
+
const result = normalizeSchema(
|
|
100
|
+
{ type: "string", example: "hello", default: "world" },
|
|
101
|
+
"response",
|
|
102
|
+
);
|
|
103
|
+
expect(result.default).toBe("world");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("exclusiveMinimum/exclusiveMaximum boolean → number", () => {
|
|
108
|
+
it("converts exclusiveMinimum: true to number format", () => {
|
|
109
|
+
const result = normalizeSchema(
|
|
110
|
+
{ type: "number", minimum: 0, exclusiveMinimum: true },
|
|
111
|
+
"response",
|
|
112
|
+
);
|
|
113
|
+
expect(result.exclusiveMinimum).toBe(0);
|
|
114
|
+
expect(result).not.toHaveProperty("minimum");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("removes exclusiveMinimum: false", () => {
|
|
118
|
+
const result = normalizeSchema(
|
|
119
|
+
{ type: "number", minimum: 0, exclusiveMinimum: false },
|
|
120
|
+
"response",
|
|
121
|
+
);
|
|
122
|
+
expect(result.minimum).toBe(0);
|
|
123
|
+
expect(result).not.toHaveProperty("exclusiveMinimum");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("converts exclusiveMaximum: true to number format", () => {
|
|
127
|
+
const result = normalizeSchema(
|
|
128
|
+
{ type: "number", maximum: 100, exclusiveMaximum: true },
|
|
129
|
+
"response",
|
|
130
|
+
);
|
|
131
|
+
expect(result.exclusiveMaximum).toBe(100);
|
|
132
|
+
expect(result).not.toHaveProperty("maximum");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("x-* extensions", () => {
|
|
137
|
+
it("strips all x- prefixed properties", () => {
|
|
138
|
+
const result = normalizeSchema(
|
|
139
|
+
{
|
|
140
|
+
type: "string",
|
|
141
|
+
"x-custom": "value",
|
|
142
|
+
"x-another": 42,
|
|
143
|
+
},
|
|
144
|
+
"response",
|
|
145
|
+
);
|
|
146
|
+
expect(result).not.toHaveProperty("x-custom");
|
|
147
|
+
expect(result).not.toHaveProperty("x-another");
|
|
148
|
+
expect(result.type).toBe("string");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("deep recursion", () => {
|
|
153
|
+
it("normalizes nested properties → items → oneOf", () => {
|
|
154
|
+
const result = normalizeSchema(
|
|
155
|
+
{
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {
|
|
158
|
+
items: {
|
|
159
|
+
type: "array",
|
|
160
|
+
items: {
|
|
161
|
+
oneOf: [
|
|
162
|
+
{ type: "string", nullable: true },
|
|
163
|
+
{ type: "integer", example: 42 },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
"response",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const itemsSchema = result.properties?.items;
|
|
173
|
+
expect(itemsSchema).toBeDefined();
|
|
174
|
+
const arraySchema = itemsSchema as Record<string, unknown>;
|
|
175
|
+
const itemSchema = arraySchema.items as Record<string, unknown>;
|
|
176
|
+
const branches = itemSchema.oneOf as Record<string, unknown>[];
|
|
177
|
+
expect(branches).toHaveLength(2);
|
|
178
|
+
// First branch: nullable string → oneOf
|
|
179
|
+
expect(branches[0]).toHaveProperty("oneOf");
|
|
180
|
+
// Second branch: example → default
|
|
181
|
+
expect(branches[1]).toHaveProperty("default", 42);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("passthrough", () => {
|
|
186
|
+
it("preserves standard JSON Schema 7 features", () => {
|
|
187
|
+
const input = {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
name: { type: "string", minLength: 1, maxLength: 100 },
|
|
191
|
+
age: { type: "integer", minimum: 0, maximum: 150 },
|
|
192
|
+
role: { type: "string", enum: ["admin", "user"] },
|
|
193
|
+
email: { type: "string", pattern: "^[^@]+@[^@]+$" },
|
|
194
|
+
},
|
|
195
|
+
required: ["name"],
|
|
196
|
+
};
|
|
197
|
+
const result = normalizeSchema(input, "response");
|
|
198
|
+
expect(result.properties?.name).toMatchObject({
|
|
199
|
+
type: "string",
|
|
200
|
+
minLength: 1,
|
|
201
|
+
maxLength: 100,
|
|
202
|
+
});
|
|
203
|
+
expect(result.properties?.age).toMatchObject({
|
|
204
|
+
type: "integer",
|
|
205
|
+
minimum: 0,
|
|
206
|
+
maximum: 150,
|
|
207
|
+
});
|
|
208
|
+
expect(result.required).toEqual(["name"]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("discriminator", () => {
|
|
213
|
+
it("adds required and enum constraints for discriminator", () => {
|
|
214
|
+
const result = normalizeSchema(
|
|
215
|
+
{
|
|
216
|
+
discriminator: {
|
|
217
|
+
propertyName: "petType",
|
|
218
|
+
mapping: {
|
|
219
|
+
dog: "#/components/schemas/Dog",
|
|
220
|
+
cat: "#/components/schemas/Cat",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
oneOf: [
|
|
224
|
+
{
|
|
225
|
+
type: "object",
|
|
226
|
+
properties: {
|
|
227
|
+
petType: { type: "string" },
|
|
228
|
+
bark: { type: "boolean" },
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
petType: { type: "string" },
|
|
235
|
+
purr: { type: "boolean" },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
"response",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(result).not.toHaveProperty("discriminator");
|
|
244
|
+
const branches = result.oneOf as Record<string, unknown>[];
|
|
245
|
+
expect(branches).toHaveLength(2);
|
|
246
|
+
|
|
247
|
+
// Each branch should have petType as required
|
|
248
|
+
for (const branch of branches) {
|
|
249
|
+
expect(branch.required).toContain("petType");
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { JSONSchema7 } from "json-schema";
|
|
4
|
+
|
|
5
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toJsonSchema(node: Record<string, unknown>): JSONSchema7 {
|
|
10
|
+
// Object.assign merges unknown-keyed properties into JSONSchema7.
|
|
11
|
+
// This is safe because the normalizer has already ensured the shape
|
|
12
|
+
// is valid JSON Schema 7 before calling this function.
|
|
13
|
+
return Object.assign<JSONSchema7, Record<string, unknown>>({}, node);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize an OpenAPI schema to pure JSON Schema 7 that json-schema-faker understands.
|
|
18
|
+
*
|
|
19
|
+
* Transforms applied:
|
|
20
|
+
* - nullable: true -> oneOf with null type
|
|
21
|
+
* - discriminator -> required + enum on branches
|
|
22
|
+
* - readOnly/writeOnly -> strip based on direction
|
|
23
|
+
* - example -> default (if default not set)
|
|
24
|
+
* - exclusiveMinimum/exclusiveMaximum boolean -> number format
|
|
25
|
+
* - x-* extensions -> stripped
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeSchema(
|
|
28
|
+
schema: Record<string, unknown>,
|
|
29
|
+
direction: "request" | "response",
|
|
30
|
+
): JSONSchema7 {
|
|
31
|
+
return normalizeNode(structuredClone(schema), direction, new WeakSet());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeNode(
|
|
35
|
+
node: Record<string, unknown>,
|
|
36
|
+
direction: "request" | "response",
|
|
37
|
+
visited: WeakSet<object>,
|
|
38
|
+
): JSONSchema7 {
|
|
39
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
40
|
+
return toJsonSchema({});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Circular reference detection — break cycles
|
|
44
|
+
if (visited.has(node)) {
|
|
45
|
+
return toJsonSchema({});
|
|
46
|
+
}
|
|
47
|
+
visited.add(node);
|
|
48
|
+
|
|
49
|
+
// Strip x-* extensions
|
|
50
|
+
for (const key of Object.keys(node)) {
|
|
51
|
+
if (key.startsWith("x-")) {
|
|
52
|
+
delete node[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle nullable: true -> oneOf with null
|
|
57
|
+
if (node.nullable === true) {
|
|
58
|
+
delete node.nullable;
|
|
59
|
+
// Only wrap if not already a composition with null
|
|
60
|
+
const copy = { ...node };
|
|
61
|
+
delete copy.nullable;
|
|
62
|
+
return {
|
|
63
|
+
oneOf: [normalizeNode(copy, direction, visited), { type: "null" }],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
delete node.nullable;
|
|
67
|
+
|
|
68
|
+
// Handle discriminator
|
|
69
|
+
if (node.discriminator && isRecord(node.discriminator)) {
|
|
70
|
+
const disc = node.discriminator;
|
|
71
|
+
const propName = disc.propertyName;
|
|
72
|
+
if (typeof propName === "string" && Array.isArray(node.oneOf)) {
|
|
73
|
+
const mappingRaw = disc.mapping ?? {};
|
|
74
|
+
const mapping = isRecord(mappingRaw) ? mappingRaw : {};
|
|
75
|
+
// mapping keys ARE the discriminator values (e.g. "dog", "cat")
|
|
76
|
+
// mapping values are $ref strings — after dereference they can't be matched to branches
|
|
77
|
+
// Use key order to correspond to oneOf branch order
|
|
78
|
+
const discriminatorValues = Object.keys(mapping);
|
|
79
|
+
|
|
80
|
+
node.oneOf = node.oneOf
|
|
81
|
+
.filter((branch): branch is Record<string, unknown> => isRecord(branch))
|
|
82
|
+
.map((branch, index) => {
|
|
83
|
+
const normalized = normalizeNode(branch, direction, visited);
|
|
84
|
+
// Ensure discriminator property is required
|
|
85
|
+
if (isRecord(normalized)) {
|
|
86
|
+
const required = Array.isArray(normalized.required)
|
|
87
|
+
? [...normalized.required]
|
|
88
|
+
: [];
|
|
89
|
+
if (!required.includes(propName)) {
|
|
90
|
+
required.push(propName);
|
|
91
|
+
}
|
|
92
|
+
normalized.required = required;
|
|
93
|
+
|
|
94
|
+
// Add enum constraint for the discriminator value
|
|
95
|
+
const mappingValue = discriminatorValues[index];
|
|
96
|
+
if (mappingValue && isRecord(normalized.properties)) {
|
|
97
|
+
const props = normalized.properties;
|
|
98
|
+
const existingRaw = props[propName] ?? {};
|
|
99
|
+
const existing = isRecord(existingRaw) ? existingRaw : {};
|
|
100
|
+
props[propName] = { ...existing, enum: [mappingValue] };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return normalized;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
delete node.discriminator;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle readOnly/writeOnly on properties
|
|
110
|
+
if (isRecord(node.properties)) {
|
|
111
|
+
const props = node.properties;
|
|
112
|
+
const required = Array.isArray(node.required)
|
|
113
|
+
? node.required.filter((r): r is string => typeof r === "string")
|
|
114
|
+
: [];
|
|
115
|
+
const keysToRemove: string[] = [];
|
|
116
|
+
|
|
117
|
+
for (const [propName, propSchemaRaw] of Object.entries(props)) {
|
|
118
|
+
if (!isRecord(propSchemaRaw)) continue;
|
|
119
|
+
const propSchema = propSchemaRaw;
|
|
120
|
+
|
|
121
|
+
// readOnly fields: remove from request schemas
|
|
122
|
+
if (direction === "request" && propSchema.readOnly === true) {
|
|
123
|
+
keysToRemove.push(propName);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// writeOnly fields: remove from response schemas
|
|
127
|
+
if (direction === "response" && propSchema.writeOnly === true) {
|
|
128
|
+
keysToRemove.push(propName);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Clean up the flags after handling
|
|
133
|
+
delete propSchema.readOnly;
|
|
134
|
+
delete propSchema.writeOnly;
|
|
135
|
+
|
|
136
|
+
// Recurse into property
|
|
137
|
+
props[propName] = normalizeNode(propSchema, direction, visited);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const key of keysToRemove) {
|
|
141
|
+
delete props[key];
|
|
142
|
+
const reqIdx = required.indexOf(key);
|
|
143
|
+
if (reqIdx !== -1) {
|
|
144
|
+
required.splice(reqIdx, 1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (required.length > 0) {
|
|
149
|
+
node.required = required;
|
|
150
|
+
} else if (keysToRemove.length > 0 && Array.isArray(node.required)) {
|
|
151
|
+
// If we removed all required fields, clean up
|
|
152
|
+
if (required.length === 0) {
|
|
153
|
+
delete node.required;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Handle example -> default
|
|
159
|
+
if ("example" in node && !("default" in node)) {
|
|
160
|
+
node.default = node.example;
|
|
161
|
+
}
|
|
162
|
+
delete node.example;
|
|
163
|
+
|
|
164
|
+
// Handle exclusiveMinimum/exclusiveMaximum boolean -> number
|
|
165
|
+
if (node.exclusiveMinimum === true && typeof node.minimum === "number") {
|
|
166
|
+
node.exclusiveMinimum = node.minimum;
|
|
167
|
+
delete node.minimum;
|
|
168
|
+
} else if (node.exclusiveMinimum === false) {
|
|
169
|
+
delete node.exclusiveMinimum;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (node.exclusiveMaximum === true && typeof node.maximum === "number") {
|
|
173
|
+
node.exclusiveMaximum = node.maximum;
|
|
174
|
+
delete node.maximum;
|
|
175
|
+
} else if (node.exclusiveMaximum === false) {
|
|
176
|
+
delete node.exclusiveMaximum;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Recurse into items (array schema)
|
|
180
|
+
if (node.items) {
|
|
181
|
+
if (Array.isArray(node.items)) {
|
|
182
|
+
node.items = node.items.map((item: unknown) =>
|
|
183
|
+
isRecord(item) ? normalizeNode(item, direction, visited) : item,
|
|
184
|
+
);
|
|
185
|
+
} else if (isRecord(node.items)) {
|
|
186
|
+
node.items = normalizeNode(node.items, direction, visited);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Recurse into additionalProperties
|
|
191
|
+
if (isRecord(node.additionalProperties)) {
|
|
192
|
+
node.additionalProperties = normalizeNode(
|
|
193
|
+
node.additionalProperties,
|
|
194
|
+
direction,
|
|
195
|
+
visited,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Recurse into composition keywords
|
|
200
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
201
|
+
const keywordValue = node[keyword];
|
|
202
|
+
if (Array.isArray(keywordValue)) {
|
|
203
|
+
node[keyword] = keywordValue.map((branch: unknown) =>
|
|
204
|
+
isRecord(branch) ? normalizeNode(branch, direction, visited) : branch,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Recurse into not
|
|
210
|
+
if (isRecord(node.not)) {
|
|
211
|
+
node.not = normalizeNode(node.not, direction, visited);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Recurse into conditional
|
|
215
|
+
for (const keyword of ["if", "then", "else"]) {
|
|
216
|
+
const keywordValue = node[keyword];
|
|
217
|
+
if (isRecord(keywordValue)) {
|
|
218
|
+
node[keyword] = normalizeNode(keywordValue, direction, visited);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Recurse into patternProperties
|
|
223
|
+
if (isRecord(node.patternProperties)) {
|
|
224
|
+
const pp = node.patternProperties;
|
|
225
|
+
for (const [pattern, schema] of Object.entries(pp)) {
|
|
226
|
+
if (isRecord(schema)) {
|
|
227
|
+
pp[pattern] = normalizeNode(schema, direction, visited);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return toJsonSchema(node);
|
|
233
|
+
}
|