@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
package/src/plugin.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/// <reference path="
|
|
1
|
+
/// <reference path="../../core/schmock.d.ts" />
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { generateFromSchema } from "@schmock/faker";
|
|
4
|
+
import Ajv from "ajv";
|
|
5
|
+
import type { JSONSchema7 } from "json-schema";
|
|
6
|
+
import { negotiateContentType } from "./content-negotiation.js";
|
|
7
|
+
import type { CrudOperation, CrudResource } from "./crud-detector.js";
|
|
4
8
|
import { detectCrudResources } from "./crud-detector.js";
|
|
5
9
|
import {
|
|
6
10
|
createCreateGenerator,
|
|
@@ -9,8 +13,16 @@ import {
|
|
|
9
13
|
createReadGenerator,
|
|
10
14
|
createStaticGenerator,
|
|
11
15
|
createUpdateGenerator,
|
|
16
|
+
findArrayProperty,
|
|
12
17
|
} from "./generators.js";
|
|
18
|
+
import type {
|
|
19
|
+
ParsedCallback,
|
|
20
|
+
ParsedPath,
|
|
21
|
+
ParsedResponseEntry,
|
|
22
|
+
SecurityScheme,
|
|
23
|
+
} from "./parser.js";
|
|
13
24
|
import { parseSpec } from "./parser.js";
|
|
25
|
+
import { parsePreferHeader } from "./prefer.js";
|
|
14
26
|
import type { SeedConfig, SeedSource } from "./seed.js";
|
|
15
27
|
import { loadSeed } from "./seed.js";
|
|
16
28
|
import { isRecord } from "./utils.js";
|
|
@@ -22,7 +34,7 @@ export interface OpenApiOptions {
|
|
|
22
34
|
spec: string | object;
|
|
23
35
|
/** Optional seed data per resource */
|
|
24
36
|
seed?: SeedConfig;
|
|
25
|
-
/** Validate request bodies (default:
|
|
37
|
+
/** Validate request bodies (default: false) */
|
|
26
38
|
validateRequests?: boolean;
|
|
27
39
|
/** Validate response bodies (default: false) */
|
|
28
40
|
validateResponses?: boolean;
|
|
@@ -32,6 +44,14 @@ export interface OpenApiOptions {
|
|
|
32
44
|
sorting?: boolean;
|
|
33
45
|
filtering?: boolean;
|
|
34
46
|
};
|
|
47
|
+
/** Override auto-detected response format per resource */
|
|
48
|
+
resources?: Record<string, Schmock.ResourceOverride>;
|
|
49
|
+
/** Log auto-detection decisions to console (default: false) */
|
|
50
|
+
debug?: boolean;
|
|
51
|
+
/** Seed for deterministic random generation */
|
|
52
|
+
fakerSeed?: number;
|
|
53
|
+
/** Validate security schemes (API key, Bearer, Basic) (default: false) */
|
|
54
|
+
security?: boolean;
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
/**
|
|
@@ -55,28 +75,62 @@ export async function openapi(
|
|
|
55
75
|
? loadSeed(options.seed, resources)
|
|
56
76
|
: new Map<string, unknown[]>();
|
|
57
77
|
|
|
78
|
+
// Build a lookup of all parsed paths for process() to reference
|
|
79
|
+
const allParsedPaths = new Map<string, ParsedPath>();
|
|
80
|
+
for (const pp of [...spec.paths]) {
|
|
81
|
+
allParsedPaths.set(`${pp.method} ${pp.path}`, pp);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Security scheme lookup
|
|
85
|
+
const securitySchemes = spec.securitySchemes;
|
|
86
|
+
const globalSecurity = spec.globalSecurity;
|
|
87
|
+
|
|
58
88
|
return {
|
|
59
89
|
name: "@schmock/openapi",
|
|
60
|
-
version: "1.
|
|
90
|
+
version: "1.4.0",
|
|
61
91
|
|
|
62
92
|
install(instance: Schmock.CallableMockInstance) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// Register CRUD routes
|
|
93
|
+
if (options.debug) {
|
|
94
|
+
console.log(
|
|
95
|
+
`[@schmock/openapi] Detected ${resources.length} CRUD resources, ${nonCrudPaths.length} static routes`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Register CRUD routes with metadata
|
|
71
100
|
for (const resource of resources) {
|
|
72
|
-
|
|
101
|
+
const override = options.resources?.[resource.name];
|
|
102
|
+
if (override) {
|
|
103
|
+
applyOverrides(resource, override);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (options.debug) {
|
|
107
|
+
logResourceDetection(resource, override);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
registerCrudRoutes(
|
|
111
|
+
instance,
|
|
112
|
+
resource,
|
|
113
|
+
seedData.get(resource.name),
|
|
114
|
+
allParsedPaths,
|
|
115
|
+
);
|
|
73
116
|
}
|
|
74
117
|
|
|
75
118
|
// Register non-CRUD routes with static generators
|
|
76
119
|
for (const parsedPath of nonCrudPaths) {
|
|
77
120
|
const routeKey =
|
|
78
121
|
`${parsedPath.method} ${parsedPath.path}` as Schmock.RouteKey;
|
|
79
|
-
|
|
122
|
+
const config: Schmock.RouteConfig = {
|
|
123
|
+
"openapi:responses": parsedPath.responses,
|
|
124
|
+
"openapi:path": parsedPath.path,
|
|
125
|
+
"openapi:requestBody": parsedPath.requestBody,
|
|
126
|
+
"openapi:security": parsedPath.security,
|
|
127
|
+
"openapi:callbacks": parsedPath.callbacks,
|
|
128
|
+
};
|
|
129
|
+
instance(
|
|
130
|
+
routeKey,
|
|
131
|
+
createStaticGenerator(parsedPath, options.fakerSeed),
|
|
132
|
+
config,
|
|
133
|
+
);
|
|
80
134
|
}
|
|
81
135
|
},
|
|
82
136
|
|
|
@@ -84,58 +138,498 @@ export async function openapi(
|
|
|
84
138
|
context: Schmock.PluginContext,
|
|
85
139
|
response?: unknown,
|
|
86
140
|
): Schmock.PluginResult {
|
|
87
|
-
//
|
|
88
|
-
|
|
141
|
+
// 1. Security validation (if enabled)
|
|
142
|
+
if (options.security && securitySchemes) {
|
|
143
|
+
const securityResult = validateSecurity(
|
|
144
|
+
context,
|
|
145
|
+
securitySchemes,
|
|
146
|
+
globalSecurity,
|
|
147
|
+
);
|
|
148
|
+
if (securityResult) return securityResult;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Content negotiation
|
|
152
|
+
const contentResult = processContentNegotiation(context);
|
|
153
|
+
if (contentResult) return contentResult;
|
|
154
|
+
|
|
155
|
+
// 3. Request validation (if enabled)
|
|
156
|
+
if (options.validateRequests) {
|
|
157
|
+
const validationResult = validateRequestBody(context);
|
|
158
|
+
if (validationResult) {
|
|
159
|
+
return validationResult;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4. Prefer header handling
|
|
164
|
+
const result = processPreferHeader(context, response, options.fakerSeed);
|
|
165
|
+
|
|
166
|
+
// 5. Fire callbacks (fire-and-forget, after response is determined)
|
|
167
|
+
const callbacks = context.route["openapi:callbacks"] as
|
|
168
|
+
| ParsedCallback[]
|
|
169
|
+
| undefined;
|
|
170
|
+
if (callbacks && callbacks.length > 0) {
|
|
171
|
+
fireCallbacks(callbacks, context, result.response);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
89
175
|
},
|
|
90
176
|
};
|
|
91
177
|
}
|
|
92
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Validate security requirements for the request.
|
|
181
|
+
* Returns 401 if auth is required and missing/invalid.
|
|
182
|
+
*/
|
|
183
|
+
function validateSecurity(
|
|
184
|
+
context: Schmock.PluginContext,
|
|
185
|
+
schemes: Map<string, SecurityScheme>,
|
|
186
|
+
globalSecurity?: string[][],
|
|
187
|
+
): Schmock.PluginResult | undefined {
|
|
188
|
+
// Determine applicable security: operation-level overrides global
|
|
189
|
+
const routeSecurity = context.route["openapi:security"] as
|
|
190
|
+
| string[][]
|
|
191
|
+
| undefined;
|
|
192
|
+
const security = routeSecurity ?? globalSecurity;
|
|
193
|
+
|
|
194
|
+
// No security requirements
|
|
195
|
+
if (!security || security.length === 0) return undefined;
|
|
196
|
+
|
|
197
|
+
// Check each OR group — if any group passes, request is authorized
|
|
198
|
+
for (const group of security) {
|
|
199
|
+
// Empty group = public endpoint (security: [{}])
|
|
200
|
+
if (group.length === 0) return undefined;
|
|
201
|
+
|
|
202
|
+
// All schemes in the group must pass (AND)
|
|
203
|
+
const allPass = group.every((schemeName) => {
|
|
204
|
+
const scheme = schemes.get(schemeName);
|
|
205
|
+
if (!scheme) return false;
|
|
206
|
+
return checkSchemePresence(scheme, context.headers);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (allPass) return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// No group passed — build WWW-Authenticate header
|
|
213
|
+
const wwwAuth = buildWwwAuthenticate(security, schemes);
|
|
214
|
+
const headers: Record<string, string> = {};
|
|
215
|
+
if (wwwAuth) {
|
|
216
|
+
headers["www-authenticate"] = wwwAuth;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
context,
|
|
221
|
+
response: [
|
|
222
|
+
401,
|
|
223
|
+
{
|
|
224
|
+
error: "Unauthorized",
|
|
225
|
+
code: "UNAUTHORIZED",
|
|
226
|
+
},
|
|
227
|
+
headers,
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function checkSchemePresence(
|
|
233
|
+
scheme: SecurityScheme,
|
|
234
|
+
headers: Record<string, string>,
|
|
235
|
+
): boolean {
|
|
236
|
+
if (scheme.type === "http") {
|
|
237
|
+
const auth = headers.authorization ?? headers.Authorization ?? "";
|
|
238
|
+
if (scheme.scheme === "bearer") {
|
|
239
|
+
return auth.toLowerCase().startsWith("bearer ");
|
|
240
|
+
}
|
|
241
|
+
if (scheme.scheme === "basic") {
|
|
242
|
+
return auth.toLowerCase().startsWith("basic ");
|
|
243
|
+
}
|
|
244
|
+
// Other http schemes — just check authorization exists
|
|
245
|
+
return auth.length > 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (scheme.type === "apiKey") {
|
|
249
|
+
if (scheme.in === "header" && scheme.name) {
|
|
250
|
+
const headerName = scheme.name.toLowerCase();
|
|
251
|
+
return (headers[headerName] ?? headers[scheme.name] ?? "") !== "";
|
|
252
|
+
}
|
|
253
|
+
// query and cookie api keys can't be checked from headers alone
|
|
254
|
+
// For simplicity, pass through (they'd need query/cookie context)
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// oauth2 / openIdConnect — just check for bearer token
|
|
259
|
+
if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
|
|
260
|
+
const auth = headers.authorization ?? headers.Authorization ?? "";
|
|
261
|
+
return auth.toLowerCase().startsWith("bearer ");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildWwwAuthenticate(
|
|
268
|
+
security: string[][],
|
|
269
|
+
schemes: Map<string, SecurityScheme>,
|
|
270
|
+
): string {
|
|
271
|
+
const challenges: string[] = [];
|
|
272
|
+
|
|
273
|
+
for (const group of security) {
|
|
274
|
+
for (const schemeName of group) {
|
|
275
|
+
const scheme = schemes.get(schemeName);
|
|
276
|
+
if (!scheme) continue;
|
|
277
|
+
|
|
278
|
+
if (scheme.type === "http" && scheme.scheme === "bearer") {
|
|
279
|
+
if (!challenges.includes("Bearer")) challenges.push("Bearer");
|
|
280
|
+
} else if (scheme.type === "http" && scheme.scheme === "basic") {
|
|
281
|
+
if (!challenges.includes("Basic")) challenges.push("Basic");
|
|
282
|
+
} else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
|
|
283
|
+
if (!challenges.includes("Bearer")) challenges.push("Bearer");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return challenges.join(", ");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Check Accept header against available content types. Returns 406 if no match.
|
|
293
|
+
*/
|
|
294
|
+
function processContentNegotiation(
|
|
295
|
+
context: Schmock.PluginContext,
|
|
296
|
+
): Schmock.PluginResult | undefined {
|
|
297
|
+
const accept = context.headers.accept ?? context.headers.Accept;
|
|
298
|
+
if (!accept || accept === "*/*") return undefined;
|
|
299
|
+
|
|
300
|
+
const responses = context.route["openapi:responses"] as
|
|
301
|
+
| Map<number, ParsedResponseEntry>
|
|
302
|
+
| undefined;
|
|
303
|
+
if (!responses) return undefined;
|
|
304
|
+
|
|
305
|
+
// Collect all available content types across responses
|
|
306
|
+
const allContentTypes = new Set<string>();
|
|
307
|
+
for (const entry of responses.values()) {
|
|
308
|
+
if (entry.contentTypes) {
|
|
309
|
+
for (const ct of entry.contentTypes) {
|
|
310
|
+
allContentTypes.add(ct);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// No content types defined in spec → skip negotiation
|
|
316
|
+
if (allContentTypes.size === 0) return undefined;
|
|
317
|
+
|
|
318
|
+
const matched = negotiateContentType(accept, [...allContentTypes]);
|
|
319
|
+
if (!matched) {
|
|
320
|
+
return {
|
|
321
|
+
context,
|
|
322
|
+
response: [
|
|
323
|
+
406,
|
|
324
|
+
{
|
|
325
|
+
error: "Not Acceptable",
|
|
326
|
+
code: "NOT_ACCEPTABLE",
|
|
327
|
+
acceptable: [...allContentTypes],
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Handle Prefer header directives: code=N, example=name, dynamic=true
|
|
338
|
+
*/
|
|
339
|
+
function processPreferHeader(
|
|
340
|
+
context: Schmock.PluginContext,
|
|
341
|
+
response: unknown,
|
|
342
|
+
fakerSeed?: number,
|
|
343
|
+
): Schmock.PluginResult {
|
|
344
|
+
const preferValue = context.headers.prefer ?? context.headers.Prefer;
|
|
345
|
+
if (!preferValue) {
|
|
346
|
+
return { context, response };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const prefer = parsePreferHeader(preferValue);
|
|
350
|
+
const responses = context.route["openapi:responses"] as
|
|
351
|
+
| Map<number, ParsedResponseEntry>
|
|
352
|
+
| undefined;
|
|
353
|
+
|
|
354
|
+
if (!responses) {
|
|
355
|
+
return { context, response };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Prefer: code=N — return the response for that status code
|
|
359
|
+
if (prefer.code !== undefined) {
|
|
360
|
+
const entry = responses.get(prefer.code);
|
|
361
|
+
if (entry) {
|
|
362
|
+
const body = entry.schema
|
|
363
|
+
? generateResponseBody(entry.schema, fakerSeed)
|
|
364
|
+
: {};
|
|
365
|
+
return { context, response: [prefer.code, body] };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Prefer: example=name — find a named example across responses
|
|
370
|
+
if (prefer.example !== undefined) {
|
|
371
|
+
for (const [code, entry] of responses) {
|
|
372
|
+
if (entry.examples?.has(prefer.example)) {
|
|
373
|
+
return {
|
|
374
|
+
context,
|
|
375
|
+
response: [code, entry.examples.get(prefer.example)],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Prefer: dynamic=true — regenerate from schema
|
|
382
|
+
if (prefer.dynamic) {
|
|
383
|
+
for (const [code, entry] of responses) {
|
|
384
|
+
if (code >= 200 && code < 300 && entry.schema) {
|
|
385
|
+
const body = generateResponseBody(entry.schema, fakerSeed);
|
|
386
|
+
return { context, response: [code, body] };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { context, response };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ajv = new Ajv({ allErrors: true });
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Validate request body against the spec's requestBody schema.
|
|
398
|
+
* Returns a PluginResult with 400 status if validation fails, or undefined to continue.
|
|
399
|
+
*/
|
|
400
|
+
function validateRequestBody(
|
|
401
|
+
context: Schmock.PluginContext,
|
|
402
|
+
): Schmock.PluginResult | undefined {
|
|
403
|
+
const requestBodySchema = context.route["openapi:requestBody"] as
|
|
404
|
+
| JSONSchema7
|
|
405
|
+
| undefined;
|
|
406
|
+
|
|
407
|
+
if (!requestBodySchema || context.body === undefined) {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const validate = ajv.compile(requestBodySchema);
|
|
412
|
+
if (!validate(context.body)) {
|
|
413
|
+
const errors =
|
|
414
|
+
validate.errors?.map((e) => ({
|
|
415
|
+
path: e.instancePath || "/",
|
|
416
|
+
message: e.message ?? "validation failed",
|
|
417
|
+
keyword: e.keyword,
|
|
418
|
+
})) ?? [];
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
context,
|
|
422
|
+
response: [
|
|
423
|
+
400,
|
|
424
|
+
{
|
|
425
|
+
error: "Request validation failed",
|
|
426
|
+
code: "VALIDATION_ERROR",
|
|
427
|
+
details: errors,
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Resolve a callback URL expression using runtime values.
|
|
438
|
+
* Handles expressions like "{$request.body#/callbackUrl}" and literal URLs.
|
|
439
|
+
*/
|
|
440
|
+
function resolveCallbackUrl(
|
|
441
|
+
expression: string,
|
|
442
|
+
context: Schmock.PluginContext,
|
|
443
|
+
response: unknown,
|
|
444
|
+
): string | undefined {
|
|
445
|
+
// Replace all runtime expression tokens
|
|
446
|
+
return expression.replace(/\{\$([^}]+)\}/g, (_, expr: string) => {
|
|
447
|
+
// $request.body#/path — JSON pointer into request body
|
|
448
|
+
if (expr.startsWith("request.body#")) {
|
|
449
|
+
const pointer = expr.slice("request.body#".length);
|
|
450
|
+
const value = resolveJsonPointer(context.body, pointer);
|
|
451
|
+
return typeof value === "string" ? value : "";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// $request.header.name
|
|
455
|
+
if (expr.startsWith("request.header.")) {
|
|
456
|
+
const headerName = expr.slice("request.header.".length).toLowerCase();
|
|
457
|
+
return context.headers[headerName] ?? "";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// $request.query.name
|
|
461
|
+
if (expr.startsWith("request.query.")) {
|
|
462
|
+
const queryName = expr.slice("request.query.".length);
|
|
463
|
+
return context.query[queryName] ?? "";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// $request.path.param
|
|
467
|
+
if (expr.startsWith("request.path.")) {
|
|
468
|
+
const paramName = expr.slice("request.path.".length);
|
|
469
|
+
return context.params[paramName] ?? "";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// $response.body#/path — JSON pointer into response body
|
|
473
|
+
if (expr.startsWith("response.body#")) {
|
|
474
|
+
const pointer = expr.slice("response.body#".length);
|
|
475
|
+
const responseBody = Array.isArray(response) ? response[1] : response;
|
|
476
|
+
const value = resolveJsonPointer(responseBody, pointer);
|
|
477
|
+
return typeof value === "string" ? value : "";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return "";
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function resolveJsonPointer(obj: unknown, pointer: string): unknown {
|
|
485
|
+
if (!isRecord(obj) || !pointer.startsWith("/")) return undefined;
|
|
486
|
+
|
|
487
|
+
const parts = pointer.slice(1).split("/");
|
|
488
|
+
let current: unknown = obj;
|
|
489
|
+
for (const part of parts) {
|
|
490
|
+
if (!isRecord(current)) return undefined;
|
|
491
|
+
current = current[part];
|
|
492
|
+
}
|
|
493
|
+
return current;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Fire callbacks in a fire-and-forget manner.
|
|
498
|
+
* Silently ignores failures — callbacks are best-effort.
|
|
499
|
+
*/
|
|
500
|
+
function fireCallbacks(
|
|
501
|
+
callbacks: ParsedCallback[],
|
|
502
|
+
context: Schmock.PluginContext,
|
|
503
|
+
response: unknown,
|
|
504
|
+
): void {
|
|
505
|
+
for (const callback of callbacks) {
|
|
506
|
+
const url = resolveCallbackUrl(callback.urlExpression, context, response);
|
|
507
|
+
if (!url || !url.startsWith("http")) continue;
|
|
508
|
+
|
|
509
|
+
const body = Array.isArray(response) ? response[1] : response;
|
|
510
|
+
|
|
511
|
+
// Fire and forget
|
|
512
|
+
void fetch(url, {
|
|
513
|
+
method: callback.method,
|
|
514
|
+
headers: { "content-type": "application/json" },
|
|
515
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
516
|
+
}).catch(() => {
|
|
517
|
+
// Silently ignore callback failures
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function generateResponseBody(schema: JSONSchema7, seed?: number): unknown {
|
|
523
|
+
try {
|
|
524
|
+
return generateFromSchema({ schema, seed });
|
|
525
|
+
} catch {
|
|
526
|
+
return {};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
93
530
|
function registerCrudRoutes(
|
|
94
531
|
instance: Schmock.CallableMockInstance,
|
|
95
532
|
resource: CrudResource,
|
|
96
|
-
seedItems
|
|
533
|
+
seedItems: unknown[] | undefined,
|
|
534
|
+
allParsedPaths: Map<string, ParsedPath>,
|
|
97
535
|
): void {
|
|
98
|
-
// Create a seeded generator wrapper that initializes state on first call
|
|
99
536
|
const ensureSeeded = createSeeder(resource, seedItems);
|
|
100
537
|
|
|
101
538
|
for (const op of resource.operations) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
case "update": {
|
|
122
|
-
const gen = createUpdateGenerator(resource);
|
|
123
|
-
const putKey = `PUT ${resource.itemPath}` as Schmock.RouteKey;
|
|
124
|
-
const patchKey = `PATCH ${resource.itemPath}` as Schmock.RouteKey;
|
|
125
|
-
instance(putKey, wrapWithSeeder(ensureSeeded, gen));
|
|
126
|
-
instance(patchKey, wrapWithSeeder(ensureSeeded, gen));
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
case "delete": {
|
|
130
|
-
const gen = createDeleteGenerator(resource);
|
|
131
|
-
const routeKey = `DELETE ${resource.itemPath}` as Schmock.RouteKey;
|
|
132
|
-
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
133
|
-
break;
|
|
539
|
+
const meta = resource.operationMeta?.get(op);
|
|
540
|
+
const routeEntries = getCrudRouteEntries(op, resource);
|
|
541
|
+
|
|
542
|
+
for (const { routeKey, method } of routeEntries) {
|
|
543
|
+
const gen = createCrudGenerator(op, resource, meta);
|
|
544
|
+
const parsedPath =
|
|
545
|
+
allParsedPaths.get(`${method} ${resource.basePath}`) ??
|
|
546
|
+
allParsedPaths.get(`${method} ${resource.itemPath}`);
|
|
547
|
+
|
|
548
|
+
const config: Schmock.RouteConfig = {};
|
|
549
|
+
if (parsedPath) {
|
|
550
|
+
config["openapi:responses"] = parsedPath.responses;
|
|
551
|
+
config["openapi:path"] = parsedPath.path;
|
|
552
|
+
config["openapi:requestBody"] = parsedPath.requestBody;
|
|
553
|
+
config["openapi:security"] = parsedPath.security;
|
|
554
|
+
config["openapi:callbacks"] = parsedPath.callbacks;
|
|
134
555
|
}
|
|
556
|
+
|
|
557
|
+
instance(routeKey, wrapWithSeeder(ensureSeeded, gen), config);
|
|
135
558
|
}
|
|
136
559
|
}
|
|
137
560
|
}
|
|
138
561
|
|
|
562
|
+
interface RouteEntry {
|
|
563
|
+
routeKey: Schmock.RouteKey;
|
|
564
|
+
method: Schmock.HttpMethod;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function getCrudRouteEntries(
|
|
568
|
+
op: CrudOperation,
|
|
569
|
+
resource: CrudResource,
|
|
570
|
+
): RouteEntry[] {
|
|
571
|
+
switch (op) {
|
|
572
|
+
case "list":
|
|
573
|
+
return [
|
|
574
|
+
{
|
|
575
|
+
routeKey: `GET ${resource.basePath}` as Schmock.RouteKey,
|
|
576
|
+
method: "GET",
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
case "create":
|
|
580
|
+
return [
|
|
581
|
+
{
|
|
582
|
+
routeKey: `POST ${resource.basePath}` as Schmock.RouteKey,
|
|
583
|
+
method: "POST",
|
|
584
|
+
},
|
|
585
|
+
];
|
|
586
|
+
case "read":
|
|
587
|
+
return [
|
|
588
|
+
{
|
|
589
|
+
routeKey: `GET ${resource.itemPath}` as Schmock.RouteKey,
|
|
590
|
+
method: "GET",
|
|
591
|
+
},
|
|
592
|
+
];
|
|
593
|
+
case "update":
|
|
594
|
+
return [
|
|
595
|
+
{
|
|
596
|
+
routeKey: `PUT ${resource.itemPath}` as Schmock.RouteKey,
|
|
597
|
+
method: "PUT",
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
routeKey: `PATCH ${resource.itemPath}` as Schmock.RouteKey,
|
|
601
|
+
method: "PATCH",
|
|
602
|
+
},
|
|
603
|
+
];
|
|
604
|
+
case "delete":
|
|
605
|
+
return [
|
|
606
|
+
{
|
|
607
|
+
routeKey: `DELETE ${resource.itemPath}` as Schmock.RouteKey,
|
|
608
|
+
method: "DELETE",
|
|
609
|
+
},
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function createCrudGenerator(
|
|
615
|
+
op: CrudOperation,
|
|
616
|
+
resource: CrudResource,
|
|
617
|
+
meta?: Schmock.CrudOperationMeta,
|
|
618
|
+
): Schmock.GeneratorFunction {
|
|
619
|
+
switch (op) {
|
|
620
|
+
case "list":
|
|
621
|
+
return createListGenerator(resource, meta);
|
|
622
|
+
case "create":
|
|
623
|
+
return createCreateGenerator(resource, meta);
|
|
624
|
+
case "read":
|
|
625
|
+
return createReadGenerator(resource, meta);
|
|
626
|
+
case "update":
|
|
627
|
+
return createUpdateGenerator(resource, meta);
|
|
628
|
+
case "delete":
|
|
629
|
+
return createDeleteGenerator(resource, meta);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
139
633
|
/**
|
|
140
634
|
* Create a seeder function that initializes collection state once.
|
|
141
635
|
*/
|
|
@@ -153,7 +647,6 @@ function createSeeder(
|
|
|
153
647
|
|
|
154
648
|
if (seedItems && seedItems.length > 0) {
|
|
155
649
|
state[stateKey] = [...seedItems];
|
|
156
|
-
// Set counter to highest existing ID
|
|
157
650
|
let maxId = 0;
|
|
158
651
|
for (const item of seedItems) {
|
|
159
652
|
if (isRecord(item) && resource.idParam in item) {
|
|
@@ -180,3 +673,114 @@ function wrapWithSeeder(
|
|
|
180
673
|
return generator(ctx);
|
|
181
674
|
};
|
|
182
675
|
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Apply manual overrides to a resource's operation metadata.
|
|
679
|
+
*/
|
|
680
|
+
function applyOverrides(
|
|
681
|
+
resource: CrudResource,
|
|
682
|
+
override: Schmock.ResourceOverride,
|
|
683
|
+
): void {
|
|
684
|
+
if (!resource.operationMeta) {
|
|
685
|
+
resource.operationMeta = new Map();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (
|
|
689
|
+
override.listWrapProperty !== undefined ||
|
|
690
|
+
override.listFlat !== undefined
|
|
691
|
+
) {
|
|
692
|
+
const listMeta = resource.operationMeta.get("list") ?? {};
|
|
693
|
+
|
|
694
|
+
if (override.listFlat) {
|
|
695
|
+
delete listMeta.responseSchema;
|
|
696
|
+
} else if (override.listWrapProperty && listMeta.responseSchema) {
|
|
697
|
+
const arrayInfo = findArrayProperty(listMeta.responseSchema);
|
|
698
|
+
if (
|
|
699
|
+
!arrayInfo.property ||
|
|
700
|
+
arrayInfo.property !== override.listWrapProperty
|
|
701
|
+
) {
|
|
702
|
+
const itemSchema = arrayInfo.itemSchema ?? resource.schema ?? {};
|
|
703
|
+
listMeta.responseSchema = {
|
|
704
|
+
type: "object",
|
|
705
|
+
properties: {
|
|
706
|
+
[override.listWrapProperty]: {
|
|
707
|
+
type: "array",
|
|
708
|
+
items: itemSchema,
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
} else if (override.listWrapProperty) {
|
|
714
|
+
const itemSchema = resource.schema ?? {};
|
|
715
|
+
listMeta.responseSchema = {
|
|
716
|
+
type: "object",
|
|
717
|
+
properties: {
|
|
718
|
+
[override.listWrapProperty]: {
|
|
719
|
+
type: "array",
|
|
720
|
+
items: itemSchema,
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
resource.operationMeta.set("list", listMeta);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (override.errorSchema) {
|
|
730
|
+
const errorSchemaMap = new Map<number, JSONSchema7>();
|
|
731
|
+
errorSchemaMap.set(404, override.errorSchema);
|
|
732
|
+
errorSchemaMap.set(400, override.errorSchema);
|
|
733
|
+
errorSchemaMap.set(409, override.errorSchema);
|
|
734
|
+
|
|
735
|
+
for (const op of ["read", "update", "delete"] as CrudOperation[]) {
|
|
736
|
+
const meta = resource.operationMeta.get(op) ?? {};
|
|
737
|
+
meta.errorSchemas = errorSchemaMap;
|
|
738
|
+
resource.operationMeta.set(op, meta);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Log resource detection info for debug mode.
|
|
745
|
+
*/
|
|
746
|
+
function logResourceDetection(
|
|
747
|
+
resource: CrudResource,
|
|
748
|
+
override?: Schmock.ResourceOverride,
|
|
749
|
+
): void {
|
|
750
|
+
const listMeta = resource.operationMeta?.get("list");
|
|
751
|
+
let listFormat = "flat";
|
|
752
|
+
if (listMeta?.responseSchema) {
|
|
753
|
+
const arrayInfo = findArrayProperty(listMeta.responseSchema);
|
|
754
|
+
if (arrayInfo.property) {
|
|
755
|
+
const hasAllOf = "allOf" in (listMeta.responseSchema ?? {});
|
|
756
|
+
listFormat = `wrapped("${arrayInfo.property}"${hasAllOf ? " via allOf" : ""})`;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const readMeta = resource.operationMeta?.get("read");
|
|
761
|
+
const errorFormat = readMeta?.errorSchemas?.has(404)
|
|
762
|
+
? "schema(404)"
|
|
763
|
+
: "default";
|
|
764
|
+
|
|
765
|
+
const headerCount = listMeta?.responseHeaders
|
|
766
|
+
? Object.keys(listMeta.responseHeaders).length
|
|
767
|
+
: 0;
|
|
768
|
+
|
|
769
|
+
console.log(
|
|
770
|
+
`[@schmock/openapi] ${resource.name}: list=${listFormat}, error=${errorFormat}, headers=${headerCount}`,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
if (override) {
|
|
774
|
+
const definedKeys: string[] = [];
|
|
775
|
+
if (override.listWrapProperty !== undefined)
|
|
776
|
+
definedKeys.push("listWrapProperty");
|
|
777
|
+
if (override.listFlat !== undefined) definedKeys.push("listFlat");
|
|
778
|
+
if (override.errorSchema !== undefined) definedKeys.push("errorSchema");
|
|
779
|
+
|
|
780
|
+
if (definedKeys.length > 0) {
|
|
781
|
+
console.log(
|
|
782
|
+
`[@schmock/openapi] Override applied: ${resource.name}.${definedKeys.join(", ")}`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|