@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/src/plugin.ts CHANGED
@@ -1,6 +1,10 @@
1
- /// <reference path="../../../types/schmock.d.ts" />
1
+ /// <reference path="../../core/schmock.d.ts" />
2
2
 
3
- import type { CrudResource } from "./crud-detector.js";
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: true) */
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.1.1",
90
+ version: "1.4.0",
61
91
 
62
92
  install(instance: Schmock.CallableMockInstance) {
63
- // Seed initial state — we need state to be initialized before registering routes
64
- // The state is populated via generator context.state on first request,
65
- // but we need to pre-populate it. We'll use a global config state approach.
66
- // Since generators use ctx.state (the global config state), we set up seed
67
- // data through a setup route that runs on first access, or we just seed
68
- // in the generators themselves.
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
- registerCrudRoutes(instance, resource, seedData.get(resource.name));
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
- instance(routeKey, createStaticGenerator(parsedPath));
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
- // Pass through generators handle everything
88
- return { context, response };
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?: unknown[],
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
- switch (op) {
103
- case "list": {
104
- const gen = createListGenerator(resource);
105
- const routeKey = `GET ${resource.basePath}` as Schmock.RouteKey;
106
- instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
107
- break;
108
- }
109
- case "create": {
110
- const gen = createCreateGenerator(resource);
111
- const routeKey = `POST ${resource.basePath}` as Schmock.RouteKey;
112
- instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
113
- break;
114
- }
115
- case "read": {
116
- const gen = createReadGenerator(resource);
117
- const routeKey = `GET ${resource.itemPath}` as Schmock.RouteKey;
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
+ }