@schmock/openapi 1.2.0 → 1.4.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/content-negotiation.d.ts +7 -0
- package/dist/content-negotiation.d.ts.map +1 -0
- package/dist/content-negotiation.js +46 -0
- package/dist/crud-detector.d.ts +2 -0
- package/dist/crud-detector.d.ts.map +1 -1
- package/dist/crud-detector.js +55 -8
- package/dist/generators.d.ts +27 -7
- package/dist/generators.d.ts.map +1 -1
- package/dist/generators.js +190 -18
- package/dist/index.js +159 -159
- package/dist/normalizer.js +1 -1
- package/dist/parser.d.ts +31 -4
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +208 -6
- package/dist/plugin.d.ts +9 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +506 -47
- package/dist/prefer.d.ts +12 -0
- package/dist/prefer.d.ts.map +1 -0
- package/dist/prefer.js +25 -0
- package/dist/seed.js +1 -1
- package/package.json +5 -3
- package/src/content-negotiation.ts +53 -0
- package/src/crud-detector.ts +65 -8
- package/src/generators.test.ts +270 -0
- package/src/generators.ts +237 -11
- package/src/index.ts +1 -1
- package/src/normalizer.ts +1 -1
- package/src/parser.ts +292 -12
- package/src/plugin.ts +655 -51
- package/src/prefer.ts +37 -0
- package/src/seed.ts +1 -1
- package/src/steps/callback-mocking.steps.ts +164 -0
- package/src/steps/content-negotiation.steps.ts +107 -0
- package/src/steps/errors-mode.steps.ts +95 -0
- package/src/steps/openapi-compliance.steps.ts +427 -0
- package/src/steps/prefer-header.steps.ts +140 -0
- package/src/steps/security-validation.steps.ts +183 -0
- package/src/stress.test.ts +92 -35
package/src/generators.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/// <reference path="
|
|
1
|
+
/// <reference path="../../core/schmock.d.ts" />
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { generateFromSchema } from "@schmock/faker";
|
|
4
5
|
import type { JSONSchema7 } from "json-schema";
|
|
5
6
|
import type { CrudResource } from "./crud-detector.js";
|
|
6
7
|
import type { ParsedPath } from "./parser.js";
|
|
@@ -8,6 +9,136 @@ import { isRecord } from "./utils.js";
|
|
|
8
9
|
|
|
9
10
|
const COLLECTION_STATE_PREFIX = "openapi:collections:";
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Result of finding the array property in a response schema.
|
|
14
|
+
* If property is undefined, the schema is a flat array (or unknown).
|
|
15
|
+
*/
|
|
16
|
+
interface ArrayPropertyInfo {
|
|
17
|
+
/** Property name holding the array (e.g. "data"), undefined for flat arrays */
|
|
18
|
+
property?: string;
|
|
19
|
+
/** Schema for the array items */
|
|
20
|
+
itemSchema?: JSONSchema7;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find which property in a response schema holds the array of items.
|
|
25
|
+
* Handles flat arrays, object wrappers (Stripe), and allOf compositions (Scalar Galaxy).
|
|
26
|
+
*/
|
|
27
|
+
export function findArrayProperty(schema: JSONSchema7): ArrayPropertyInfo {
|
|
28
|
+
if (!schema || typeof schema === "boolean") return {};
|
|
29
|
+
|
|
30
|
+
// Case 1: flat array
|
|
31
|
+
if (schema.type === "array") {
|
|
32
|
+
const items = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
|
33
|
+
const itemSchema = isRecord(items) ? (items as JSONSchema7) : undefined;
|
|
34
|
+
return { itemSchema };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Case 2: object with properties — scan for the array property
|
|
38
|
+
if (schema.type === "object" && isRecord(schema.properties)) {
|
|
39
|
+
return findArrayInProperties(schema.properties);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Case 3: allOf — merge branches into one virtual object, then scan
|
|
43
|
+
if (Array.isArray(schema.allOf)) {
|
|
44
|
+
const merged: Record<string, JSONSchema7> = {};
|
|
45
|
+
for (const branch of schema.allOf) {
|
|
46
|
+
if (isRecord(branch) && isRecord(branch.properties)) {
|
|
47
|
+
for (const [key, value] of Object.entries(branch.properties)) {
|
|
48
|
+
if (isRecord(value)) {
|
|
49
|
+
merged[key] = value as JSONSchema7;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Object.keys(merged).length > 0) {
|
|
55
|
+
return findArrayInProperties(merged);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Case 4: anyOf/oneOf — try first branch
|
|
60
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
61
|
+
const branches = schema[keyword];
|
|
62
|
+
if (Array.isArray(branches) && branches.length > 0) {
|
|
63
|
+
const first = branches[0];
|
|
64
|
+
if (isRecord(first)) {
|
|
65
|
+
return findArrayProperty(first as JSONSchema7);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findArrayInProperties(
|
|
74
|
+
properties: Record<string, unknown>,
|
|
75
|
+
): ArrayPropertyInfo {
|
|
76
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
77
|
+
if (!isRecord(value)) continue;
|
|
78
|
+
const prop = value as JSONSchema7;
|
|
79
|
+
if (prop.type === "array" && prop.items) {
|
|
80
|
+
const items = Array.isArray(prop.items) ? prop.items[0] : prop.items;
|
|
81
|
+
const itemSchema = isRecord(items) ? (items as JSONSchema7) : undefined;
|
|
82
|
+
return { property: key, itemSchema };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Generate header values from spec-defined response header definitions.
|
|
90
|
+
*/
|
|
91
|
+
export function generateHeaderValues(
|
|
92
|
+
headerDefs: Record<string, Schmock.ResponseHeaderDef> | undefined,
|
|
93
|
+
): Record<string, string> {
|
|
94
|
+
if (!headerDefs) return {};
|
|
95
|
+
|
|
96
|
+
const headers: Record<string, string> = {};
|
|
97
|
+
|
|
98
|
+
for (const [name, def] of Object.entries(headerDefs)) {
|
|
99
|
+
const value = generateSingleHeaderValue(def.schema);
|
|
100
|
+
if (value !== undefined) {
|
|
101
|
+
headers[name] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return headers;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function generateSingleHeaderValue(
|
|
109
|
+
schema: JSONSchema7 | undefined,
|
|
110
|
+
): string | undefined {
|
|
111
|
+
if (!schema || typeof schema === "boolean") return undefined;
|
|
112
|
+
|
|
113
|
+
// Has enum → first value
|
|
114
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
115
|
+
return String(schema.enum[0]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Has default (from example → default normalization)
|
|
119
|
+
if ("default" in schema && schema.default !== undefined) {
|
|
120
|
+
return String(schema.default);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Format-based generation
|
|
124
|
+
if (schema.format === "uuid") {
|
|
125
|
+
return randomUUID();
|
|
126
|
+
}
|
|
127
|
+
if (schema.format === "date-time") {
|
|
128
|
+
return new Date().toISOString();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Type-based fallback
|
|
132
|
+
if (schema.type === "integer" || schema.type === "number") {
|
|
133
|
+
return "0";
|
|
134
|
+
}
|
|
135
|
+
if (schema.type === "string") {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
11
142
|
function toTuple(status: number, body: unknown): [number, unknown] {
|
|
12
143
|
return [status, body];
|
|
13
144
|
}
|
|
@@ -45,16 +176,40 @@ function getNextId(
|
|
|
45
176
|
|
|
46
177
|
export function createListGenerator(
|
|
47
178
|
resource: CrudResource,
|
|
179
|
+
meta?: Schmock.CrudOperationMeta,
|
|
48
180
|
): Schmock.GeneratorFunction {
|
|
181
|
+
// Pre-compute wrapper info at setup time
|
|
182
|
+
const wrapperInfo = meta?.responseSchema
|
|
183
|
+
? findArrayProperty(meta.responseSchema)
|
|
184
|
+
: undefined;
|
|
185
|
+
const headerDefs = meta?.responseHeaders;
|
|
186
|
+
|
|
49
187
|
return (ctx: Schmock.RequestContext) => {
|
|
50
188
|
const collection = getCollection(ctx.state, resource.name);
|
|
51
|
-
|
|
189
|
+
const items = [...collection];
|
|
190
|
+
|
|
191
|
+
// If no wrapper detected or flat array, return items directly
|
|
192
|
+
if (!wrapperInfo?.property || !meta?.responseSchema) {
|
|
193
|
+
return addHeaders(items, headerDefs);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Generate the full wrapper skeleton from schema, then inject live data
|
|
197
|
+
const skeleton = generateWrapperSkeleton(meta.responseSchema);
|
|
198
|
+
if (isRecord(skeleton)) {
|
|
199
|
+
skeleton[wrapperInfo.property] = items;
|
|
200
|
+
return addHeaders(skeleton, headerDefs);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return addHeaders(items, headerDefs);
|
|
52
204
|
};
|
|
53
205
|
}
|
|
54
206
|
|
|
55
207
|
export function createCreateGenerator(
|
|
56
208
|
resource: CrudResource,
|
|
209
|
+
meta?: Schmock.CrudOperationMeta,
|
|
57
210
|
): Schmock.GeneratorFunction {
|
|
211
|
+
const headerDefs = meta?.responseHeaders;
|
|
212
|
+
|
|
58
213
|
return (ctx: Schmock.RequestContext) => {
|
|
59
214
|
const collection = getCollection(ctx.state, resource.name);
|
|
60
215
|
const id = getNextId(ctx.state, resource.name);
|
|
@@ -70,36 +225,42 @@ export function createCreateGenerator(
|
|
|
70
225
|
}
|
|
71
226
|
|
|
72
227
|
collection.push(item);
|
|
73
|
-
return toTuple(201, item);
|
|
228
|
+
return addHeaders(toTuple(201, item), headerDefs);
|
|
74
229
|
};
|
|
75
230
|
}
|
|
76
231
|
|
|
77
232
|
export function createReadGenerator(
|
|
78
233
|
resource: CrudResource,
|
|
234
|
+
meta?: Schmock.CrudOperationMeta,
|
|
79
235
|
): Schmock.GeneratorFunction {
|
|
236
|
+
const headerDefs = meta?.responseHeaders;
|
|
237
|
+
|
|
80
238
|
return (ctx: Schmock.RequestContext) => {
|
|
81
239
|
const collection = getCollection(ctx.state, resource.name);
|
|
82
240
|
const idValue = ctx.params[resource.idParam];
|
|
83
241
|
const item = findById(collection, resource.idParam, idValue);
|
|
84
242
|
|
|
85
243
|
if (!item) {
|
|
86
|
-
return
|
|
244
|
+
return generateErrorResponse(404, meta);
|
|
87
245
|
}
|
|
88
246
|
|
|
89
|
-
return item;
|
|
247
|
+
return addHeaders(item, headerDefs);
|
|
90
248
|
};
|
|
91
249
|
}
|
|
92
250
|
|
|
93
251
|
export function createUpdateGenerator(
|
|
94
252
|
resource: CrudResource,
|
|
253
|
+
meta?: Schmock.CrudOperationMeta,
|
|
95
254
|
): Schmock.GeneratorFunction {
|
|
255
|
+
const headerDefs = meta?.responseHeaders;
|
|
256
|
+
|
|
96
257
|
return (ctx: Schmock.RequestContext) => {
|
|
97
258
|
const collection = getCollection(ctx.state, resource.name);
|
|
98
259
|
const idValue = ctx.params[resource.idParam];
|
|
99
260
|
const index = findIndexById(collection, resource.idParam, idValue);
|
|
100
261
|
|
|
101
262
|
if (index === -1) {
|
|
102
|
-
return
|
|
263
|
+
return generateErrorResponse(404, meta);
|
|
103
264
|
}
|
|
104
265
|
|
|
105
266
|
const existingRaw = collection[index];
|
|
@@ -110,12 +271,13 @@ export function createUpdateGenerator(
|
|
|
110
271
|
[resource.idParam]: existing[resource.idParam], // Preserve ID
|
|
111
272
|
};
|
|
112
273
|
collection[index] = updated;
|
|
113
|
-
return updated;
|
|
274
|
+
return addHeaders(updated, headerDefs);
|
|
114
275
|
};
|
|
115
276
|
}
|
|
116
277
|
|
|
117
278
|
export function createDeleteGenerator(
|
|
118
279
|
resource: CrudResource,
|
|
280
|
+
meta?: Schmock.CrudOperationMeta,
|
|
119
281
|
): Schmock.GeneratorFunction {
|
|
120
282
|
return (ctx: Schmock.RequestContext) => {
|
|
121
283
|
const collection = getCollection(ctx.state, resource.name);
|
|
@@ -123,7 +285,7 @@ export function createDeleteGenerator(
|
|
|
123
285
|
const index = findIndexById(collection, resource.idParam, idValue);
|
|
124
286
|
|
|
125
287
|
if (index === -1) {
|
|
126
|
-
return
|
|
288
|
+
return generateErrorResponse(404, meta);
|
|
127
289
|
}
|
|
128
290
|
|
|
129
291
|
collection.splice(index, 1);
|
|
@@ -133,6 +295,7 @@ export function createDeleteGenerator(
|
|
|
133
295
|
|
|
134
296
|
export function createStaticGenerator(
|
|
135
297
|
parsedPath: ParsedPath,
|
|
298
|
+
seed?: number,
|
|
136
299
|
): Schmock.GeneratorFunction {
|
|
137
300
|
// Get the success response schema
|
|
138
301
|
let responseSchema: JSONSchema7 | undefined;
|
|
@@ -155,7 +318,7 @@ export function createStaticGenerator(
|
|
|
155
318
|
return () => {
|
|
156
319
|
if (responseSchema) {
|
|
157
320
|
try {
|
|
158
|
-
return generateFromSchema({ schema: responseSchema });
|
|
321
|
+
return generateFromSchema({ schema: responseSchema, seed });
|
|
159
322
|
} catch (error) {
|
|
160
323
|
console.warn(
|
|
161
324
|
`[@schmock/openapi] Schema generation failed for ${parsedPath.method} ${parsedPath.path}:`,
|
|
@@ -175,10 +338,11 @@ export function generateSeedItems(
|
|
|
175
338
|
schema: JSONSchema7,
|
|
176
339
|
count: number,
|
|
177
340
|
idParam: string,
|
|
341
|
+
seed?: number,
|
|
178
342
|
): unknown[] {
|
|
179
343
|
const items: unknown[] = [];
|
|
180
344
|
for (let i = 0; i < count; i++) {
|
|
181
|
-
const generated = generateFromSchema({ schema });
|
|
345
|
+
const generated = generateFromSchema({ schema, seed });
|
|
182
346
|
const item: Record<string, unknown> = isRecord(generated)
|
|
183
347
|
? generated
|
|
184
348
|
: { value: generated };
|
|
@@ -188,6 +352,68 @@ export function generateSeedItems(
|
|
|
188
352
|
return items;
|
|
189
353
|
}
|
|
190
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Generate an error response using the spec's error schema if available,
|
|
357
|
+
* or fall back to the default { error, code } format.
|
|
358
|
+
*/
|
|
359
|
+
function generateErrorResponse(
|
|
360
|
+
status: number,
|
|
361
|
+
meta?: Schmock.CrudOperationMeta,
|
|
362
|
+
): [number, unknown] | [number, unknown, Record<string, string>] {
|
|
363
|
+
const errorSchema = meta?.errorSchemas?.get(status);
|
|
364
|
+
if (errorSchema) {
|
|
365
|
+
try {
|
|
366
|
+
const body = generateFromSchema({ schema: errorSchema });
|
|
367
|
+
return toTuple(status, body);
|
|
368
|
+
} catch {
|
|
369
|
+
// Fall through to default
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Default error format
|
|
374
|
+
const defaults: Record<number, { error: string; code: string }> = {
|
|
375
|
+
404: { error: "Not found", code: "NOT_FOUND" },
|
|
376
|
+
400: { error: "Bad request", code: "BAD_REQUEST" },
|
|
377
|
+
409: { error: "Conflict", code: "CONFLICT" },
|
|
378
|
+
};
|
|
379
|
+
return toTuple(status, defaults[status] ?? { error: "Error", code: "ERROR" });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Generate a skeleton object from a response schema.
|
|
384
|
+
* Used to create wrapper objects (e.g. { data: [], has_more: false, object: "list" })
|
|
385
|
+
*/
|
|
386
|
+
function generateWrapperSkeleton(schema: JSONSchema7): unknown {
|
|
387
|
+
try {
|
|
388
|
+
return generateFromSchema({ schema });
|
|
389
|
+
} catch {
|
|
390
|
+
return {};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* If response headers are defined, convert a response value into a triple [status, body, headers].
|
|
396
|
+
* Otherwise return the value as-is.
|
|
397
|
+
*/
|
|
398
|
+
function addHeaders(
|
|
399
|
+
value: Schmock.ResponseResult,
|
|
400
|
+
headerDefs: Record<string, Schmock.ResponseHeaderDef> | undefined,
|
|
401
|
+
): Schmock.ResponseResult {
|
|
402
|
+
const headers = generateHeaderValues(headerDefs);
|
|
403
|
+
if (Object.keys(headers).length === 0) {
|
|
404
|
+
return value;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// If already a tuple [status, body] or [status, body, headers]
|
|
408
|
+
if (Array.isArray(value) && value.length >= 2) {
|
|
409
|
+
const status = typeof value[0] === "number" ? value[0] : 200;
|
|
410
|
+
return [status, value[1], headers];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Plain value → [200, body, headers]
|
|
414
|
+
return [200, value, headers];
|
|
415
|
+
}
|
|
416
|
+
|
|
191
417
|
function findById(
|
|
192
418
|
collection: unknown[],
|
|
193
419
|
idParam: string,
|
package/src/index.ts
CHANGED
package/src/normalizer.ts
CHANGED