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