@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/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 +5 -3
- 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/src/parser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/// <reference path="
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|