@schmock/openapi 1.2.0 → 1.4.0

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