@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/parser.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 SwaggerParser from "@apidevtools/swagger-parser";
4
4
  import { toHttpMethod } from "@schmock/core";
@@ -7,11 +7,40 @@ import type { OpenAPI } from "openapi-types";
7
7
  import { normalizeSchema } from "./normalizer.js";
8
8
  import { isRecord } from "./utils.js";
9
9
 
10
+ export interface SecurityScheme {
11
+ type: "apiKey" | "http" | "oauth2" | "openIdConnect";
12
+ /** For apiKey: header, query, or cookie */
13
+ in?: "header" | "query" | "cookie";
14
+ /** For apiKey: the header/query/cookie name */
15
+ name?: string;
16
+ /** For http: bearer, basic, etc. */
17
+ scheme?: string;
18
+ }
19
+
10
20
  export interface ParsedSpec {
11
21
  title: string;
12
22
  version: string;
13
23
  basePath: string;
14
24
  paths: ParsedPath[];
25
+ securitySchemes?: Map<string, SecurityScheme>;
26
+ globalSecurity?: string[][];
27
+ }
28
+
29
+ export interface ParsedResponseEntry {
30
+ schema?: JSONSchema7;
31
+ description: string;
32
+ headers?: Record<string, Schmock.ResponseHeaderDef>;
33
+ examples?: Map<string, unknown>;
34
+ contentTypes?: string[];
35
+ }
36
+
37
+ export interface ParsedCallback {
38
+ /** Runtime expression for the callback URL (e.g. "{$request.body#/callbackUrl}") */
39
+ urlExpression: string;
40
+ /** HTTP method for the callback request */
41
+ method: Schmock.HttpMethod;
42
+ /** JSON Schema for the callback request body */
43
+ requestBody?: JSONSchema7;
15
44
  }
16
45
 
17
46
  export interface ParsedPath {
@@ -21,8 +50,12 @@ export interface ParsedPath {
21
50
  operationId?: string;
22
51
  parameters: ParsedParameter[];
23
52
  requestBody?: JSONSchema7;
24
- responses: Map<number, { schema?: JSONSchema7; description: string }>;
53
+ responses: Map<number, ParsedResponseEntry>;
25
54
  tags: string[];
55
+ /** Per-operation security requirements (each entry is OR, keys within are AND) */
56
+ security?: string[][];
57
+ /** OAS3 callbacks defined on this operation */
58
+ callbacks?: ParsedCallback[];
26
59
  }
27
60
 
28
61
  export interface ParsedParameter {
@@ -127,12 +160,19 @@ export async function parseSpec(source: string | object): Promise<ParsedSpec> {
127
160
  basePath = basePath.slice(0, -1);
128
161
  }
129
162
 
163
+ // Extract security schemes
164
+ const securitySchemes = extractSecuritySchemes(api, isSwagger2);
165
+ const globalSecurityRaw = "security" in api ? api.security : undefined;
166
+ const globalSecurity = extractSecurityRequirements(
167
+ Array.isArray(globalSecurityRaw) ? globalSecurityRaw : undefined,
168
+ );
169
+
130
170
  const paths: ParsedPath[] = [];
131
171
  const rawPaths =
132
172
  "paths" in api && isRecord(api.paths) ? api.paths : undefined;
133
173
 
134
174
  if (!rawPaths) {
135
- return { title, version, basePath, paths };
175
+ return { title, version, basePath, paths, securitySchemes, globalSecurity };
136
176
  }
137
177
 
138
178
  for (const [pathTemplate, pathItemRaw] of Object.entries(rawPaths)) {
@@ -183,6 +223,17 @@ export async function parseSpec(source: string | object): Promise<ParsedSpec> {
183
223
  ? operation.tags.filter((t): t is string => typeof t === "string")
184
224
  : [];
185
225
 
226
+ // Extract per-operation security
227
+ const operationSecurity = Array.isArray(operation.security)
228
+ ? extractSecurityRequirements(operation.security)
229
+ : undefined;
230
+
231
+ // Extract OAS3 callbacks
232
+ const callbacks =
233
+ !isSwagger2 && isRecord(operation.callbacks)
234
+ ? extractCallbacks(operation.callbacks)
235
+ : undefined;
236
+
186
237
  // Filter out body parameters from the final parameter list (Swagger 2.0)
187
238
  const filteredParams = mergedParams.filter(isNotBodyParam);
188
239
 
@@ -194,11 +245,13 @@ export async function parseSpec(source: string | object): Promise<ParsedSpec> {
194
245
  requestBody,
195
246
  responses,
196
247
  tags,
248
+ security: operationSecurity,
249
+ callbacks,
197
250
  });
198
251
  }
199
252
  }
200
253
 
201
- return { title, version, basePath, paths };
254
+ return { title, version, basePath, paths, securitySchemes, globalSecurity };
202
255
  }
203
256
 
204
257
  interface InternalParameter {
@@ -318,11 +371,8 @@ function extractOpenApi3RequestBody(
318
371
  function extractResponses(
319
372
  responses: Record<string, unknown> | undefined,
320
373
  isSwagger2: boolean,
321
- ): Map<number, { schema?: JSONSchema7; description: string }> {
322
- const result = new Map<
323
- number,
324
- { schema?: JSONSchema7; description: string }
325
- >();
374
+ ): Map<number, ParsedResponseEntry> {
375
+ const result = new Map<number, ParsedResponseEntry>();
326
376
 
327
377
  if (!responses) return result;
328
378
 
@@ -336,28 +386,106 @@ function extractResponses(
336
386
  const description = getString(response.description) ?? "";
337
387
 
338
388
  let schema: JSONSchema7 | undefined;
389
+ let examples: Map<string, unknown> | undefined;
390
+ let contentTypes: string[] | undefined;
391
+
339
392
  if (isSwagger2) {
340
- // Swagger 2.0: schema is directly on the response
341
393
  if (isRecord(response.schema)) {
342
394
  schema = normalizeSchema(response.schema, "response");
343
395
  }
396
+ // Swagger 2.0 single example
397
+ if (response.examples !== undefined && isRecord(response.examples)) {
398
+ examples = new Map();
399
+ for (const [key, value] of Object.entries(response.examples)) {
400
+ examples.set(key, value);
401
+ }
402
+ }
344
403
  } else {
345
- // OpenAPI 3.x: schema is nested in content
346
404
  const content = isRecord(response.content) ? response.content : undefined;
347
405
  if (content) {
406
+ contentTypes = Object.keys(content);
348
407
  const jsonEntry = findJsonContent(content);
349
408
  if (jsonEntry && isRecord(jsonEntry.schema)) {
350
409
  schema = normalizeSchema(jsonEntry.schema, "response");
351
410
  }
411
+ // OAS3 named examples
412
+ if (jsonEntry) {
413
+ examples = extractExamples(jsonEntry);
414
+ }
352
415
  }
353
416
  }
354
417
 
355
- result.set(code, { schema, description });
418
+ const headers = extractResponseHeaders(response, isSwagger2);
419
+ result.set(code, { schema, description, headers, examples, contentTypes });
356
420
  }
357
421
 
358
422
  return result;
359
423
  }
360
424
 
425
+ function extractExamples(
426
+ contentEntry: Record<string, unknown>,
427
+ ): Map<string, unknown> | undefined {
428
+ const result = new Map<string, unknown>();
429
+
430
+ // Single `example` value
431
+ if ("example" in contentEntry && contentEntry.example !== undefined) {
432
+ result.set("default", contentEntry.example);
433
+ }
434
+
435
+ // Named `examples` map
436
+ if (isRecord(contentEntry.examples)) {
437
+ for (const [name, exampleObj] of Object.entries(contentEntry.examples)) {
438
+ if (isRecord(exampleObj) && "value" in exampleObj) {
439
+ result.set(name, exampleObj.value);
440
+ }
441
+ }
442
+ }
443
+
444
+ return result.size > 0 ? result : undefined;
445
+ }
446
+
447
+ function extractResponseHeaders(
448
+ response: Record<string, unknown>,
449
+ isSwagger2: boolean,
450
+ ): Record<string, Schmock.ResponseHeaderDef> | undefined {
451
+ const rawHeaders = isRecord(response.headers) ? response.headers : undefined;
452
+ if (!rawHeaders) return undefined;
453
+
454
+ const headers: Record<string, Schmock.ResponseHeaderDef> = {};
455
+ let hasHeaders = false;
456
+
457
+ for (const [name, headerRaw] of Object.entries(rawHeaders)) {
458
+ if (!isRecord(headerRaw)) continue;
459
+
460
+ const desc = getString(headerRaw.description) ?? "";
461
+ let headerSchema: JSONSchema7 | undefined;
462
+
463
+ if (isSwagger2) {
464
+ // Swagger 2.0: type/format/enum are inline on the header
465
+ if (headerRaw.type) {
466
+ headerSchema = normalizeSchema(
467
+ {
468
+ type: headerRaw.type,
469
+ format: headerRaw.format,
470
+ enum: headerRaw.enum,
471
+ },
472
+ "response",
473
+ );
474
+ }
475
+ } else {
476
+ // OpenAPI 3.x: schema is nested
477
+ if (isRecord(headerRaw.schema)) {
478
+ headerSchema = normalizeSchema(headerRaw.schema, "response");
479
+ }
480
+ }
481
+
482
+ headers[name] = { schema: headerSchema, description: desc };
483
+ hasHeaders = true;
484
+ }
485
+
486
+ return hasHeaders ? headers : undefined;
487
+ }
488
+
361
489
  /**
362
490
  * Find the best JSON-like content type entry from an OpenAPI content map.
363
491
  * Prefers application/json, then any *+json or *json* type.
@@ -384,3 +512,155 @@ function findJsonContent(
384
512
  function convertPathTemplate(path: string): string {
385
513
  return path.replace(/\{([^}]+)\}/g, ":$1");
386
514
  }
515
+
516
+ function extractSecuritySchemes(
517
+ api: OpenAPI.Document,
518
+ isSwagger2: boolean,
519
+ ): Map<string, SecurityScheme> | undefined {
520
+ const schemes = new Map<string, SecurityScheme>();
521
+
522
+ let rawSchemes: Record<string, unknown> | undefined;
523
+
524
+ if (isSwagger2) {
525
+ // Swagger 2.0: securityDefinitions
526
+ if ("securityDefinitions" in api) {
527
+ const defs = api.securityDefinitions;
528
+ if (isRecord(defs)) {
529
+ rawSchemes = defs;
530
+ }
531
+ }
532
+ } else {
533
+ // OpenAPI 3.x: components.securitySchemes
534
+ if ("components" in api && isRecord(api.components)) {
535
+ const comp = api.components;
536
+ if ("securitySchemes" in comp && isRecord(comp.securitySchemes)) {
537
+ rawSchemes = comp.securitySchemes;
538
+ }
539
+ }
540
+ }
541
+
542
+ if (!rawSchemes) return schemes.size > 0 ? schemes : undefined;
543
+
544
+ for (const [name, schemeDef] of Object.entries(rawSchemes)) {
545
+ if (!isRecord(schemeDef)) continue;
546
+
547
+ const type = getString(schemeDef.type);
548
+ if (!type) continue;
549
+
550
+ const scheme = toSecurityScheme(type, schemeDef, isSwagger2);
551
+ if (scheme) {
552
+ schemes.set(name, scheme);
553
+ }
554
+ }
555
+
556
+ return schemes.size > 0 ? schemes : undefined;
557
+ }
558
+
559
+ const SECURITY_SCHEME_TYPES = new Set([
560
+ "apiKey",
561
+ "http",
562
+ "oauth2",
563
+ "openIdConnect",
564
+ ]);
565
+ const API_KEY_LOCATIONS = new Set(["header", "query", "cookie"]);
566
+
567
+ function toSecurityScheme(
568
+ type: string,
569
+ def: Record<string, unknown>,
570
+ isSwagger2: boolean,
571
+ ): SecurityScheme | undefined {
572
+ // Handle Swagger 2.0 basic auth
573
+ if (isSwagger2 && type === "basic") {
574
+ return { type: "http", scheme: "basic" };
575
+ }
576
+
577
+ if (!SECURITY_SCHEME_TYPES.has(type)) return undefined;
578
+
579
+ const scheme: SecurityScheme = {
580
+ type:
581
+ type === "apiKey"
582
+ ? "apiKey"
583
+ : type === "http"
584
+ ? "http"
585
+ : type === "oauth2"
586
+ ? "oauth2"
587
+ : "openIdConnect",
588
+ };
589
+
590
+ if (type === "apiKey") {
591
+ const location = getString(def.in);
592
+ if (location && API_KEY_LOCATIONS.has(location)) {
593
+ scheme.in =
594
+ location === "header"
595
+ ? "header"
596
+ : location === "query"
597
+ ? "query"
598
+ : "cookie";
599
+ }
600
+ scheme.name = getString(def.name);
601
+ } else if (type === "http") {
602
+ scheme.scheme = getString(def.scheme);
603
+ }
604
+
605
+ return scheme;
606
+ }
607
+
608
+ /**
609
+ * Extract security requirements from a security array.
610
+ * Each entry in the array is an OR condition (any can match).
611
+ * Each entry is an object where keys are scheme names (AND within).
612
+ * Returns array of string arrays: [[schemeA, schemeB], [schemeC]] means (A AND B) OR C.
613
+ * An empty array entry means "no auth required" (public).
614
+ */
615
+ function extractSecurityRequirements(
616
+ security: unknown[] | undefined,
617
+ ): string[][] | undefined {
618
+ if (!security || security.length === 0) return undefined;
619
+
620
+ const result: string[][] = [];
621
+ for (const entry of security) {
622
+ if (!isRecord(entry)) continue;
623
+ result.push(Object.keys(entry));
624
+ }
625
+
626
+ return result.length > 0 ? result : undefined;
627
+ }
628
+
629
+ /**
630
+ * Extract OAS3 callbacks from an operation.
631
+ * Callbacks structure: { callbackName: { urlExpression: { method: { requestBody, ... } } } }
632
+ */
633
+ function extractCallbacks(
634
+ callbacks: Record<string, unknown>,
635
+ ): ParsedCallback[] | undefined {
636
+ const result: ParsedCallback[] = [];
637
+
638
+ for (const callbackObj of Object.values(callbacks)) {
639
+ if (!isRecord(callbackObj)) continue;
640
+
641
+ // Each key is a URL expression like "{$request.body#/callbackUrl}"
642
+ for (const [urlExpression, pathItem] of Object.entries(callbackObj)) {
643
+ if (!isRecord(pathItem)) continue;
644
+
645
+ for (const methodKey of Object.keys(pathItem)) {
646
+ if (!HTTP_METHOD_KEYS.has(methodKey)) continue;
647
+
648
+ const operation = pathItem[methodKey];
649
+ if (!isRecord(operation)) continue;
650
+
651
+ let reqBody: JSONSchema7 | undefined;
652
+ if (isRecord(operation.requestBody)) {
653
+ reqBody = extractOpenApi3RequestBody(operation.requestBody);
654
+ }
655
+
656
+ result.push({
657
+ urlExpression,
658
+ method: toHttpMethod(methodKey.toUpperCase()),
659
+ requestBody: reqBody,
660
+ });
661
+ }
662
+ }
663
+ }
664
+
665
+ return result.length > 0 ? result : undefined;
666
+ }