@schmock/openapi 1.2.1 → 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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negotiate the best content type match for an Accept header.
|
|
3
|
+
* Supports quality values (q=0.8) and wildcards (*\/*).
|
|
4
|
+
* Returns the matched content type or null if no match.
|
|
5
|
+
*/
|
|
6
|
+
export function negotiateContentType(
|
|
7
|
+
accept: string,
|
|
8
|
+
available: string[],
|
|
9
|
+
): string | null {
|
|
10
|
+
if (!accept || accept === "*/*") {
|
|
11
|
+
return available[0] ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Parse Accept header into sorted entries by quality
|
|
15
|
+
const entries = accept
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((part) => {
|
|
18
|
+
const [type, ...params] = part.trim().split(";");
|
|
19
|
+
let q = 1;
|
|
20
|
+
for (const param of params) {
|
|
21
|
+
const match = param.trim().match(/^q\s*=\s*([\d.]+)$/);
|
|
22
|
+
if (match) {
|
|
23
|
+
q = Number.parseFloat(match[1]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { type: type.trim(), q };
|
|
27
|
+
})
|
|
28
|
+
.sort((a, b) => b.q - a.q);
|
|
29
|
+
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.q === 0) continue;
|
|
32
|
+
|
|
33
|
+
// Wildcard matches anything
|
|
34
|
+
if (entry.type === "*/*") {
|
|
35
|
+
return available[0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Type wildcard (e.g., "application/*")
|
|
39
|
+
if (entry.type.endsWith("/*")) {
|
|
40
|
+
const prefix = entry.type.slice(0, -1);
|
|
41
|
+
const match = available.find((ct) => ct.startsWith(prefix));
|
|
42
|
+
if (match) return match;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Exact match
|
|
47
|
+
if (available.includes(entry.type)) {
|
|
48
|
+
return entry.type;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
package/src/crud-detector.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/// <reference path="
|
|
1
|
+
/// <reference path="../../core/schmock.d.ts" />
|
|
2
2
|
|
|
3
3
|
import type { JSONSchema7 } from "json-schema";
|
|
4
|
+
import { findArrayProperty } from "./generators.js";
|
|
4
5
|
import type { ParsedPath } from "./parser.js";
|
|
5
6
|
import { isRecord, toJsonSchema } from "./utils.js";
|
|
6
7
|
|
|
@@ -19,6 +20,8 @@ export interface CrudResource {
|
|
|
19
20
|
operations: CrudOperation[];
|
|
20
21
|
/** Response schema for the resource item */
|
|
21
22
|
schema?: JSONSchema7;
|
|
23
|
+
/** Per-operation metadata auto-detected from spec */
|
|
24
|
+
operationMeta?: Map<CrudOperation, Schmock.CrudOperationMeta>;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
interface DetectionResult {
|
|
@@ -97,6 +100,7 @@ function buildResource(
|
|
|
97
100
|
let itemPath = "";
|
|
98
101
|
let idParam = "";
|
|
99
102
|
let schema: JSONSchema7 | undefined;
|
|
103
|
+
const operationMeta = new Map<CrudOperation, Schmock.CrudOperationMeta>();
|
|
100
104
|
|
|
101
105
|
for (const p of paths) {
|
|
102
106
|
const isCollection = p.path === basePath;
|
|
@@ -105,18 +109,30 @@ function buildResource(
|
|
|
105
109
|
if (isCollection) {
|
|
106
110
|
if (p.method === "GET") {
|
|
107
111
|
operations.push("list");
|
|
108
|
-
// Try to extract item schema from list response (array items)
|
|
109
112
|
const listSchema = getSuccessResponseSchema(p);
|
|
110
|
-
if (listSchema
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
if (listSchema) {
|
|
114
|
+
// Extract item schema from both flat arrays and wrapped lists
|
|
115
|
+
const arrayInfo = findArrayProperty(listSchema);
|
|
116
|
+
if (arrayInfo.itemSchema) {
|
|
117
|
+
schema = schema ?? arrayInfo.itemSchema;
|
|
118
|
+
} else if (listSchema.type === "array" && listSchema.items) {
|
|
119
|
+
// Fallback: direct flat array
|
|
120
|
+
const items = Array.isArray(listSchema.items)
|
|
121
|
+
? listSchema.items[0]
|
|
122
|
+
: listSchema.items;
|
|
123
|
+
if (isRecord(items)) {
|
|
124
|
+
schema = schema ?? toJsonSchema(items);
|
|
125
|
+
}
|
|
116
126
|
}
|
|
117
127
|
}
|
|
128
|
+
|
|
129
|
+
// Capture list operation metadata
|
|
130
|
+
const meta = buildOperationMeta(p, "list");
|
|
131
|
+
operationMeta.set("list", meta);
|
|
118
132
|
} else if (p.method === "POST") {
|
|
119
133
|
operations.push("create");
|
|
134
|
+
const meta = buildOperationMeta(p, "create");
|
|
135
|
+
operationMeta.set("create", meta);
|
|
120
136
|
}
|
|
121
137
|
} else if (isItem) {
|
|
122
138
|
// Extract ID param from the item path
|
|
@@ -134,12 +150,18 @@ function buildResource(
|
|
|
134
150
|
if (p.method === "GET") {
|
|
135
151
|
operations.push("read");
|
|
136
152
|
schema = schema ?? getSuccessResponseSchema(p);
|
|
153
|
+
const meta = buildOperationMeta(p, "read");
|
|
154
|
+
operationMeta.set("read", meta);
|
|
137
155
|
} else if (p.method === "PUT" || p.method === "PATCH") {
|
|
138
156
|
if (!operations.includes("update")) {
|
|
139
157
|
operations.push("update");
|
|
158
|
+
const meta = buildOperationMeta(p, "update");
|
|
159
|
+
operationMeta.set("update", meta);
|
|
140
160
|
}
|
|
141
161
|
} else if (p.method === "DELETE") {
|
|
142
162
|
operations.push("delete");
|
|
163
|
+
const meta = buildOperationMeta(p, "delete");
|
|
164
|
+
operationMeta.set("delete", meta);
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
167
|
}
|
|
@@ -177,9 +199,44 @@ function buildResource(
|
|
|
177
199
|
idParam,
|
|
178
200
|
operations,
|
|
179
201
|
schema,
|
|
202
|
+
operationMeta: operationMeta.size > 0 ? operationMeta : undefined,
|
|
180
203
|
};
|
|
181
204
|
}
|
|
182
205
|
|
|
206
|
+
function buildOperationMeta(
|
|
207
|
+
p: ParsedPath,
|
|
208
|
+
_operation: CrudOperation,
|
|
209
|
+
): Schmock.CrudOperationMeta {
|
|
210
|
+
const meta: Schmock.CrudOperationMeta = {};
|
|
211
|
+
|
|
212
|
+
// Capture full success response schema
|
|
213
|
+
const responseSchema = getSuccessResponseSchema(p);
|
|
214
|
+
if (responseSchema) {
|
|
215
|
+
meta.responseSchema = responseSchema;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Capture success response headers
|
|
219
|
+
for (const [code, resp] of p.responses) {
|
|
220
|
+
if (code >= 200 && code < 300 && resp.headers) {
|
|
221
|
+
meta.responseHeaders = resp.headers;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Capture error response schemas (4xx)
|
|
227
|
+
const errorSchemas = new Map<number, JSONSchema7>();
|
|
228
|
+
for (const [code, resp] of p.responses) {
|
|
229
|
+
if (code >= 400 && code < 600 && resp.schema) {
|
|
230
|
+
errorSchemas.set(code, resp.schema);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (errorSchemas.size > 0) {
|
|
234
|
+
meta.errorSchemas = errorSchemas;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return meta;
|
|
238
|
+
}
|
|
239
|
+
|
|
183
240
|
function getSuccessResponseSchema(p: ParsedPath): JSONSchema7 | undefined {
|
|
184
241
|
// Try 200, then 201, then first 2xx
|
|
185
242
|
for (const code of [200, 201]) {
|
package/src/generators.test.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
createListGenerator,
|
|
7
7
|
createReadGenerator,
|
|
8
8
|
createUpdateGenerator,
|
|
9
|
+
findArrayProperty,
|
|
10
|
+
generateHeaderValues,
|
|
9
11
|
generateSeedItems,
|
|
10
12
|
} from "./generators";
|
|
11
13
|
|
|
@@ -211,4 +213,272 @@ describe("generators", () => {
|
|
|
211
213
|
}
|
|
212
214
|
});
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
describe("findArrayProperty", () => {
|
|
218
|
+
it("returns empty for flat type:array schema", () => {
|
|
219
|
+
const result = findArrayProperty({
|
|
220
|
+
type: "array",
|
|
221
|
+
items: { type: "object", properties: { id: { type: "integer" } } },
|
|
222
|
+
});
|
|
223
|
+
expect(result.property).toBeUndefined();
|
|
224
|
+
expect(result.itemSchema).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("finds array in Stripe-style inline object", () => {
|
|
228
|
+
const result = findArrayProperty({
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
data: {
|
|
232
|
+
type: "array",
|
|
233
|
+
items: {
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: { email: { type: "string" } },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
has_more: { type: "boolean" },
|
|
239
|
+
object: { type: "string", enum: ["list"] },
|
|
240
|
+
url: { type: "string" },
|
|
241
|
+
},
|
|
242
|
+
required: ["data", "has_more", "object", "url"],
|
|
243
|
+
});
|
|
244
|
+
expect(result.property).toBe("data");
|
|
245
|
+
expect(result.itemSchema).toBeDefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("finds array in allOf composition (Scalar Galaxy style)", () => {
|
|
249
|
+
const result = findArrayProperty({
|
|
250
|
+
allOf: [
|
|
251
|
+
{
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: {
|
|
254
|
+
data: {
|
|
255
|
+
type: "array",
|
|
256
|
+
items: {
|
|
257
|
+
type: "object",
|
|
258
|
+
properties: { name: { type: "string" } },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: {
|
|
266
|
+
meta: {
|
|
267
|
+
type: "object",
|
|
268
|
+
properties: { total: { type: "integer" } },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
expect(result.property).toBe("data");
|
|
275
|
+
expect(result.itemSchema).toBeDefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("tries first branch of anyOf", () => {
|
|
279
|
+
const result = findArrayProperty({
|
|
280
|
+
anyOf: [
|
|
281
|
+
{
|
|
282
|
+
type: "array",
|
|
283
|
+
items: { type: "object" },
|
|
284
|
+
},
|
|
285
|
+
{ type: "null" },
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
expect(result.property).toBeUndefined();
|
|
289
|
+
expect(result.itemSchema).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("returns empty for schema with no array property", () => {
|
|
293
|
+
const result = findArrayProperty({
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: {
|
|
296
|
+
name: { type: "string" },
|
|
297
|
+
count: { type: "integer" },
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
expect(result.property).toBeUndefined();
|
|
301
|
+
expect(result.itemSchema).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("returns empty for empty schema", () => {
|
|
305
|
+
const result = findArrayProperty({});
|
|
306
|
+
expect(result.property).toBeUndefined();
|
|
307
|
+
expect(result.itemSchema).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("generateHeaderValues", () => {
|
|
312
|
+
it("returns empty object for undefined defs", () => {
|
|
313
|
+
expect(generateHeaderValues(undefined)).toEqual({});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("returns empty object for empty defs", () => {
|
|
317
|
+
expect(generateHeaderValues({})).toEqual({});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("generates UUID for format:uuid", () => {
|
|
321
|
+
const headers = generateHeaderValues({
|
|
322
|
+
"X-Request-ID": {
|
|
323
|
+
schema: { type: "string", format: "uuid" },
|
|
324
|
+
description: "Request ID",
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
expect(headers["X-Request-ID"]).toMatch(
|
|
328
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("generates ISO date for format:date-time", () => {
|
|
333
|
+
const headers = generateHeaderValues({
|
|
334
|
+
"X-Timestamp": {
|
|
335
|
+
schema: { type: "string", format: "date-time" },
|
|
336
|
+
description: "Timestamp",
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
expect(new Date(headers["X-Timestamp"]).toISOString()).toBe(
|
|
340
|
+
headers["X-Timestamp"],
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("uses first enum value", () => {
|
|
345
|
+
const headers = generateHeaderValues({
|
|
346
|
+
"X-Cache": {
|
|
347
|
+
schema: { type: "string", enum: ["HIT", "MISS"] },
|
|
348
|
+
description: "Cache status",
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
expect(headers["X-Cache"]).toBe("HIT");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("uses default value from example normalization", () => {
|
|
355
|
+
const headers = generateHeaderValues({
|
|
356
|
+
"X-Total": {
|
|
357
|
+
schema: { type: "integer", default: 1000 },
|
|
358
|
+
description: "Total items",
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
expect(headers["X-Total"]).toBe("1000");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("generates 0 for integer type", () => {
|
|
365
|
+
const headers = generateHeaderValues({
|
|
366
|
+
"X-Count": {
|
|
367
|
+
schema: { type: "integer" },
|
|
368
|
+
description: "Count",
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
expect(headers["X-Count"]).toBe("0");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("generates empty string for string type", () => {
|
|
375
|
+
const headers = generateHeaderValues({
|
|
376
|
+
"X-Token": {
|
|
377
|
+
schema: { type: "string" },
|
|
378
|
+
description: "Token",
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
expect(headers["X-Token"]).toBe("");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("skips headers with no schema", () => {
|
|
385
|
+
const headers = generateHeaderValues({
|
|
386
|
+
"X-NoSchema": {
|
|
387
|
+
description: "No schema defined",
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
expect(headers["X-NoSchema"]).toBeUndefined();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("list generator with meta (wrapped response)", () => {
|
|
395
|
+
it("wraps list in schema-defined object when wrapper detected", () => {
|
|
396
|
+
const resource = makeResource();
|
|
397
|
+
const state: Record<string, unknown> = {
|
|
398
|
+
"openapi:collections:pets": [
|
|
399
|
+
{ petId: 1, name: "Buddy" },
|
|
400
|
+
{ petId: 2, name: "Max" },
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const meta: Schmock.CrudOperationMeta = {
|
|
405
|
+
responseSchema: {
|
|
406
|
+
type: "object",
|
|
407
|
+
properties: {
|
|
408
|
+
data: {
|
|
409
|
+
type: "array",
|
|
410
|
+
items: {
|
|
411
|
+
type: "object",
|
|
412
|
+
properties: { petId: { type: "integer" } },
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
total: { type: "integer", default: 0 },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const list = createListGenerator(resource, meta);
|
|
421
|
+
const result = list(makeContext({ state }));
|
|
422
|
+
const body = result as Record<string, unknown>;
|
|
423
|
+
expect(body.data).toHaveLength(2);
|
|
424
|
+
expect(body.total).toBeDefined();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("returns flat array when no meta provided", () => {
|
|
428
|
+
const resource = makeResource();
|
|
429
|
+
const state: Record<string, unknown> = {
|
|
430
|
+
"openapi:collections:pets": [{ petId: 1, name: "Buddy" }],
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const list = createListGenerator(resource);
|
|
434
|
+
const result = list(makeContext({ state }));
|
|
435
|
+
expect(Array.isArray(result)).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe("error generator with meta (spec-defined errors)", () => {
|
|
440
|
+
it("uses error schema from meta when available", () => {
|
|
441
|
+
const resource = makeResource();
|
|
442
|
+
const state: Record<string, unknown> = {
|
|
443
|
+
"openapi:collections:pets": [],
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const errorSchemas = new Map<number, Schmock.JSONSchema7>();
|
|
447
|
+
errorSchemas.set(404, {
|
|
448
|
+
type: "object",
|
|
449
|
+
properties: {
|
|
450
|
+
title: { type: "string", default: "Not Found" },
|
|
451
|
+
status: { type: "integer", default: 404 },
|
|
452
|
+
},
|
|
453
|
+
required: ["title", "status"],
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const meta: Schmock.CrudOperationMeta = { errorSchemas };
|
|
457
|
+
const read = createReadGenerator(resource, meta);
|
|
458
|
+
const result = read(
|
|
459
|
+
makeContext({ path: "/pets/999", params: { petId: "999" }, state }),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
expect(Array.isArray(result)).toBe(true);
|
|
463
|
+
const tuple = result as [number, unknown];
|
|
464
|
+
expect(tuple[0]).toBe(404);
|
|
465
|
+
const body = tuple[1] as Record<string, unknown>;
|
|
466
|
+
expect(body.title).toBeDefined();
|
|
467
|
+
expect(body.status).toBeDefined();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("falls back to default error when no meta", () => {
|
|
471
|
+
const resource = makeResource();
|
|
472
|
+
const state: Record<string, unknown> = {
|
|
473
|
+
"openapi:collections:pets": [],
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const read = createReadGenerator(resource);
|
|
477
|
+
const result = read(
|
|
478
|
+
makeContext({ path: "/pets/999", params: { petId: "999" }, state }),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
expect(result).toEqual([404, { error: "Not found", code: "NOT_FOUND" }]);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
214
484
|
});
|