genoc 0.4.2 → 0.5.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/README.md +2 -1
- package/dist/analyzer/analyze.js +139 -28
- package/dist/analyzer/path-analyzer.d.ts +21 -1
- package/dist/analyzer/path-analyzer.js +3 -1
- package/dist/analyzer/types.d.ts +25 -0
- package/dist/cli/impl.js +3 -1
- package/dist/generator/client-generator.js +65 -5
- package/dist/generator/contracts-generator.js +7 -0
- package/dist/generator/method-generator.d.ts +9 -1
- package/dist/generator/method-generator.js +38 -1
- package/dist/parser/spec-reader.js +6 -25
- package/dist/parser/version/spec-links.d.ts +55 -0
- package/dist/parser/version/spec-links.js +65 -0
- package/dist/parser/version/v3.0/validator.d.ts +2 -0
- package/dist/parser/version/v3.0/validator.js +23 -13
- package/dist/parser/version/v3.1/validator.d.ts +2 -0
- package/dist/parser/version/v3.1/validator.js +25 -13
- package/dist/parser/version/validate-security.d.ts +17 -0
- package/dist/parser/version/validate-security.js +218 -0
- package/dist/parser/version/version-detector.js +27 -8
- package/dist/runtime/defined-errors.d.ts +3 -2
- package/dist/runtime/defined-errors.js +6 -3
- package/dist/runtime/errors.d.ts +1 -3
- package/dist/runtime/errors.js +2 -6
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/requester.d.ts +67 -0
- package/dist/types/openapi.d.ts +5 -4
- package/dist/utils/http-methods.d.ts +8 -0
- package/dist/utils/http-methods.js +17 -0
- package/dist/utils/operation-naming.js +6 -0
- package/package.json +1 -1
|
@@ -5,23 +5,6 @@ import { fetchSpec } from '../utils/proxy.js';
|
|
|
5
5
|
import { isUrl } from '../utils/url.js';
|
|
6
6
|
const VALID_EXTENSIONS = ['.json', '.yaml', '.yml'];
|
|
7
7
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
8
|
-
function assertSupportedVersion(doc) {
|
|
9
|
-
if (typeof doc !== 'object' ||
|
|
10
|
-
doc === null ||
|
|
11
|
-
!('openapi' in doc) ||
|
|
12
|
-
typeof doc.openapi !== 'string') {
|
|
13
|
-
throw new Error("Invalid spec: missing or invalid 'openapi' field.");
|
|
14
|
-
}
|
|
15
|
-
const version = doc.openapi;
|
|
16
|
-
// Check for valid version format
|
|
17
|
-
if (!version.match(/^\d+\.\d+(\.\d+)?$/)) {
|
|
18
|
-
throw new Error(`Invalid OpenAPI version format: ${version}`);
|
|
19
|
-
}
|
|
20
|
-
// Check that major version is 3 (but don't restrict minor versions)
|
|
21
|
-
if (!version.startsWith('3.')) {
|
|
22
|
-
throw new Error(`Unsupported OpenAPI version: ${version}. Supported versions: 3.0, 3.1, 3.2`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
8
|
function parseContent(content, ext) {
|
|
26
9
|
if (ext === '.json') {
|
|
27
10
|
try {
|
|
@@ -69,10 +52,10 @@ export async function loadFromFile(filePath) {
|
|
|
69
52
|
throw new Error(`Failed to load spec from file: ${err.message}`, { cause: err });
|
|
70
53
|
}
|
|
71
54
|
const doc = parseContent(content, ext);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
55
|
+
// Version/dialect rules — including whether the parsed root is even an
|
|
56
|
+
// OpenAPI document — belong to the version module, which resolves the
|
|
57
|
+
// dialect and links spec violations to the specification. The cast is safe
|
|
58
|
+
// for the same I/O reason as above: callers validate via resolveVersion.
|
|
76
59
|
return doc;
|
|
77
60
|
}
|
|
78
61
|
export async function loadFromUrl(url, opts) {
|
|
@@ -103,10 +86,8 @@ export async function loadFromUrl(url, opts) {
|
|
|
103
86
|
throw new Error(`Failed to parse spec from URL as JSON or YAML: ${yamlErr.message}`, { cause: yamlErr });
|
|
104
87
|
}
|
|
105
88
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
assertSupportedVersion(doc);
|
|
89
|
+
// Version/dialect rules — including document-rootedness — belong to the
|
|
90
|
+
// version module (see loadFromFile).
|
|
110
91
|
return doc;
|
|
111
92
|
}
|
|
112
93
|
export async function load(source, opts) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of links from spec-violation errors to the section of the
|
|
3
|
+
* OpenAPI specification that states the rule being violated.
|
|
4
|
+
*
|
|
5
|
+
* Unsupported-feature errors (Swagger 2.0, OpenAPI 3.2, external `$ref`s)
|
|
6
|
+
* never use this module: the document is valid and the error cites a genoc
|
|
7
|
+
* limitation, not a specification rule.
|
|
8
|
+
*/
|
|
9
|
+
/** Latest patch release per dialect. */
|
|
10
|
+
export declare const SPEC_PAGE: Record<'3.0' | '3.1', string>;
|
|
11
|
+
/**
|
|
12
|
+
* One anchor per violated rule, shared by both dialect pages. A wrong anchor
|
|
13
|
+
* is a one-line fix here; no automated anchor checking exists.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SPEC_ANCHORS: {
|
|
16
|
+
readonly 'document-root': "openapi-object";
|
|
17
|
+
readonly 'openapi-field': "oas-version";
|
|
18
|
+
readonly 'info-object': "info-object";
|
|
19
|
+
readonly 'info-title': "info-title";
|
|
20
|
+
readonly 'info-version': "info-version";
|
|
21
|
+
readonly 'paths-object': "paths-object";
|
|
22
|
+
readonly 'path-item-object': "path-item-object";
|
|
23
|
+
readonly 'components-security-schemes': "security-scheme-object";
|
|
24
|
+
readonly 'components-schemas': "components-schemas";
|
|
25
|
+
readonly 'schema-object': "schema-object";
|
|
26
|
+
readonly 'security-scheme-object': "security-scheme-object-0";
|
|
27
|
+
readonly 'security-scheme-type': "security-scheme-type";
|
|
28
|
+
readonly 'security-scheme-name': "security-scheme-name";
|
|
29
|
+
readonly 'security-scheme-in': "security-scheme-in";
|
|
30
|
+
readonly 'security-scheme-scheme': "security-scheme-scheme";
|
|
31
|
+
readonly 'security-scheme-flows': "security-scheme-flows";
|
|
32
|
+
readonly 'security-scheme-open-id-connect-url': "security-scheme-open-id-connect-url";
|
|
33
|
+
readonly 'oauth-flow-object': "oauth-flow-object";
|
|
34
|
+
readonly 'oauth-flow-scopes': "oauth-flow-scopes";
|
|
35
|
+
readonly 'reference-object': "reference-object";
|
|
36
|
+
readonly 'security-requirement-object': "security-requirement-object";
|
|
37
|
+
};
|
|
38
|
+
export type SpecLinkKey = keyof typeof SPEC_ANCHORS;
|
|
39
|
+
export declare function specLinkUrl(version: '3.0' | '3.1', key: SpecLinkKey): string;
|
|
40
|
+
/**
|
|
41
|
+
* Renders a spec-violation message with its link line appended on its own
|
|
42
|
+
* line, indented four spaces so it aligns under the ` - ` bullet that
|
|
43
|
+
* `validateSpec` puts each message in.
|
|
44
|
+
*/
|
|
45
|
+
export declare function specViolation(version: '3.0' | '3.1', key: SpecLinkKey, message: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Detection error for a document that violates the specification itself
|
|
48
|
+
* (as opposed to an unsupported-but-valid dialect). Carries the anchor key
|
|
49
|
+
* so `resolveVersion` can render the link against the dialect being applied.
|
|
50
|
+
*/
|
|
51
|
+
export declare class SpecViolationError extends Error {
|
|
52
|
+
/** Which specification section states the violated rule. */
|
|
53
|
+
linkKey: SpecLinkKey;
|
|
54
|
+
constructor(message: string, linkKey: SpecLinkKey);
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of links from spec-violation errors to the section of the
|
|
3
|
+
* OpenAPI specification that states the rule being violated.
|
|
4
|
+
*
|
|
5
|
+
* Unsupported-feature errors (Swagger 2.0, OpenAPI 3.2, external `$ref`s)
|
|
6
|
+
* never use this module: the document is valid and the error cites a genoc
|
|
7
|
+
* limitation, not a specification rule.
|
|
8
|
+
*/
|
|
9
|
+
/** Latest patch release per dialect. */
|
|
10
|
+
export const SPEC_PAGE = {
|
|
11
|
+
'3.0': 'https://spec.openapis.org/oas/v3.0.4.html',
|
|
12
|
+
'3.1': 'https://spec.openapis.org/oas/v3.1.1.html',
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* One anchor per violated rule, shared by both dialect pages. A wrong anchor
|
|
16
|
+
* is a one-line fix here; no automated anchor checking exists.
|
|
17
|
+
*/
|
|
18
|
+
export const SPEC_ANCHORS = {
|
|
19
|
+
'document-root': 'openapi-object',
|
|
20
|
+
'openapi-field': 'oas-version',
|
|
21
|
+
'info-object': 'info-object',
|
|
22
|
+
'info-title': 'info-title',
|
|
23
|
+
'info-version': 'info-version',
|
|
24
|
+
'paths-object': 'paths-object',
|
|
25
|
+
'path-item-object': 'path-item-object',
|
|
26
|
+
'components-security-schemes': 'security-scheme-object',
|
|
27
|
+
'components-schemas': 'components-schemas',
|
|
28
|
+
'schema-object': 'schema-object',
|
|
29
|
+
'security-scheme-object': 'security-scheme-object-0',
|
|
30
|
+
'security-scheme-type': 'security-scheme-type',
|
|
31
|
+
'security-scheme-name': 'security-scheme-name',
|
|
32
|
+
'security-scheme-in': 'security-scheme-in',
|
|
33
|
+
'security-scheme-scheme': 'security-scheme-scheme',
|
|
34
|
+
'security-scheme-flows': 'security-scheme-flows',
|
|
35
|
+
'security-scheme-open-id-connect-url': 'security-scheme-open-id-connect-url',
|
|
36
|
+
'oauth-flow-object': 'oauth-flow-object',
|
|
37
|
+
'oauth-flow-scopes': 'oauth-flow-scopes',
|
|
38
|
+
'reference-object': 'reference-object',
|
|
39
|
+
'security-requirement-object': 'security-requirement-object',
|
|
40
|
+
};
|
|
41
|
+
export function specLinkUrl(version, key) {
|
|
42
|
+
return `${SPEC_PAGE[version]}#${SPEC_ANCHORS[key]}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Renders a spec-violation message with its link line appended on its own
|
|
46
|
+
* line, indented four spaces so it aligns under the ` - ` bullet that
|
|
47
|
+
* `validateSpec` puts each message in.
|
|
48
|
+
*/
|
|
49
|
+
export function specViolation(version, key, message) {
|
|
50
|
+
return `${message}\n ${specLinkUrl(version, key)}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Detection error for a document that violates the specification itself
|
|
54
|
+
* (as opposed to an unsupported-but-valid dialect). Carries the anchor key
|
|
55
|
+
* so `resolveVersion` can render the link against the dialect being applied.
|
|
56
|
+
*/
|
|
57
|
+
export class SpecViolationError extends Error {
|
|
58
|
+
/** Which specification section states the violated rule. */
|
|
59
|
+
linkKey;
|
|
60
|
+
constructor(message, linkKey) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'SpecViolationError';
|
|
63
|
+
this.linkKey = linkKey;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -9,5 +9,7 @@ import type { ValidationResult } from '../validate.js';
|
|
|
9
9
|
* - Warns about 3.1-only features: `webhooks`, `$schema` in schemas, `type` as array
|
|
10
10
|
* - Accepts `nullable` and `example` (valid in 3.0, not in 3.1)
|
|
11
11
|
* - Errors on `items` as array (3.1-only tuple syntax)
|
|
12
|
+
* - Validates `components.securitySchemes` and `security` requirements (shared rules;
|
|
13
|
+
* non-oauth2/openIdConnect scopes must be empty, `mutualTLS` rejected as 3.1-only)
|
|
12
14
|
*/
|
|
13
15
|
export declare function validateSpec30(spec: unknown): ValidationResult;
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { specViolation } from '../spec-links.js';
|
|
2
|
+
import { validateSecurity } from '../validate-security.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every violation in this file belongs to the 3.0 dialect;
|
|
5
|
+
* bind it once so call sites stay key-and-message only.
|
|
6
|
+
*/
|
|
7
|
+
const violation = (key, message) => specViolation('3.0', key, message);
|
|
1
8
|
/**
|
|
2
9
|
* Validate a raw OpenAPI 3.0.x specification.
|
|
3
10
|
*
|
|
@@ -8,39 +15,41 @@
|
|
|
8
15
|
* - Warns about 3.1-only features: `webhooks`, `$schema` in schemas, `type` as array
|
|
9
16
|
* - Accepts `nullable` and `example` (valid in 3.0, not in 3.1)
|
|
10
17
|
* - Errors on `items` as array (3.1-only tuple syntax)
|
|
18
|
+
* - Validates `components.securitySchemes` and `security` requirements (shared rules;
|
|
19
|
+
* non-oauth2/openIdConnect scopes must be empty, `mutualTLS` rejected as 3.1-only)
|
|
11
20
|
*/
|
|
12
21
|
export function validateSpec30(spec) {
|
|
13
22
|
const errors = [];
|
|
14
23
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
15
|
-
errors.push('Document must be an object');
|
|
24
|
+
errors.push(violation('document-root', 'Document must be an object'));
|
|
16
25
|
return { valid: false, errors };
|
|
17
26
|
}
|
|
18
27
|
const s = spec;
|
|
19
28
|
if (!s.openapi || typeof s.openapi !== 'string') {
|
|
20
|
-
errors.push("OpenAPI specification must have an 'openapi' field with string value");
|
|
29
|
+
errors.push(violation('openapi-field', "OpenAPI specification must have an 'openapi' field with string value"));
|
|
21
30
|
}
|
|
22
31
|
else if (!s.openapi.startsWith('3.0')) {
|
|
23
|
-
errors.push(`OpenAPI version must start with '3.0', got: ${s.openapi}`);
|
|
32
|
+
errors.push(violation('openapi-field', `OpenAPI version must start with '3.0', got: ${s.openapi}`));
|
|
24
33
|
}
|
|
25
34
|
if (!s.info || typeof s.info !== 'object' || Array.isArray(s.info)) {
|
|
26
|
-
errors.push("OpenAPI specification must have an 'info' field with object value");
|
|
35
|
+
errors.push(violation('info-object', "OpenAPI specification must have an 'info' field with object value"));
|
|
27
36
|
}
|
|
28
37
|
else {
|
|
29
38
|
const info = s.info;
|
|
30
39
|
if (!info.title || typeof info.title !== 'string') {
|
|
31
|
-
errors.push("Info object must have a 'title' field with string value");
|
|
40
|
+
errors.push(violation('info-title', "Info object must have a 'title' field with string value"));
|
|
32
41
|
}
|
|
33
42
|
if (!info.version || typeof info.version !== 'string') {
|
|
34
|
-
errors.push("Info object must have a 'version' field with string value");
|
|
43
|
+
errors.push(violation('info-version', "Info object must have a 'version' field with string value"));
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
if (!s.paths || typeof s.paths !== 'object' || Array.isArray(s.paths)) {
|
|
38
|
-
errors.push("OpenAPI 3.0 specification must have a 'paths' field with object value");
|
|
47
|
+
errors.push(violation('paths-object', "OpenAPI 3.0 specification must have a 'paths' field with object value"));
|
|
39
48
|
}
|
|
40
49
|
if (s.paths && typeof s.paths === 'object' && !Array.isArray(s.paths)) {
|
|
41
50
|
for (const [pathKey, pathItem] of Object.entries(s.paths)) {
|
|
42
51
|
if (!pathItem || typeof pathItem !== 'object' || Array.isArray(pathItem)) {
|
|
43
|
-
errors.push(`Path item '${pathKey}' must be an object`);
|
|
52
|
+
errors.push(violation('path-item-object', `Path item '${pathKey}' must be an object`));
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
}
|
|
@@ -48,13 +57,13 @@ export function validateSpec30(spec) {
|
|
|
48
57
|
const components = s.components;
|
|
49
58
|
if (components.schemas) {
|
|
50
59
|
if (Array.isArray(components.schemas) || typeof components.schemas !== 'object') {
|
|
51
|
-
errors.push("'components.schemas' must be an object");
|
|
60
|
+
errors.push(violation('components-schemas', "'components.schemas' must be an object"));
|
|
52
61
|
}
|
|
53
62
|
else {
|
|
54
63
|
const schemas = components.schemas;
|
|
55
64
|
for (const [key, schema] of Object.entries(schemas)) {
|
|
56
65
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
57
|
-
errors.push(`Schema '${key}' must be an object`);
|
|
66
|
+
errors.push(violation('schema-object', `Schema '${key}' must be an object`));
|
|
58
67
|
}
|
|
59
68
|
else {
|
|
60
69
|
validateSchema30(schema, `components.schemas.${key}`, errors);
|
|
@@ -63,6 +72,7 @@ export function validateSpec30(spec) {
|
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
}
|
|
75
|
+
validateSecurity(s, '3.0', errors);
|
|
66
76
|
return {
|
|
67
77
|
valid: errors.length === 0,
|
|
68
78
|
errors,
|
|
@@ -78,13 +88,13 @@ function validateSchema30(schema, path, errors) {
|
|
|
78
88
|
}
|
|
79
89
|
const s = schema;
|
|
80
90
|
if (Array.isArray(s.type)) {
|
|
81
|
-
errors.push(`Schema '${path}' uses 'type' as an array which is a 3.1-only feature`);
|
|
91
|
+
errors.push(violation('schema-object', `Schema '${path}' uses 'type' as an array which is a 3.1-only feature`));
|
|
82
92
|
}
|
|
83
93
|
if ('$schema' in s) {
|
|
84
|
-
errors.push(`Schema '${path}' uses '$schema' which is a 3.1-only feature`);
|
|
94
|
+
errors.push(violation('schema-object', `Schema '${path}' uses '$schema' which is a 3.1-only feature`));
|
|
85
95
|
}
|
|
86
96
|
if (s.items !== undefined && Array.isArray(s.items)) {
|
|
87
|
-
errors.push(`Schema '${path}' uses 'items' as an array (tuple syntax) which is a 3.1-only feature`);
|
|
97
|
+
errors.push(violation('schema-object', `Schema '${path}' uses 'items' as an array (tuple syntax) which is a 3.1-only feature`));
|
|
88
98
|
}
|
|
89
99
|
if (s.properties && typeof s.properties === 'object' && !Array.isArray(s.properties)) {
|
|
90
100
|
for (const [key, prop] of Object.entries(s.properties)) {
|
|
@@ -13,5 +13,7 @@ import type { ValidationResult } from '../validate.js';
|
|
|
13
13
|
* - Accepts `type` as array, `$schema`, `$id`, `$comment`, `const`, `examples`, and `$ref` siblings
|
|
14
14
|
* (all legal in 3.1 via JSON Schema 2020-12)
|
|
15
15
|
* - Accepts schema-level `example` (deprecated in 3.1 but still legal)
|
|
16
|
+
* - Validates `components.securitySchemes` and `security` requirements (shared rules;
|
|
17
|
+
* `mutualTLS` accepted, non-empty scopes for non-oauth2/openIdConnect schemes allowed as role names)
|
|
16
18
|
*/
|
|
17
19
|
export declare function validateSpec31(spec: unknown): ValidationResult;
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { specViolation } from '../spec-links.js';
|
|
2
|
+
import { validateSecurity } from '../validate-security.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every violation in this file belongs to the 3.1 dialect;
|
|
5
|
+
* bind it once so call sites stay key-and-message only.
|
|
6
|
+
*/
|
|
7
|
+
const violation = (key, message) => specViolation('3.1', key, message);
|
|
1
8
|
/**
|
|
2
9
|
* Validate a raw OpenAPI 3.1.x specification.
|
|
3
10
|
*
|
|
@@ -12,50 +19,54 @@
|
|
|
12
19
|
* - Accepts `type` as array, `$schema`, `$id`, `$comment`, `const`, `examples`, and `$ref` siblings
|
|
13
20
|
* (all legal in 3.1 via JSON Schema 2020-12)
|
|
14
21
|
* - Accepts schema-level `example` (deprecated in 3.1 but still legal)
|
|
22
|
+
* - Validates `components.securitySchemes` and `security` requirements (shared rules;
|
|
23
|
+
* `mutualTLS` accepted, non-empty scopes for non-oauth2/openIdConnect schemes allowed as role names)
|
|
15
24
|
*/
|
|
16
25
|
export function validateSpec31(spec) {
|
|
17
26
|
const errors = [];
|
|
18
27
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
19
|
-
errors.push('Document must be an object');
|
|
28
|
+
errors.push(violation('document-root', 'Document must be an object'));
|
|
20
29
|
return { valid: false, errors };
|
|
21
30
|
}
|
|
22
31
|
const s = spec;
|
|
23
32
|
if (!s.openapi || typeof s.openapi !== 'string') {
|
|
24
|
-
errors.push("OpenAPI specification must have an 'openapi' field with string value");
|
|
33
|
+
errors.push(violation('openapi-field', "OpenAPI specification must have an 'openapi' field with string value"));
|
|
25
34
|
}
|
|
26
35
|
else if (!s.openapi.startsWith('3.1')) {
|
|
27
|
-
errors.push(`OpenAPI version must start with '3.1', got: ${s.openapi}`);
|
|
36
|
+
errors.push(violation('openapi-field', `OpenAPI version must start with '3.1', got: ${s.openapi}`));
|
|
28
37
|
}
|
|
29
38
|
if (!s.info || typeof s.info !== 'object' || Array.isArray(s.info)) {
|
|
30
|
-
errors.push("OpenAPI specification must have an 'info' field with object value");
|
|
39
|
+
errors.push(violation('info-object', "OpenAPI specification must have an 'info' field with object value"));
|
|
31
40
|
}
|
|
32
41
|
else {
|
|
33
42
|
const info = s.info;
|
|
34
43
|
if (!info.title || typeof info.title !== 'string') {
|
|
35
|
-
errors.push("Info object must have a 'title' field with string value");
|
|
44
|
+
errors.push(violation('info-title', "Info object must have a 'title' field with string value"));
|
|
36
45
|
}
|
|
37
46
|
if (!info.version || typeof info.version !== 'string') {
|
|
38
|
-
errors.push("Info object must have a 'version' field with string value");
|
|
47
|
+
errors.push(violation('info-version', "Info object must have a 'version' field with string value"));
|
|
39
48
|
}
|
|
40
49
|
}
|
|
41
50
|
if (!s.paths && !s.components && !s.webhooks) {
|
|
51
|
+
// Not a spec violation: all three fields are optional in 3.1; the check
|
|
52
|
+
// exists because an empty document gives genoc nothing to generate.
|
|
42
53
|
errors.push("OpenAPI specification must have at least one of 'paths', 'components', or 'webhooks'");
|
|
43
54
|
}
|
|
44
55
|
if (s.paths) {
|
|
45
56
|
if (typeof s.paths !== 'object' || Array.isArray(s.paths)) {
|
|
46
|
-
errors.push("'paths' field must be an object");
|
|
57
|
+
errors.push(violation('paths-object', "'paths' field must be an object"));
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
if (s.components && typeof s.components === 'object' && !Array.isArray(s.components)) {
|
|
50
61
|
const components = s.components;
|
|
51
62
|
if (components.schemas) {
|
|
52
63
|
if (typeof components.schemas !== 'object' || Array.isArray(components.schemas)) {
|
|
53
|
-
errors.push("'components.schemas' must be an object");
|
|
64
|
+
errors.push(violation('components-schemas', "'components.schemas' must be an object"));
|
|
54
65
|
}
|
|
55
66
|
else {
|
|
56
67
|
for (const [key, schema] of Object.entries(components.schemas)) {
|
|
57
68
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
58
|
-
errors.push(`Schema '${key}' must be an object`);
|
|
69
|
+
errors.push(violation('schema-object', `Schema '${key}' must be an object`));
|
|
59
70
|
}
|
|
60
71
|
else {
|
|
61
72
|
validateSchema31(schema, `components.schemas.${key}`, errors);
|
|
@@ -64,6 +75,7 @@ export function validateSpec31(spec) {
|
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
}
|
|
78
|
+
validateSecurity(s, '3.1', errors);
|
|
67
79
|
return {
|
|
68
80
|
valid: errors.length === 0,
|
|
69
81
|
errors,
|
|
@@ -79,16 +91,16 @@ function validateSchema31(schema, path, errors) {
|
|
|
79
91
|
}
|
|
80
92
|
const s = schema;
|
|
81
93
|
if ('nullable' in s) {
|
|
82
|
-
errors.push(`Schema '${path}' uses 'nullable' which is a 3.0-only feature`);
|
|
94
|
+
errors.push(violation('schema-object', `Schema '${path}' uses 'nullable' which is a 3.0-only feature`));
|
|
83
95
|
}
|
|
84
96
|
if (typeof s.exclusiveMinimum === 'boolean') {
|
|
85
|
-
errors.push(`Schema '${path}' uses 'exclusiveMinimum' as a boolean which is a 3.0-only feature`);
|
|
97
|
+
errors.push(violation('schema-object', `Schema '${path}' uses 'exclusiveMinimum' as a boolean which is a 3.0-only feature`));
|
|
86
98
|
}
|
|
87
99
|
if (typeof s.exclusiveMaximum === 'boolean') {
|
|
88
|
-
errors.push(`Schema '${path}' uses 'exclusiveMaximum' as a boolean which is a 3.0-only feature`);
|
|
100
|
+
errors.push(violation('schema-object', `Schema '${path}' uses 'exclusiveMaximum' as a boolean which is a 3.0-only feature`));
|
|
89
101
|
}
|
|
90
102
|
if (s.items !== undefined && Array.isArray(s.items)) {
|
|
91
|
-
errors.push(`Schema '${path}' uses 'items' as an array which is not allowed in 3.1 (use 'prefixItems')`);
|
|
103
|
+
errors.push(violation('schema-object', `Schema '${path}' uses 'items' as an array which is not allowed in 3.1 (use 'prefixItems')`));
|
|
92
104
|
}
|
|
93
105
|
if (s.properties && typeof s.properties === 'object' && !Array.isArray(s.properties)) {
|
|
94
106
|
for (const [key, prop] of Object.entries(s.properties)) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared security validation for 3.0 and 3.1 validators.
|
|
3
|
+
*
|
|
4
|
+
* Rules are identical across dialects except:
|
|
5
|
+
* - `mutualTLS` scheme type is 3.1-only
|
|
6
|
+
* - non-empty scope arrays for non-oauth2/openIdConnect schemes are legal in
|
|
7
|
+
* 3.1 (role names) but MUST be empty in 3.0
|
|
8
|
+
*
|
|
9
|
+
* `$ref` scheme entries are resolved here (internal `#/` pointers, cycle
|
|
10
|
+
* guarded) so the resolved object is validated like an inline scheme — the
|
|
11
|
+
* soundness guarantee "validation ⇒ generated scheme payloads are well-formed"
|
|
12
|
+
* holds for referenced schemes too. External-document refs are left to the
|
|
13
|
+
* ref-resolver, which rejects them at its own boundary. `security` on a Path
|
|
14
|
+
* Item is rejected: OpenAPI allows security at document and operation level
|
|
15
|
+
* only, and silently dropping it would lose auth metadata.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateSecurity(spec: Record<string, unknown>, version: '3.0' | '3.1', errors: string[]): void;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared security validation for 3.0 and 3.1 validators.
|
|
3
|
+
*
|
|
4
|
+
* Rules are identical across dialects except:
|
|
5
|
+
* - `mutualTLS` scheme type is 3.1-only
|
|
6
|
+
* - non-empty scope arrays for non-oauth2/openIdConnect schemes are legal in
|
|
7
|
+
* 3.1 (role names) but MUST be empty in 3.0
|
|
8
|
+
*
|
|
9
|
+
* `$ref` scheme entries are resolved here (internal `#/` pointers, cycle
|
|
10
|
+
* guarded) so the resolved object is validated like an inline scheme — the
|
|
11
|
+
* soundness guarantee "validation ⇒ generated scheme payloads are well-formed"
|
|
12
|
+
* holds for referenced schemes too. External-document refs are left to the
|
|
13
|
+
* ref-resolver, which rejects them at its own boundary. `security` on a Path
|
|
14
|
+
* Item is rejected: OpenAPI allows security at document and operation level
|
|
15
|
+
* only, and silently dropping it would lose auth metadata.
|
|
16
|
+
*/
|
|
17
|
+
import { HTTP_METHODS } from '../../utils/http-methods.js';
|
|
18
|
+
import { specViolation } from './spec-links.js';
|
|
19
|
+
const SCHEME_TYPES = {
|
|
20
|
+
'3.0': new Set(['apiKey', 'http', 'oauth2', 'openIdConnect']),
|
|
21
|
+
'3.1': new Set(['apiKey', 'http', 'oauth2', 'openIdConnect', 'mutualTLS']),
|
|
22
|
+
};
|
|
23
|
+
export function validateSecurity(spec, version, errors) {
|
|
24
|
+
/** Raw entries keyed by declared name — presence check for requirement refs. */
|
|
25
|
+
let declaredNames;
|
|
26
|
+
/** `name` → resolved (un-ref'd) scheme object used for scope-rule checks. */
|
|
27
|
+
let resolvedByName;
|
|
28
|
+
const components = spec.components;
|
|
29
|
+
const rawSchemes = components?.securitySchemes;
|
|
30
|
+
if (rawSchemes === undefined) {
|
|
31
|
+
declaredNames = undefined;
|
|
32
|
+
}
|
|
33
|
+
else if (typeof rawSchemes !== 'object' || rawSchemes === null || Array.isArray(rawSchemes)) {
|
|
34
|
+
errors.push(specViolation(version, 'components-security-schemes', "'components.securitySchemes' must be an object"));
|
|
35
|
+
declaredNames = new Set();
|
|
36
|
+
resolvedByName = new Map();
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const schemes = rawSchemes;
|
|
40
|
+
declaredNames = new Set(Object.keys(schemes));
|
|
41
|
+
resolvedByName = new Map();
|
|
42
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
43
|
+
const resolved = resolveSchemeTarget(spec, name, scheme, version, new Set(), errors);
|
|
44
|
+
if (resolved !== undefined) {
|
|
45
|
+
validateScheme(name, resolved, version, errors);
|
|
46
|
+
resolvedByName.set(name, resolved);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
validateSecurityList(spec.security, 'security', declaredNames, resolvedByName, version, errors);
|
|
51
|
+
const paths = spec.paths;
|
|
52
|
+
if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
56
|
+
if (typeof pathItem !== 'object' || pathItem === null || Array.isArray(pathItem)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if ('security' in pathItem) {
|
|
60
|
+
errors.push(specViolation(version, 'path-item-object', `'paths.${pathKey}.security' is not allowed: security requirements may be declared at the document and operation levels only`));
|
|
61
|
+
}
|
|
62
|
+
for (const method of HTTP_METHODS) {
|
|
63
|
+
const op = pathItem[method];
|
|
64
|
+
if (typeof op === 'object' && op !== null && !Array.isArray(op)) {
|
|
65
|
+
validateSecurityList(op.security, `paths.${pathKey}.${method}.security`, declaredNames, resolvedByName, version, errors);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Follow a scheme entry to its validation target: a non-`$ref` entry is its
|
|
72
|
+
* own target; internal pointers (`#/…` and the root pointer `#`) are walked
|
|
73
|
+
* (JSON Pointer escapes honored, cycles guarded) and chained refs are
|
|
74
|
+
* followed. External refs are returned unresolved (the ref-resolver rejects
|
|
75
|
+
* them later). Returns `undefined` — after pushing an error — when the entry
|
|
76
|
+
* is not an object, the pointer dangles or carries malformed percent-
|
|
77
|
+
* encoding, or a ref cycle is detected.
|
|
78
|
+
*/
|
|
79
|
+
function resolveSchemeTarget(spec, name, entry, version, seenRefs, errors) {
|
|
80
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
81
|
+
errors.push(specViolation(version, 'security-scheme-object', `Security scheme '${name}' must be an object`));
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const record = entry;
|
|
85
|
+
const ref = record.$ref;
|
|
86
|
+
if (typeof ref !== 'string') {
|
|
87
|
+
return record;
|
|
88
|
+
}
|
|
89
|
+
if (ref !== '#' && !ref.startsWith('#/')) {
|
|
90
|
+
return record; // external document — the ref-resolver's boundary
|
|
91
|
+
}
|
|
92
|
+
if (seenRefs.has(ref)) {
|
|
93
|
+
errors.push(specViolation(version, 'reference-object', `Security scheme '${name}' has a circular $ref chain ('${ref}')`));
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
seenRefs.add(ref);
|
|
97
|
+
let segments;
|
|
98
|
+
try {
|
|
99
|
+
const pointerBody = ref === '#' ? '' : ref.slice(2);
|
|
100
|
+
segments =
|
|
101
|
+
pointerBody === ''
|
|
102
|
+
? []
|
|
103
|
+
: pointerBody
|
|
104
|
+
.split('/')
|
|
105
|
+
.map((s) => decodeURIComponent(s.replace(/~1/g, '/').replace(/~0/g, '~')));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// malformed percent-encoding (e.g. a bare '%') — same story as a dangling pointer
|
|
109
|
+
errors.push(specViolation(version, 'reference-object', `Security scheme '${name}' $ref '${ref}' does not resolve`));
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
let target = spec;
|
|
113
|
+
for (const segment of segments) {
|
|
114
|
+
if (typeof target !== 'object' ||
|
|
115
|
+
target === null ||
|
|
116
|
+
Array.isArray(target) ||
|
|
117
|
+
!(segment in target)) {
|
|
118
|
+
errors.push(specViolation(version, 'reference-object', `Security scheme '${name}' $ref '${ref}' does not resolve`));
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
target = target[segment];
|
|
122
|
+
}
|
|
123
|
+
return resolveSchemeTarget(spec, name, target, version, seenRefs, errors);
|
|
124
|
+
}
|
|
125
|
+
function validateScheme(name, scheme, version, errors) {
|
|
126
|
+
if (typeof scheme !== 'object' || scheme === null || Array.isArray(scheme)) {
|
|
127
|
+
errors.push(specViolation(version, 'security-scheme-object', `Security scheme '${name}' must be an object`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const s = scheme;
|
|
131
|
+
if (typeof s.$ref === 'string') {
|
|
132
|
+
return; // external ref — shape belongs to the target document's boundary
|
|
133
|
+
}
|
|
134
|
+
const type = s.type;
|
|
135
|
+
if (typeof type !== 'string' || !SCHEME_TYPES[version].has(type)) {
|
|
136
|
+
const allowed = [...SCHEME_TYPES[version]].join(', ');
|
|
137
|
+
errors.push(specViolation(version, 'security-scheme-type', `Security scheme '${name}' must have a 'type' field with one of: ${allowed}, got: ${JSON.stringify(type ?? null)}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
switch (type) {
|
|
141
|
+
case 'apiKey':
|
|
142
|
+
if (typeof s.name !== 'string' || s.name === '') {
|
|
143
|
+
errors.push(specViolation(version, 'security-scheme-name', `apiKey security scheme '${name}' must have a 'name' field with string value`));
|
|
144
|
+
}
|
|
145
|
+
if (s.in !== 'query' && s.in !== 'header' && s.in !== 'cookie') {
|
|
146
|
+
errors.push(specViolation(version, 'security-scheme-in', `apiKey security scheme '${name}' must have an 'in' field with one of: query, header, cookie`));
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
case 'http':
|
|
150
|
+
if (typeof s.scheme !== 'string' || s.scheme === '') {
|
|
151
|
+
errors.push(specViolation(version, 'security-scheme-scheme', `http security scheme '${name}' must have a 'scheme' field with string value`));
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case 'oauth2':
|
|
155
|
+
validateOauth2Flows(name, s.flows, version, errors);
|
|
156
|
+
break;
|
|
157
|
+
case 'openIdConnect':
|
|
158
|
+
if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') {
|
|
159
|
+
errors.push(specViolation(version, 'security-scheme-open-id-connect-url', `openIdConnect security scheme '${name}' must have an 'openIdConnectUrl' field with string value`));
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case 'mutualTLS':
|
|
163
|
+
break; // no scheme-specific fields
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function validateOauth2Flows(name, flows, version, errors) {
|
|
167
|
+
if (typeof flows !== 'object' || flows === null || Array.isArray(flows)) {
|
|
168
|
+
errors.push(specViolation(version, 'security-scheme-flows', `oauth2 security scheme '${name}' must have a 'flows' field with object value`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const [flowKind, flow] of Object.entries(flows)) {
|
|
172
|
+
if (flow === undefined) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (typeof flow !== 'object' || flow === null || Array.isArray(flow)) {
|
|
176
|
+
errors.push(specViolation(version, 'oauth-flow-object', `oauth2 flow '${flowKind}' of security scheme '${name}' must be an object`));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const scopes = flow.scopes;
|
|
180
|
+
if (typeof scopes !== 'object' || scopes === null || Array.isArray(scopes)) {
|
|
181
|
+
errors.push(specViolation(version, 'oauth-flow-scopes', `oauth2 flow '${flowKind}' of security scheme '${name}' must have a 'scopes' field with object value`));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function validateSecurityList(list, context, declaredNames, resolvedByName, version, errors) {
|
|
186
|
+
if (list === undefined) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!Array.isArray(list)) {
|
|
190
|
+
errors.push(specViolation(version, 'security-requirement-object', `'${context}' must be an array`));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
list.forEach((requirement, index) => {
|
|
194
|
+
const reqContext = `${context}[${index}]`;
|
|
195
|
+
if (typeof requirement !== 'object' || requirement === null || Array.isArray(requirement)) {
|
|
196
|
+
errors.push(specViolation(version, 'security-requirement-object', `'${reqContext}' must be an object`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
for (const [schemeName, scopes] of Object.entries(requirement)) {
|
|
200
|
+
if (!declaredNames?.has(schemeName)) {
|
|
201
|
+
errors.push(specViolation(version, 'security-requirement-object', `'${reqContext}' references unknown security scheme '${schemeName}' (not declared in components.securitySchemes)`));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const schemeType = resolvedByName?.get(schemeName)?.type;
|
|
205
|
+
if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== 'string')) {
|
|
206
|
+
errors.push(specViolation(version, 'security-requirement-object', `'${reqContext}.${schemeName}' must be an array of strings`));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (version === '3.0' &&
|
|
210
|
+
scopes.length > 0 &&
|
|
211
|
+
schemeType !== undefined &&
|
|
212
|
+
schemeType !== 'oauth2' &&
|
|
213
|
+
schemeType !== 'openIdConnect') {
|
|
214
|
+
errors.push(specViolation(version, 'security-requirement-object', `'${reqContext}.${schemeName}' must have an empty scopes array for '${String(schemeType)}' schemes in OpenAPI 3.0`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|