@schmock/openapi 1.2.1 → 1.7.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 +6 -4
- 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/dist/plugin.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
/// <reference path="
|
|
1
|
+
/// <reference path="../../core/schmock.d.ts" />
|
|
2
|
+
import { generateFromSchema } from "@schmock/faker";
|
|
3
|
+
import Ajv from "ajv";
|
|
4
|
+
import { negotiateContentType } from "./content-negotiation.js";
|
|
2
5
|
import { detectCrudResources } from "./crud-detector.js";
|
|
3
|
-
import { createCreateGenerator, createDeleteGenerator, createListGenerator, createReadGenerator, createStaticGenerator, createUpdateGenerator, } from "./generators.js";
|
|
6
|
+
import { createCreateGenerator, createDeleteGenerator, createListGenerator, createReadGenerator, createStaticGenerator, createUpdateGenerator, findArrayProperty, } from "./generators.js";
|
|
4
7
|
import { parseSpec } from "./parser.js";
|
|
8
|
+
import { parsePreferHeader } from "./prefer.js";
|
|
5
9
|
import { loadSeed } from "./seed.js";
|
|
6
10
|
import { isRecord } from "./utils.js";
|
|
7
11
|
/**
|
|
@@ -22,72 +26,439 @@ export async function openapi(options) {
|
|
|
22
26
|
const seedData = options.seed
|
|
23
27
|
? loadSeed(options.seed, resources)
|
|
24
28
|
: new Map();
|
|
29
|
+
// Build a lookup of all parsed paths for process() to reference
|
|
30
|
+
const allParsedPaths = new Map();
|
|
31
|
+
for (const pp of [...spec.paths]) {
|
|
32
|
+
allParsedPaths.set(`${pp.method} ${pp.path}`, pp);
|
|
33
|
+
}
|
|
34
|
+
// Security scheme lookup
|
|
35
|
+
const securitySchemes = spec.securitySchemes;
|
|
36
|
+
const globalSecurity = spec.globalSecurity;
|
|
25
37
|
return {
|
|
26
38
|
name: "@schmock/openapi",
|
|
27
|
-
version: "1.
|
|
39
|
+
version: "1.4.0",
|
|
28
40
|
install(instance) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
// data through a setup route that runs on first access, or we just seed
|
|
34
|
-
// in the generators themselves.
|
|
35
|
-
// Register CRUD routes
|
|
41
|
+
if (options.debug) {
|
|
42
|
+
console.log(`[@schmock/openapi] Detected ${resources.length} CRUD resources, ${nonCrudPaths.length} static routes`);
|
|
43
|
+
}
|
|
44
|
+
// Register CRUD routes with metadata
|
|
36
45
|
for (const resource of resources) {
|
|
37
|
-
|
|
46
|
+
const override = options.resources?.[resource.name];
|
|
47
|
+
if (override) {
|
|
48
|
+
applyOverrides(resource, override);
|
|
49
|
+
}
|
|
50
|
+
if (options.debug) {
|
|
51
|
+
logResourceDetection(resource, override);
|
|
52
|
+
}
|
|
53
|
+
registerCrudRoutes(instance, resource, seedData.get(resource.name), allParsedPaths);
|
|
38
54
|
}
|
|
39
55
|
// Register non-CRUD routes with static generators
|
|
40
56
|
for (const parsedPath of nonCrudPaths) {
|
|
41
57
|
const routeKey = `${parsedPath.method} ${parsedPath.path}`;
|
|
42
|
-
|
|
58
|
+
const config = {
|
|
59
|
+
"openapi:responses": parsedPath.responses,
|
|
60
|
+
"openapi:path": parsedPath.path,
|
|
61
|
+
"openapi:requestBody": parsedPath.requestBody,
|
|
62
|
+
"openapi:security": parsedPath.security,
|
|
63
|
+
"openapi:callbacks": parsedPath.callbacks,
|
|
64
|
+
};
|
|
65
|
+
instance(routeKey, createStaticGenerator(parsedPath, options.fakerSeed), config);
|
|
43
66
|
}
|
|
44
67
|
},
|
|
45
68
|
process(context, response) {
|
|
46
|
-
//
|
|
47
|
-
|
|
69
|
+
// 1. Security validation (if enabled)
|
|
70
|
+
if (options.security && securitySchemes) {
|
|
71
|
+
const securityResult = validateSecurity(context, securitySchemes, globalSecurity);
|
|
72
|
+
if (securityResult)
|
|
73
|
+
return securityResult;
|
|
74
|
+
}
|
|
75
|
+
// 2. Content negotiation
|
|
76
|
+
const contentResult = processContentNegotiation(context);
|
|
77
|
+
if (contentResult)
|
|
78
|
+
return contentResult;
|
|
79
|
+
// 3. Request validation (if enabled)
|
|
80
|
+
if (options.validateRequests) {
|
|
81
|
+
const validationResult = validateRequestBody(context);
|
|
82
|
+
if (validationResult) {
|
|
83
|
+
return validationResult;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 4. Prefer header handling
|
|
87
|
+
const result = processPreferHeader(context, response, options.fakerSeed);
|
|
88
|
+
// 5. Fire callbacks (fire-and-forget, after response is determined)
|
|
89
|
+
const callbacks = context.route["openapi:callbacks"];
|
|
90
|
+
if (callbacks && callbacks.length > 0) {
|
|
91
|
+
fireCallbacks(callbacks, context, result.response);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
48
94
|
},
|
|
49
95
|
};
|
|
50
96
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Validate security requirements for the request.
|
|
99
|
+
* Returns 401 if auth is required and missing/invalid.
|
|
100
|
+
*/
|
|
101
|
+
function validateSecurity(context, schemes, globalSecurity) {
|
|
102
|
+
// Determine applicable security: operation-level overrides global
|
|
103
|
+
const routeSecurity = context.route["openapi:security"];
|
|
104
|
+
const security = routeSecurity ?? globalSecurity;
|
|
105
|
+
// No security requirements
|
|
106
|
+
if (!security || security.length === 0)
|
|
107
|
+
return undefined;
|
|
108
|
+
// Check each OR group — if any group passes, request is authorized
|
|
109
|
+
for (const group of security) {
|
|
110
|
+
// Empty group = public endpoint (security: [{}])
|
|
111
|
+
if (group.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
// All schemes in the group must pass (AND)
|
|
114
|
+
const allPass = group.every((schemeName) => {
|
|
115
|
+
const scheme = schemes.get(schemeName);
|
|
116
|
+
if (!scheme)
|
|
117
|
+
return false;
|
|
118
|
+
return checkSchemePresence(scheme, context.headers);
|
|
119
|
+
});
|
|
120
|
+
if (allPass)
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
// No group passed — build WWW-Authenticate header
|
|
124
|
+
const wwwAuth = buildWwwAuthenticate(security, schemes);
|
|
125
|
+
const headers = {};
|
|
126
|
+
if (wwwAuth) {
|
|
127
|
+
headers["www-authenticate"] = wwwAuth;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
context,
|
|
131
|
+
response: [
|
|
132
|
+
401,
|
|
133
|
+
{
|
|
134
|
+
error: "Unauthorized",
|
|
135
|
+
code: "UNAUTHORIZED",
|
|
136
|
+
},
|
|
137
|
+
headers,
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function checkSchemePresence(scheme, headers) {
|
|
142
|
+
if (scheme.type === "http") {
|
|
143
|
+
const auth = headers.authorization ?? headers.Authorization ?? "";
|
|
144
|
+
if (scheme.scheme === "bearer") {
|
|
145
|
+
return auth.toLowerCase().startsWith("bearer ");
|
|
146
|
+
}
|
|
147
|
+
if (scheme.scheme === "basic") {
|
|
148
|
+
return auth.toLowerCase().startsWith("basic ");
|
|
149
|
+
}
|
|
150
|
+
// Other http schemes — just check authorization exists
|
|
151
|
+
return auth.length > 0;
|
|
152
|
+
}
|
|
153
|
+
if (scheme.type === "apiKey") {
|
|
154
|
+
if (scheme.in === "header" && scheme.name) {
|
|
155
|
+
const headerName = scheme.name.toLowerCase();
|
|
156
|
+
return (headers[headerName] ?? headers[scheme.name] ?? "") !== "";
|
|
157
|
+
}
|
|
158
|
+
// query and cookie api keys can't be checked from headers alone
|
|
159
|
+
// For simplicity, pass through (they'd need query/cookie context)
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
// oauth2 / openIdConnect — just check for bearer token
|
|
163
|
+
if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
|
|
164
|
+
const auth = headers.authorization ?? headers.Authorization ?? "";
|
|
165
|
+
return auth.toLowerCase().startsWith("bearer ");
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
function buildWwwAuthenticate(security, schemes) {
|
|
170
|
+
const challenges = [];
|
|
171
|
+
for (const group of security) {
|
|
172
|
+
for (const schemeName of group) {
|
|
173
|
+
const scheme = schemes.get(schemeName);
|
|
174
|
+
if (!scheme)
|
|
175
|
+
continue;
|
|
176
|
+
if (scheme.type === "http" && scheme.scheme === "bearer") {
|
|
177
|
+
if (!challenges.includes("Bearer"))
|
|
178
|
+
challenges.push("Bearer");
|
|
61
179
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
66
|
-
break;
|
|
180
|
+
else if (scheme.type === "http" && scheme.scheme === "basic") {
|
|
181
|
+
if (!challenges.includes("Basic"))
|
|
182
|
+
challenges.push("Basic");
|
|
67
183
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
72
|
-
break;
|
|
184
|
+
else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
|
|
185
|
+
if (!challenges.includes("Bearer"))
|
|
186
|
+
challenges.push("Bearer");
|
|
73
187
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return challenges.join(", ");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check Accept header against available content types. Returns 406 if no match.
|
|
194
|
+
*/
|
|
195
|
+
function processContentNegotiation(context) {
|
|
196
|
+
const accept = context.headers.accept ?? context.headers.Accept;
|
|
197
|
+
if (!accept || accept === "*/*")
|
|
198
|
+
return undefined;
|
|
199
|
+
const responses = context.route["openapi:responses"];
|
|
200
|
+
if (!responses)
|
|
201
|
+
return undefined;
|
|
202
|
+
// Collect all available content types across responses
|
|
203
|
+
const allContentTypes = new Set();
|
|
204
|
+
for (const entry of responses.values()) {
|
|
205
|
+
if (entry.contentTypes) {
|
|
206
|
+
for (const ct of entry.contentTypes) {
|
|
207
|
+
allContentTypes.add(ct);
|
|
81
208
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// No content types defined in spec → skip negotiation
|
|
212
|
+
if (allContentTypes.size === 0)
|
|
213
|
+
return undefined;
|
|
214
|
+
const matched = negotiateContentType(accept, [...allContentTypes]);
|
|
215
|
+
if (!matched) {
|
|
216
|
+
return {
|
|
217
|
+
context,
|
|
218
|
+
response: [
|
|
219
|
+
406,
|
|
220
|
+
{
|
|
221
|
+
error: "Not Acceptable",
|
|
222
|
+
code: "NOT_ACCEPTABLE",
|
|
223
|
+
acceptable: [...allContentTypes],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Handle Prefer header directives: code=N, example=name, dynamic=true
|
|
232
|
+
*/
|
|
233
|
+
function processPreferHeader(context, response, fakerSeed) {
|
|
234
|
+
const preferValue = context.headers.prefer ?? context.headers.Prefer;
|
|
235
|
+
if (!preferValue) {
|
|
236
|
+
return { context, response };
|
|
237
|
+
}
|
|
238
|
+
const prefer = parsePreferHeader(preferValue);
|
|
239
|
+
const responses = context.route["openapi:responses"];
|
|
240
|
+
if (!responses) {
|
|
241
|
+
return { context, response };
|
|
242
|
+
}
|
|
243
|
+
// Prefer: code=N — return the response for that status code
|
|
244
|
+
if (prefer.code !== undefined) {
|
|
245
|
+
const entry = responses.get(prefer.code);
|
|
246
|
+
if (entry) {
|
|
247
|
+
const body = entry.schema
|
|
248
|
+
? generateResponseBody(entry.schema, fakerSeed)
|
|
249
|
+
: {};
|
|
250
|
+
return { context, response: [prefer.code, body] };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Prefer: example=name — find a named example across responses
|
|
254
|
+
if (prefer.example !== undefined) {
|
|
255
|
+
for (const [code, entry] of responses) {
|
|
256
|
+
if (entry.examples?.has(prefer.example)) {
|
|
257
|
+
return {
|
|
258
|
+
context,
|
|
259
|
+
response: [code, entry.examples.get(prefer.example)],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Prefer: dynamic=true — regenerate from schema
|
|
265
|
+
if (prefer.dynamic) {
|
|
266
|
+
for (const [code, entry] of responses) {
|
|
267
|
+
if (code >= 200 && code < 300 && entry.schema) {
|
|
268
|
+
const body = generateResponseBody(entry.schema, fakerSeed);
|
|
269
|
+
return { context, response: [code, body] };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { context, response };
|
|
274
|
+
}
|
|
275
|
+
const ajv = new Ajv({ allErrors: true });
|
|
276
|
+
/**
|
|
277
|
+
* Validate request body against the spec's requestBody schema.
|
|
278
|
+
* Returns a PluginResult with 400 status if validation fails, or undefined to continue.
|
|
279
|
+
*/
|
|
280
|
+
function validateRequestBody(context) {
|
|
281
|
+
const requestBodySchema = context.route["openapi:requestBody"];
|
|
282
|
+
if (!requestBodySchema || context.body === undefined) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
const validate = ajv.compile(requestBodySchema);
|
|
286
|
+
if (!validate(context.body)) {
|
|
287
|
+
const errors = validate.errors?.map((e) => ({
|
|
288
|
+
path: e.instancePath || "/",
|
|
289
|
+
message: e.message ?? "validation failed",
|
|
290
|
+
keyword: e.keyword,
|
|
291
|
+
})) ?? [];
|
|
292
|
+
return {
|
|
293
|
+
context,
|
|
294
|
+
response: [
|
|
295
|
+
400,
|
|
296
|
+
{
|
|
297
|
+
error: "Request validation failed",
|
|
298
|
+
code: "VALIDATION_ERROR",
|
|
299
|
+
details: errors,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Resolve a callback URL expression using runtime values.
|
|
308
|
+
* Handles expressions like "{$request.body#/callbackUrl}" and literal URLs.
|
|
309
|
+
*/
|
|
310
|
+
function resolveCallbackUrl(expression, context, response) {
|
|
311
|
+
// Replace all runtime expression tokens
|
|
312
|
+
return expression.replace(/\{\$([^}]+)\}/g, (_, expr) => {
|
|
313
|
+
// $request.body#/path — JSON pointer into request body
|
|
314
|
+
if (expr.startsWith("request.body#")) {
|
|
315
|
+
const pointer = expr.slice("request.body#".length);
|
|
316
|
+
const value = resolveJsonPointer(context.body, pointer);
|
|
317
|
+
return typeof value === "string" ? value : "";
|
|
318
|
+
}
|
|
319
|
+
// $request.header.name
|
|
320
|
+
if (expr.startsWith("request.header.")) {
|
|
321
|
+
const headerName = expr.slice("request.header.".length).toLowerCase();
|
|
322
|
+
return context.headers[headerName] ?? "";
|
|
323
|
+
}
|
|
324
|
+
// $request.query.name
|
|
325
|
+
if (expr.startsWith("request.query.")) {
|
|
326
|
+
const queryName = expr.slice("request.query.".length);
|
|
327
|
+
return context.query[queryName] ?? "";
|
|
328
|
+
}
|
|
329
|
+
// $request.path.param
|
|
330
|
+
if (expr.startsWith("request.path.")) {
|
|
331
|
+
const paramName = expr.slice("request.path.".length);
|
|
332
|
+
return context.params[paramName] ?? "";
|
|
333
|
+
}
|
|
334
|
+
// $response.body#/path — JSON pointer into response body
|
|
335
|
+
if (expr.startsWith("response.body#")) {
|
|
336
|
+
const pointer = expr.slice("response.body#".length);
|
|
337
|
+
const responseBody = Array.isArray(response) ? response[1] : response;
|
|
338
|
+
const value = resolveJsonPointer(responseBody, pointer);
|
|
339
|
+
return typeof value === "string" ? value : "";
|
|
340
|
+
}
|
|
341
|
+
return "";
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
function resolveJsonPointer(obj, pointer) {
|
|
345
|
+
if (!isRecord(obj) || !pointer.startsWith("/"))
|
|
346
|
+
return undefined;
|
|
347
|
+
const parts = pointer.slice(1).split("/");
|
|
348
|
+
let current = obj;
|
|
349
|
+
for (const part of parts) {
|
|
350
|
+
if (!isRecord(current))
|
|
351
|
+
return undefined;
|
|
352
|
+
current = current[part];
|
|
353
|
+
}
|
|
354
|
+
return current;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Fire callbacks in a fire-and-forget manner.
|
|
358
|
+
* Silently ignores failures — callbacks are best-effort.
|
|
359
|
+
*/
|
|
360
|
+
function fireCallbacks(callbacks, context, response) {
|
|
361
|
+
for (const callback of callbacks) {
|
|
362
|
+
const url = resolveCallbackUrl(callback.urlExpression, context, response);
|
|
363
|
+
if (!url || !url.startsWith("http"))
|
|
364
|
+
continue;
|
|
365
|
+
const body = Array.isArray(response) ? response[1] : response;
|
|
366
|
+
// Fire and forget
|
|
367
|
+
void fetch(url, {
|
|
368
|
+
method: callback.method,
|
|
369
|
+
headers: { "content-type": "application/json" },
|
|
370
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
371
|
+
}).catch(() => {
|
|
372
|
+
// Silently ignore callback failures
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function generateResponseBody(schema, seed) {
|
|
377
|
+
try {
|
|
378
|
+
return generateFromSchema({ schema, seed });
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return {};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function registerCrudRoutes(instance, resource, seedItems, allParsedPaths) {
|
|
385
|
+
const ensureSeeded = createSeeder(resource, seedItems);
|
|
386
|
+
for (const op of resource.operations) {
|
|
387
|
+
const meta = resource.operationMeta?.get(op);
|
|
388
|
+
const routeEntries = getCrudRouteEntries(op, resource);
|
|
389
|
+
for (const { routeKey, method } of routeEntries) {
|
|
390
|
+
const gen = createCrudGenerator(op, resource, meta);
|
|
391
|
+
const parsedPath = allParsedPaths.get(`${method} ${resource.basePath}`) ??
|
|
392
|
+
allParsedPaths.get(`${method} ${resource.itemPath}`);
|
|
393
|
+
const config = {};
|
|
394
|
+
if (parsedPath) {
|
|
395
|
+
config["openapi:responses"] = parsedPath.responses;
|
|
396
|
+
config["openapi:path"] = parsedPath.path;
|
|
397
|
+
config["openapi:requestBody"] = parsedPath.requestBody;
|
|
398
|
+
config["openapi:security"] = parsedPath.security;
|
|
399
|
+
config["openapi:callbacks"] = parsedPath.callbacks;
|
|
87
400
|
}
|
|
401
|
+
instance(routeKey, wrapWithSeeder(ensureSeeded, gen), config);
|
|
88
402
|
}
|
|
89
403
|
}
|
|
90
404
|
}
|
|
405
|
+
function getCrudRouteEntries(op, resource) {
|
|
406
|
+
switch (op) {
|
|
407
|
+
case "list":
|
|
408
|
+
return [
|
|
409
|
+
{
|
|
410
|
+
routeKey: `GET ${resource.basePath}`,
|
|
411
|
+
method: "GET",
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
case "create":
|
|
415
|
+
return [
|
|
416
|
+
{
|
|
417
|
+
routeKey: `POST ${resource.basePath}`,
|
|
418
|
+
method: "POST",
|
|
419
|
+
},
|
|
420
|
+
];
|
|
421
|
+
case "read":
|
|
422
|
+
return [
|
|
423
|
+
{
|
|
424
|
+
routeKey: `GET ${resource.itemPath}`,
|
|
425
|
+
method: "GET",
|
|
426
|
+
},
|
|
427
|
+
];
|
|
428
|
+
case "update":
|
|
429
|
+
return [
|
|
430
|
+
{
|
|
431
|
+
routeKey: `PUT ${resource.itemPath}`,
|
|
432
|
+
method: "PUT",
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
routeKey: `PATCH ${resource.itemPath}`,
|
|
436
|
+
method: "PATCH",
|
|
437
|
+
},
|
|
438
|
+
];
|
|
439
|
+
case "delete":
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
routeKey: `DELETE ${resource.itemPath}`,
|
|
443
|
+
method: "DELETE",
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function createCrudGenerator(op, resource, meta) {
|
|
449
|
+
switch (op) {
|
|
450
|
+
case "list":
|
|
451
|
+
return createListGenerator(resource, meta);
|
|
452
|
+
case "create":
|
|
453
|
+
return createCreateGenerator(resource, meta);
|
|
454
|
+
case "read":
|
|
455
|
+
return createReadGenerator(resource, meta);
|
|
456
|
+
case "update":
|
|
457
|
+
return createUpdateGenerator(resource, meta);
|
|
458
|
+
case "delete":
|
|
459
|
+
return createDeleteGenerator(resource, meta);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
91
462
|
/**
|
|
92
463
|
* Create a seeder function that initializes collection state once.
|
|
93
464
|
*/
|
|
@@ -101,7 +472,6 @@ function createSeeder(resource, seedItems) {
|
|
|
101
472
|
state[seededKey] = true;
|
|
102
473
|
if (seedItems && seedItems.length > 0) {
|
|
103
474
|
state[stateKey] = [...seedItems];
|
|
104
|
-
// Set counter to highest existing ID
|
|
105
475
|
let maxId = 0;
|
|
106
476
|
for (const item of seedItems) {
|
|
107
477
|
if (isRecord(item) && resource.idParam in item) {
|
|
@@ -125,3 +495,92 @@ function wrapWithSeeder(seeder, generator) {
|
|
|
125
495
|
return generator(ctx);
|
|
126
496
|
};
|
|
127
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Apply manual overrides to a resource's operation metadata.
|
|
500
|
+
*/
|
|
501
|
+
function applyOverrides(resource, override) {
|
|
502
|
+
if (!resource.operationMeta) {
|
|
503
|
+
resource.operationMeta = new Map();
|
|
504
|
+
}
|
|
505
|
+
if (override.listWrapProperty !== undefined ||
|
|
506
|
+
override.listFlat !== undefined) {
|
|
507
|
+
const listMeta = resource.operationMeta.get("list") ?? {};
|
|
508
|
+
if (override.listFlat) {
|
|
509
|
+
delete listMeta.responseSchema;
|
|
510
|
+
}
|
|
511
|
+
else if (override.listWrapProperty && listMeta.responseSchema) {
|
|
512
|
+
const arrayInfo = findArrayProperty(listMeta.responseSchema);
|
|
513
|
+
if (!arrayInfo.property ||
|
|
514
|
+
arrayInfo.property !== override.listWrapProperty) {
|
|
515
|
+
const itemSchema = arrayInfo.itemSchema ?? resource.schema ?? {};
|
|
516
|
+
listMeta.responseSchema = {
|
|
517
|
+
type: "object",
|
|
518
|
+
properties: {
|
|
519
|
+
[override.listWrapProperty]: {
|
|
520
|
+
type: "array",
|
|
521
|
+
items: itemSchema,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (override.listWrapProperty) {
|
|
528
|
+
const itemSchema = resource.schema ?? {};
|
|
529
|
+
listMeta.responseSchema = {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
[override.listWrapProperty]: {
|
|
533
|
+
type: "array",
|
|
534
|
+
items: itemSchema,
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
resource.operationMeta.set("list", listMeta);
|
|
540
|
+
}
|
|
541
|
+
if (override.errorSchema) {
|
|
542
|
+
const errorSchemaMap = new Map();
|
|
543
|
+
errorSchemaMap.set(404, override.errorSchema);
|
|
544
|
+
errorSchemaMap.set(400, override.errorSchema);
|
|
545
|
+
errorSchemaMap.set(409, override.errorSchema);
|
|
546
|
+
for (const op of ["read", "update", "delete"]) {
|
|
547
|
+
const meta = resource.operationMeta.get(op) ?? {};
|
|
548
|
+
meta.errorSchemas = errorSchemaMap;
|
|
549
|
+
resource.operationMeta.set(op, meta);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Log resource detection info for debug mode.
|
|
555
|
+
*/
|
|
556
|
+
function logResourceDetection(resource, override) {
|
|
557
|
+
const listMeta = resource.operationMeta?.get("list");
|
|
558
|
+
let listFormat = "flat";
|
|
559
|
+
if (listMeta?.responseSchema) {
|
|
560
|
+
const arrayInfo = findArrayProperty(listMeta.responseSchema);
|
|
561
|
+
if (arrayInfo.property) {
|
|
562
|
+
const hasAllOf = "allOf" in (listMeta.responseSchema ?? {});
|
|
563
|
+
listFormat = `wrapped("${arrayInfo.property}"${hasAllOf ? " via allOf" : ""})`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const readMeta = resource.operationMeta?.get("read");
|
|
567
|
+
const errorFormat = readMeta?.errorSchemas?.has(404)
|
|
568
|
+
? "schema(404)"
|
|
569
|
+
: "default";
|
|
570
|
+
const headerCount = listMeta?.responseHeaders
|
|
571
|
+
? Object.keys(listMeta.responseHeaders).length
|
|
572
|
+
: 0;
|
|
573
|
+
console.log(`[@schmock/openapi] ${resource.name}: list=${listFormat}, error=${errorFormat}, headers=${headerCount}`);
|
|
574
|
+
if (override) {
|
|
575
|
+
const definedKeys = [];
|
|
576
|
+
if (override.listWrapProperty !== undefined)
|
|
577
|
+
definedKeys.push("listWrapProperty");
|
|
578
|
+
if (override.listFlat !== undefined)
|
|
579
|
+
definedKeys.push("listFlat");
|
|
580
|
+
if (override.errorSchema !== undefined)
|
|
581
|
+
definedKeys.push("errorSchema");
|
|
582
|
+
if (definedKeys.length > 0) {
|
|
583
|
+
console.log(`[@schmock/openapi] Override applied: ${resource.name}.${definedKeys.join(", ")}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
package/dist/prefer.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface PreferDirectives {
|
|
2
|
+
code?: number;
|
|
3
|
+
example?: string;
|
|
4
|
+
dynamic?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse the RFC 7240 Prefer header for mock-specific directives.
|
|
8
|
+
* Supports: code=N, example=name, dynamic=true
|
|
9
|
+
*/
|
|
10
|
+
export declare function parsePreferHeader(value: string): PreferDirectives;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=prefer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prefer.d.ts","sourceRoot":"","sources":["../src/prefer.ts"],"names":[],"mappings":"AAEA,UAAU,gBAAgB;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,CAwBjE"}
|
package/dist/prefer.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference path="../../core/schmock.d.ts" />
|
|
2
|
+
/**
|
|
3
|
+
* Parse the RFC 7240 Prefer header for mock-specific directives.
|
|
4
|
+
* Supports: code=N, example=name, dynamic=true
|
|
5
|
+
*/
|
|
6
|
+
export function parsePreferHeader(value) {
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const part of value.split(",")) {
|
|
9
|
+
const trimmed = part.trim();
|
|
10
|
+
const codeMatch = trimmed.match(/^code\s*=\s*(\d+)$/);
|
|
11
|
+
if (codeMatch) {
|
|
12
|
+
result.code = Number(codeMatch[1]);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const exampleMatch = trimmed.match(/^example\s*=\s*(.+)$/);
|
|
16
|
+
if (exampleMatch) {
|
|
17
|
+
result.example = exampleMatch[1].trim();
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (trimmed === "dynamic=true" || trimmed === "dynamic") {
|
|
21
|
+
result.dynamic = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
package/dist/seed.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schmock/openapi",
|
|
3
3
|
"description": "OpenAPI/Swagger spec-driven auto-registration plugin for Schmock",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -25,15 +25,17 @@
|
|
|
25
25
|
"test:watch": "vitest --watch",
|
|
26
26
|
"test:bdd": "vitest run --config vitest.config.bdd.ts",
|
|
27
27
|
"lint": "biome check src/*.ts",
|
|
28
|
-
"lint:fix": "biome check --write --unsafe src/*.ts"
|
|
28
|
+
"lint:fix": "biome check --write --unsafe src/*.ts",
|
|
29
|
+
"check:publish": "publint && attw --pack --ignore-rules cjs-resolves-to-esm"
|
|
29
30
|
},
|
|
30
31
|
"license": "MIT",
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@apidevtools/swagger-parser": "^12.0.0",
|
|
33
|
-
"@schmock/
|
|
34
|
+
"@schmock/faker": "^1.7.0",
|
|
35
|
+
"ajv": "^8.17.1"
|
|
34
36
|
},
|
|
35
37
|
"peerDependencies": {
|
|
36
|
-
"@schmock/core": "^1.
|
|
38
|
+
"@schmock/core": "^1.7.0"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
39
41
|
"@amiceli/vitest-cucumber": "^6.2.0",
|