@zudojs/openapi 0.1.0 → 1.1.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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -21
  3. package/dist/index.d.ts +26 -21
  4. package/dist/index.js +31 -13
  5. package/dist/openApiComponents/index.d.ts +2 -2
  6. package/dist/openApiComponents/index.js +1 -1
  7. package/dist/openApiConstants/index.d.ts +1 -1
  8. package/dist/openApiConstants/index.js +1 -1
  9. package/dist/openApiConstants/openApiConstants.core.d.ts +15 -3
  10. package/dist/openApiConstants/openApiConstants.core.js +22 -3
  11. package/dist/openApiDocument/index.d.ts +1 -1
  12. package/dist/openApiDocument/index.js +1 -1
  13. package/dist/openApiDocument/openApiDocument.builder.d.ts +39 -52
  14. package/dist/openApiDocument/openApiDocument.builder.js +73 -72
  15. package/dist/openApiErrors/index.d.ts +2 -2
  16. package/dist/openApiErrors/index.js +2 -1
  17. package/dist/openApiErrors/openApiError.base.d.ts +9 -0
  18. package/dist/openApiErrors/openApiError.base.js +7 -0
  19. package/dist/openApiErrors/openApiError.types.d.ts +34 -14
  20. package/dist/openApiErrors/openApiError.types.js +83 -35
  21. package/dist/openApiHttp/index.d.ts +2 -2
  22. package/dist/openApiHttp/index.js +2 -2
  23. package/dist/openApiHttp/openApiHttpAdapter.core.d.ts +127 -25
  24. package/dist/openApiHttp/openApiHttpAdapter.core.js +212 -16
  25. package/dist/openApiRegistry/openApiRegistry.core.d.ts +38 -5
  26. package/dist/openApiRegistry/openApiRegistry.core.js +167 -66
  27. package/dist/openApiRegistry/openApiRegistry.type.d.ts +18 -13
  28. package/dist/openApiRegistry/openApiRegistry.type.js +1 -2
  29. package/dist/openApiRouting/index.d.ts +2 -2
  30. package/dist/openApiRouting/index.js +1 -1
  31. package/dist/openApiRouting/routeConverter.core.d.ts +25 -7
  32. package/dist/openApiRouting/routeConverter.core.js +104 -58
  33. package/dist/openApiRouting/routeMetadata.type.d.ts +40 -17
  34. package/dist/openApiRouting/routeMetadata.type.js +9 -0
  35. package/dist/openApiRouting/routeScanner.core.d.ts +17 -47
  36. package/dist/openApiRouting/routeScanner.core.js +40 -55
  37. package/dist/openApiSchema/index.d.ts +4 -4
  38. package/dist/openApiSchema/index.js +2 -2
  39. package/dist/openApiSchema/references.core.d.ts +18 -1
  40. package/dist/openApiSchema/references.core.js +21 -1
  41. package/dist/openApiSchema/schemaConverter.core.d.ts +51 -3
  42. package/dist/openApiSchema/schemaConverter.core.js +475 -118
  43. package/dist/openApiSchema/schemaRegistry.core.d.ts +27 -10
  44. package/dist/openApiSchema/schemaRegistry.core.js +33 -14
  45. package/dist/openApiSerialization/openApiSerializer.core.d.ts +10 -4
  46. package/dist/openApiSerialization/openApiSerializer.core.js +135 -9
  47. package/dist/openApiTypes/index.d.ts +1 -1
  48. package/dist/openApiTypes/openApiDocument.type.d.ts +16 -0
  49. package/dist/openApiTypes/openApiSchema.type.d.ts +24 -1
  50. package/dist/openApiTypes/openApiSchema.type.js +3 -0
  51. package/dist/openApiUi/index.d.ts +8 -0
  52. package/dist/openApiUi/index.js +8 -0
  53. package/dist/openApiUi/openApiUi.brand.d.ts +26 -0
  54. package/dist/openApiUi/openApiUi.brand.js +44 -0
  55. package/dist/openApiUi/openApiUi.core.d.ts +47 -0
  56. package/dist/openApiUi/openApiUi.core.js +143 -0
  57. package/dist/openApiValidation/index.d.ts +2 -2
  58. package/dist/openApiValidation/index.js +1 -1
  59. package/dist/openApiValidation/openApiValidator.core.d.ts +33 -11
  60. package/dist/openApiValidation/openApiValidator.core.js +274 -66
  61. package/package.json +25 -18
  62. package/dist/.tsbuildinfo +0 -1
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/index.js.map +0 -1
  65. package/dist/openApiComponents/index.d.ts.map +0 -1
  66. package/dist/openApiComponents/index.js.map +0 -1
  67. package/dist/openApiConstants/index.d.ts.map +0 -1
  68. package/dist/openApiConstants/index.js.map +0 -1
  69. package/dist/openApiConstants/openApiConstants.core.d.ts.map +0 -1
  70. package/dist/openApiConstants/openApiConstants.core.js.map +0 -1
  71. package/dist/openApiDocument/index.d.ts.map +0 -1
  72. package/dist/openApiDocument/index.js.map +0 -1
  73. package/dist/openApiDocument/openApiDocument.builder.d.ts.map +0 -1
  74. package/dist/openApiDocument/openApiDocument.builder.js.map +0 -1
  75. package/dist/openApiErrors/index.d.ts.map +0 -1
  76. package/dist/openApiErrors/index.js.map +0 -1
  77. package/dist/openApiErrors/openApiError.base.d.ts.map +0 -1
  78. package/dist/openApiErrors/openApiError.base.js.map +0 -1
  79. package/dist/openApiErrors/openApiError.core.d.ts.map +0 -1
  80. package/dist/openApiErrors/openApiError.core.js.map +0 -1
  81. package/dist/openApiErrors/openApiError.types.d.ts.map +0 -1
  82. package/dist/openApiErrors/openApiError.types.js.map +0 -1
  83. package/dist/openApiHttp/index.d.ts.map +0 -1
  84. package/dist/openApiHttp/index.js.map +0 -1
  85. package/dist/openApiHttp/openApiHttpAdapter.core.d.ts.map +0 -1
  86. package/dist/openApiHttp/openApiHttpAdapter.core.js.map +0 -1
  87. package/dist/openApiRegistry/index.d.ts.map +0 -1
  88. package/dist/openApiRegistry/index.js.map +0 -1
  89. package/dist/openApiRegistry/openApiRegistry.core.d.ts.map +0 -1
  90. package/dist/openApiRegistry/openApiRegistry.core.js.map +0 -1
  91. package/dist/openApiRegistry/openApiRegistry.type.d.ts.map +0 -1
  92. package/dist/openApiRegistry/openApiRegistry.type.js.map +0 -1
  93. package/dist/openApiRouting/index.d.ts.map +0 -1
  94. package/dist/openApiRouting/index.js.map +0 -1
  95. package/dist/openApiRouting/routeConverter.core.d.ts.map +0 -1
  96. package/dist/openApiRouting/routeConverter.core.js.map +0 -1
  97. package/dist/openApiRouting/routeMetadata.type.d.ts.map +0 -1
  98. package/dist/openApiRouting/routeMetadata.type.js.map +0 -1
  99. package/dist/openApiRouting/routeScanner.core.d.ts.map +0 -1
  100. package/dist/openApiRouting/routeScanner.core.js.map +0 -1
  101. package/dist/openApiSchema/index.d.ts.map +0 -1
  102. package/dist/openApiSchema/index.js.map +0 -1
  103. package/dist/openApiSchema/references.core.d.ts.map +0 -1
  104. package/dist/openApiSchema/references.core.js.map +0 -1
  105. package/dist/openApiSchema/schemaConverter.core.d.ts.map +0 -1
  106. package/dist/openApiSchema/schemaConverter.core.js.map +0 -1
  107. package/dist/openApiSchema/schemaRegistry.core.d.ts.map +0 -1
  108. package/dist/openApiSchema/schemaRegistry.core.js.map +0 -1
  109. package/dist/openApiSerialization/index.d.ts.map +0 -1
  110. package/dist/openApiSerialization/index.js.map +0 -1
  111. package/dist/openApiSerialization/openApiSerializer.core.d.ts.map +0 -1
  112. package/dist/openApiSerialization/openApiSerializer.core.js.map +0 -1
  113. package/dist/openApiTypes/index.d.ts.map +0 -1
  114. package/dist/openApiTypes/index.js.map +0 -1
  115. package/dist/openApiTypes/openApiComponent.type.d.ts.map +0 -1
  116. package/dist/openApiTypes/openApiComponent.type.js.map +0 -1
  117. package/dist/openApiTypes/openApiDocument.type.d.ts.map +0 -1
  118. package/dist/openApiTypes/openApiDocument.type.js.map +0 -1
  119. package/dist/openApiTypes/openApiPath.type.d.ts.map +0 -1
  120. package/dist/openApiTypes/openApiPath.type.js.map +0 -1
  121. package/dist/openApiTypes/openApiSchema.type.d.ts.map +0 -1
  122. package/dist/openApiTypes/openApiSchema.type.js.map +0 -1
  123. package/dist/openApiTypes/openApiSecurity.type.d.ts.map +0 -1
  124. package/dist/openApiTypes/openApiSecurity.type.js.map +0 -1
  125. package/dist/openApiTypes/openApiTypes.core.d.ts.map +0 -1
  126. package/dist/openApiTypes/openApiTypes.core.js.map +0 -1
  127. package/dist/openApiValidation/index.d.ts.map +0 -1
  128. package/dist/openApiValidation/index.js.map +0 -1
  129. package/dist/openApiValidation/openApiValidator.core.d.ts.map +0 -1
  130. package/dist/openApiValidation/openApiValidator.core.js.map +0 -1
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @zudojs/openapi/openApiUi
3
+ *
4
+ * Renders a branded documentation page (Swagger UI or ReDoc) for a served
5
+ * OpenAPI document. The page carries the Zudo mark in its header and as its
6
+ * favicon, and reads the specification from `specUrl`.
7
+ *
8
+ * The returned string is a complete HTML document; serve it with
9
+ * `content-type: text/html`. {@link OpenAPIManager.toUIResponse} does that.
10
+ */
11
+ import { ZUDO_FAVICON_DATA_URI, ZUDO_MARK_DATA_URI, ZUDO_SITE_URL, ZUDO_WORDMARK_DARK_DATA_URI, } from "./openApiUi.brand.js";
12
+ const DEFAULT_TITLE = "API reference";
13
+ const SWAGGER_ASSETS = "https://unpkg.com/swagger-ui-dist@5";
14
+ const REDOC_ASSETS = "https://cdn.redoc.ly/redoc/latest/bundles";
15
+ /** The default logo used by every branded page and by `info["x-logo"]`. */
16
+ export function zudoLogo(overrides) {
17
+ return Object.freeze({
18
+ url: ZUDO_MARK_DATA_URI,
19
+ href: ZUDO_SITE_URL,
20
+ altText: "Zudo",
21
+ backgroundColor: "#FAFAF9",
22
+ ...overrides,
23
+ });
24
+ }
25
+ /** Escapes text for safe interpolation into HTML attribute or text nodes. */
26
+ export function escapeHtml(value) {
27
+ return value
28
+ .replace(/&/g, "&")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&#39;");
33
+ }
34
+ /** Serialises a value for an inline `<script>` without letting `</script>` through. */
35
+ function jsLiteral(value) {
36
+ return JSON.stringify(value)
37
+ .replace(/</g, "\\u003c")
38
+ .replace(/\u2028/g, "\\u2028")
39
+ .replace(/\u2029/g, "\\u2029");
40
+ }
41
+ /** Rejects URLs that could execute script when placed in `src`/`href`. */
42
+ function safeUrl(value) {
43
+ const trimmed = value.trim();
44
+ if (/^\s*(javascript|vbscript):/i.test(trimmed)) {
45
+ throw new TypeError(`Refusing to render a "${trimmed.split(":")[0]}:" URL`);
46
+ }
47
+ // Attributes are always double-quoted here, so a single quote (common in
48
+ // data URIs) can stay as-is.
49
+ return trimmed
50
+ .replace(/&/g, "&amp;")
51
+ .replace(/</g, "&lt;")
52
+ .replace(/>/g, "&gt;")
53
+ .replace(/"/g, "&quot;");
54
+ }
55
+ /**
56
+ * Guards the one place caller-supplied text reaches the page unescaped.
57
+ *
58
+ * A `<style>` element ends at the first `</style`, whatever the CSS around it
59
+ * says, so `customCss` containing that sequence closes the block early and
60
+ * everything after it is parsed as HTML — a script tag included. There is no
61
+ * escape that keeps the CSS valid, so this refuses rather than mangles.
62
+ */
63
+ function safeCss(css) {
64
+ if (css === undefined)
65
+ return "";
66
+ if (/<\/\s*style/i.test(css)) {
67
+ throw new TypeError("customCss may not contain a closing </style> tag: it would end the " +
68
+ "style block and let the rest be parsed as HTML.");
69
+ }
70
+ return css;
71
+ }
72
+ const THEME_CSS = `
73
+ :root{--zd-ink:#1A1A2E;--zd-red:#C0392B;--zd-bg:#FAFAF9;--zd-border:#E5E7EB}
74
+ *{border-radius:0!important}
75
+ html,body{margin:0;background:var(--zd-bg);font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif}
76
+ .zudo-bar{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:14px;height:56px;padding:0 20px;background:var(--zd-ink);color:var(--zd-bg);border-bottom:3px solid var(--zd-red)}
77
+ .zudo-bar a{display:inline-flex;align-items:center;color:inherit;text-decoration:none}
78
+ .zudo-bar img{height:22px;width:auto;display:block}
79
+ .zudo-bar .zudo-sep{width:1px;height:22px;background:rgba(250,250,249,.25)}
80
+ .zudo-bar .zudo-title{font-weight:800;font-size:14px;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
81
+ .zudo-bar .zudo-spec{margin-left:auto;font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;text-transform:uppercase;color:rgba(250,250,249,.75);border:2px solid rgba(250,250,249,.3);padding:6px 10px}
82
+ .zudo-bar .zudo-spec:hover{border-color:var(--zd-bg);color:var(--zd-bg)}
83
+ .zudo-foot{padding:16px 20px;border-top:1px solid var(--zd-border);color:#6B7280;font-size:12px;display:flex;gap:8px;align-items:center}
84
+ .zudo-foot img{height:14px;width:auto}
85
+ .swagger-ui .topbar{display:none}
86
+ .swagger-ui .info .title{font-weight:900;letter-spacing:-.01em;color:var(--zd-ink)}
87
+ .swagger-ui .opblock{border-width:2px;box-shadow:none}
88
+ .swagger-ui .btn{border-width:2px;font-weight:700}
89
+ .swagger-ui .btn.execute{background:var(--zd-red);border-color:var(--zd-red)}
90
+ .swagger-ui .scheme-container{box-shadow:none;border-bottom:1px solid var(--zd-border);background:#fff}
91
+ `;
92
+ function header(options, title) {
93
+ const logo = options.logo === undefined ? zudoLogo({ url: ZUDO_WORDMARK_DARK_DATA_URI }) : options.logo;
94
+ const logoHtml = logo === false
95
+ ? ""
96
+ : `<a class="zudo-logo" href="${safeUrl(logo.href ?? ZUDO_SITE_URL)}" rel="noopener"><img src="${safeUrl(logo.url)}" alt="${escapeHtml(logo.altText ?? "Zudo")}"></a><span class="zudo-sep" aria-hidden="true"></span>`;
97
+ return (`<header class="zudo-bar">${logoHtml}<span class="zudo-title">${escapeHtml(title)}</span>` +
98
+ `<a class="zudo-spec" href="${safeUrl(options.specUrl)}">Open spec</a></header>`);
99
+ }
100
+ function footer() {
101
+ return `<footer class="zudo-foot"><span>Generated by</span><a href="${ZUDO_SITE_URL}" rel="noopener"><img src="${ZUDO_MARK_DATA_URI}" alt="Zudo"></a><span>@zudojs/openapi</span></footer>`;
102
+ }
103
+ function head(options, title, extra) {
104
+ const favicon = options.favicon === false
105
+ ? ""
106
+ : `<link rel="icon" href="${safeUrl(options.favicon ?? ZUDO_FAVICON_DATA_URI)}">`;
107
+ return (`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
108
+ `<meta name="viewport" content="width=device-width,initial-scale=1">` +
109
+ `<meta name="theme-color" content="#1A1A2E">` +
110
+ `<title>${escapeHtml(title)}</title>${favicon}${extra}` +
111
+ `<style>${THEME_CSS}${safeCss(options.customCss)}</style></head>`);
112
+ }
113
+ /** Renders a complete, branded documentation page for `options.specUrl`. */
114
+ export function renderOpenAPIUI(options) {
115
+ if (!options || typeof options.specUrl !== "string" || !options.specUrl.trim()) {
116
+ throw new TypeError("renderOpenAPIUI requires a specUrl");
117
+ }
118
+ const title = options.title ?? DEFAULT_TITLE;
119
+ const renderer = options.renderer ?? "swagger";
120
+ if (renderer === "redoc") {
121
+ const base = (options.assetsBaseUrl ?? REDOC_ASSETS).replace(/\/+$/, "");
122
+ return (head(options, title, "") +
123
+ `<body>${header(options, title)}` +
124
+ `<redoc spec-url="${safeUrl(options.specUrl)}" hide-hostname></redoc>` +
125
+ `<script src="${safeUrl(base)}/redoc.standalone.js"></script>` +
126
+ `${footer()}</body></html>`);
127
+ }
128
+ const base = (options.assetsBaseUrl ?? SWAGGER_ASSETS).replace(/\/+$/, "");
129
+ const config = {
130
+ url: options.specUrl,
131
+ dom_id: "#zudo-openapi",
132
+ deepLinking: true,
133
+ displayRequestDuration: true,
134
+ tryItOutEnabled: true,
135
+ ...options.swaggerOptions,
136
+ };
137
+ return (head(options, title, `<link rel="stylesheet" href="${safeUrl(base)}/swagger-ui.css">`) +
138
+ `<body>${header(options, title)}<div id="zudo-openapi"></div>` +
139
+ `<script src="${safeUrl(base)}/swagger-ui-bundle.js" crossorigin></script>` +
140
+ `<script>window.addEventListener("load",function(){window.ui=SwaggerUIBundle(${jsLiteral(config)});});</script>` +
141
+ `${footer()}</body></html>`);
142
+ }
143
+ //# sourceMappingURL=openApiUi.core.js.map
@@ -3,6 +3,6 @@
3
3
  *
4
4
  * OpenAPI document validation.
5
5
  */
6
- export type { OpenAPIValidator } from "./openApiValidator.core.js";
7
- export { OpenAPIValidatorImpl } from "./openApiValidator.core.js";
6
+ export type { OpenAPIValidator, OpenAPIValidationResult, } from "./openApiValidator.core.js";
7
+ export { OpenAPIValidatorImpl, createOpenAPIValidator, } from "./openApiValidator.core.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * OpenAPI document validation.
5
5
  */
6
- export { OpenAPIValidatorImpl } from "./openApiValidator.core.js";
6
+ export { OpenAPIValidatorImpl, createOpenAPIValidator, } from "./openApiValidator.core.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -1,22 +1,44 @@
1
1
  import type { OpenAPIDocument } from "../openApiTypes/openApiTypes.core.js";
2
- import type { OpenAPIValidationIssue } from "../openApiErrors/openApiError.core.js";
2
+ import type { OpenAPIValidationIssue } from "../openApiErrors/openApiError.types.js";
3
+ /** The result of validating a document. */
4
+ export interface OpenAPIValidationResult {
5
+ readonly valid: boolean;
6
+ readonly errors: readonly OpenAPIValidationIssue[];
7
+ readonly warnings: readonly OpenAPIValidationIssue[];
8
+ }
3
9
  export interface OpenAPIValidator {
4
- validate(document: OpenAPIDocument): {
5
- readonly valid: boolean;
6
- readonly errors: readonly OpenAPIValidationIssue[];
7
- readonly warnings: readonly OpenAPIValidationIssue[];
8
- };
10
+ validate(document: OpenAPIDocument): OpenAPIValidationResult;
9
11
  assertValid(document: OpenAPIDocument): void;
10
12
  }
11
13
  export declare class OpenAPIValidatorImpl implements OpenAPIValidator {
12
- validate(document: OpenAPIDocument): {
13
- readonly valid: boolean;
14
- readonly errors: readonly OpenAPIValidationIssue[];
15
- readonly warnings: readonly OpenAPIValidationIssue[];
16
- };
14
+ validate(document: OpenAPIDocument): OpenAPIValidationResult;
17
15
  assertValid(document: OpenAPIDocument): void;
18
16
  private validateDocumentStructure;
19
17
  private validatePaths;
18
+ private validateOperation;
20
19
  private validateOperationIds;
20
+ /**
21
+ * Checks that every security requirement names a declared scheme.
22
+ *
23
+ * A typo here produces a document that *looks* protected: tooling shows a
24
+ * lock icon, generated clients send nothing, and the mismatch is invisible
25
+ * without this check.
26
+ */
27
+ private validateSecurity;
28
+ /**
29
+ * Checks every `$ref`: that a local one resolves, and that a non-local one
30
+ * is at least not a scheme a resolver should never be pointed at.
31
+ *
32
+ * A `$ref` is an instruction to whatever dereferences the document. A
33
+ * `file:///etc/passwd` or `http://169.254.169.254/...` reference turns any
34
+ * downstream tool — a bundler, a mock server, a client generator — into an
35
+ * arbitrary-file-read or SSRF primitive on behalf of whoever supplied the
36
+ * spec. Accepting those silently, as a check that only looked at refs
37
+ * starting with `#` did, is the whole vulnerability.
38
+ */
39
+ private validateReferences;
40
+ private validateReference;
21
41
  }
42
+ /** Creates a validator. */
43
+ export declare function createOpenAPIValidator(): OpenAPIValidatorImpl;
22
44
  //# sourceMappingURL=openApiValidator.core.d.ts.map
@@ -1,4 +1,7 @@
1
- import { OpenAPIValidationError } from "../openApiErrors/openApiError.core.js";
1
+ import { OpenAPIValidationError } from "../openApiErrors/openApiError.types.js";
2
+ import { MAX_OPERATION_ID_LENGTH, RESPONSE_KEY_PATTERN, SUPPORTED_OPENAPI_VERSIONS, } from "../openApiConstants/openApiConstants.core.js";
3
+ import { extractPathParameters } from "../openApiRouting/routeConverter.core.js";
4
+ import { unescapeJsonPointerSegment } from "../openApiSchema/references.core.js";
2
5
  const OPERATIONS = [
3
6
  "get",
4
7
  "put",
@@ -9,106 +12,311 @@ const OPERATIONS = [
9
12
  "patch",
10
13
  "trace",
11
14
  ];
15
+ /** A path segment that still uses `:name` rather than `{name}`. */
16
+ const COLON_SEGMENT = /(^|\/):[^/]+/;
17
+ /** Leading URI scheme of a non-local `$ref`, when it has one. */
18
+ const URI_SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):/;
19
+ /** Schemes a `$ref` resolver may reasonably be pointed at. */
20
+ const FETCHABLE_SCHEMES = new Set(["http", "https"]);
21
+ function error(collector, path, message) {
22
+ collector.errors.push({ path, message, severity: "error" });
23
+ }
24
+ function warn(collector, path, message) {
25
+ collector.warnings.push({ path, message, severity: "warning" });
26
+ }
12
27
  export class OpenAPIValidatorImpl {
13
28
  validate(document) {
14
- const errors = [];
15
- const warnings = [];
16
- this.validateDocumentStructure(document, errors, warnings);
17
- this.validatePaths(document, errors, warnings);
18
- this.validateOperationIds(document, errors, warnings);
29
+ const collector = { errors: [], warnings: [] };
30
+ this.validateDocumentStructure(document, collector);
31
+ this.validatePaths(document, collector);
32
+ this.validateOperationIds(document, collector);
33
+ this.validateSecurity(document, collector);
34
+ this.validateReferences(document, collector);
19
35
  return {
20
- valid: errors.length === 0,
21
- errors: Object.freeze([...errors]),
22
- warnings: Object.freeze([...warnings]),
36
+ valid: collector.errors.length === 0,
37
+ errors: Object.freeze([...collector.errors]),
38
+ warnings: Object.freeze([...collector.warnings]),
23
39
  };
24
40
  }
25
41
  assertValid(document) {
26
42
  const result = this.validate(document);
27
- if (!result.valid)
28
- throw new OpenAPIValidationError("OpenAPI validation failed.", result.errors);
43
+ if (!result.valid) {
44
+ throw new OpenAPIValidationError(`OpenAPI validation failed with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`, result.errors);
45
+ }
29
46
  }
30
- validateDocumentStructure(document, errors, warnings) {
31
- if (!document.openapi)
32
- errors.push({
33
- path: ["openapi"],
34
- message: "OpenAPI version is required.",
35
- severity: "error",
36
- });
47
+ validateDocumentStructure(document, collector) {
48
+ if (!document.openapi) {
49
+ error(collector, ["openapi"], "OpenAPI version is required.");
50
+ }
51
+ else if (!SUPPORTED_OPENAPI_VERSIONS.includes(document.openapi)) {
52
+ error(collector, ["openapi"], `Unsupported OpenAPI version "${document.openapi}". Supported: ${SUPPORTED_OPENAPI_VERSIONS.join(", ")}.`);
53
+ }
37
54
  if (!document.info) {
38
- errors.push({
39
- path: ["info"],
40
- message: "Info object is required.",
41
- severity: "error",
42
- });
55
+ error(collector, ["info"], "Info object is required.");
43
56
  }
44
57
  else {
45
- if (!document.info.title)
46
- errors.push({
47
- path: ["info", "title"],
48
- message: "Info title is required.",
49
- severity: "error",
50
- });
51
- if (!document.info.version)
52
- errors.push({
53
- path: ["info", "version"],
54
- message: "Info version is required.",
55
- severity: "error",
56
- });
58
+ if (!document.info.title) {
59
+ error(collector, ["info", "title"], "Info title is required.");
60
+ }
61
+ if (!document.info.version) {
62
+ error(collector, ["info", "version"], "Info version is required.");
63
+ }
57
64
  }
58
65
  if (!document.paths || Object.keys(document.paths).length === 0) {
59
- warnings.push({
60
- path: ["paths"],
61
- message: "Document has no paths defined.",
62
- severity: "warning",
63
- });
66
+ warn(collector, ["paths"], "Document has no paths defined.");
67
+ }
68
+ for (const [index, server] of (document.servers ?? []).entries()) {
69
+ if (!server.url) {
70
+ error(collector, ["servers", index, "url"], "Server url is required.");
71
+ }
72
+ }
73
+ for (const [index, tag] of (document.tags ?? []).entries()) {
74
+ if (!tag.name) {
75
+ error(collector, ["tags", index, "name"], "Tag name is required.");
76
+ }
64
77
  }
65
78
  }
66
- validatePaths(document, errors, _warnings) {
67
- for (const [path, pathItem] of Object.entries(document.paths)) {
79
+ validatePaths(document, collector) {
80
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
68
81
  if (!pathItem)
69
82
  continue;
70
- if (path.includes(":") && !path.includes("{")) {
71
- errors.push({
72
- path: ["paths", path],
73
- message: `Path "${path}" contains un-converted path parameters. Use "/users/{id}" instead of "/users/:id".`,
74
- severity: "error",
75
- });
83
+ if (!path.startsWith("/")) {
84
+ error(collector, ["paths", path], `Path "${path}" must start with "/".`);
85
+ }
86
+ // Catches "/users/:id" and "/a/:id/{b}" alike, which a bare
87
+ // `includes(":") && !includes("{")` test misses.
88
+ if (COLON_SEGMENT.test(path)) {
89
+ error(collector, ["paths", path], `Path "${path}" contains un-converted path parameters. Use "/users/{id}" instead of "/users/:id".`);
90
+ }
91
+ const templateParameters = extractPathParameters(path);
92
+ const duplicates = templateParameters.filter((name, index) => templateParameters.indexOf(name) !== index);
93
+ for (const duplicate of duplicates) {
94
+ error(collector, ["paths", path], `Path "${path}" declares the template parameter "{${duplicate}}" more than once.`);
76
95
  }
77
96
  for (const method of OPERATIONS) {
78
97
  const operation = pathItem[method];
79
98
  if (!operation)
80
99
  continue;
81
- for (const param of operation.parameters ?? []) {
82
- if (param.in === "path" && !param.required) {
83
- errors.push({
84
- path: ["paths", path, method, "parameters"],
85
- message: `Path parameter "${param.name}" must be required.`,
86
- severity: "error",
87
- });
88
- }
100
+ this.validateOperation(path, method, operation, pathItem, templateParameters, collector);
101
+ }
102
+ }
103
+ }
104
+ validateOperation(path, method, operation, pathItem, templateParameters, collector) {
105
+ const base = ["paths", path, method];
106
+ if (!operation.responses || Object.keys(operation.responses).length === 0) {
107
+ error(collector, [...base, "responses"], "Every operation must declare at least one response.");
108
+ }
109
+ else {
110
+ for (const key of Object.keys(operation.responses)) {
111
+ if (!RESPONSE_KEY_PATTERN.test(key)) {
112
+ error(collector, [...base, "responses", key], `Response key "${key}" is not a status code, a range such as "4XX", or "default".`);
113
+ }
114
+ const response = operation.responses[key];
115
+ if (response && typeof response.description !== "string") {
116
+ error(collector, [...base, "responses", key, "description"], "A response must have a description.");
89
117
  }
90
118
  }
91
119
  }
120
+ if (operation.operationId &&
121
+ operation.operationId.length > MAX_OPERATION_ID_LENGTH) {
122
+ error(collector, [...base, "operationId"], `operationId "${operation.operationId}" exceeds the maximum length of ${MAX_OPERATION_ID_LENGTH}.`);
123
+ }
124
+ const parameters = [
125
+ ...(pathItem.parameters ?? []),
126
+ ...(operation.parameters ?? []),
127
+ ];
128
+ const seen = new Set();
129
+ for (const parameter of parameters) {
130
+ if (!parameter.name) {
131
+ error(collector, [...base, "parameters"], "Every parameter must have a name.");
132
+ continue;
133
+ }
134
+ const key = `${parameter.in}:${parameter.name}`;
135
+ if (seen.has(key)) {
136
+ error(collector, [...base, "parameters"], `Duplicate parameter "${parameter.name}" in "${parameter.in}".`);
137
+ }
138
+ seen.add(key);
139
+ if (parameter.in === "path" && parameter.required !== true) {
140
+ error(collector, [...base, "parameters"], `Path parameter "${parameter.name}" must be required.`);
141
+ }
142
+ }
143
+ // The classic OpenAPI mistake: a templated path with no matching
144
+ // parameter, or a path parameter that no template slot refers to.
145
+ const declaredPathParameters = new Set(parameters
146
+ .filter((parameter) => parameter.in === "path")
147
+ .map((parameter) => parameter.name));
148
+ for (const name of templateParameters) {
149
+ if (!declaredPathParameters.has(name)) {
150
+ error(collector, [...base, "parameters"], `Path template "{${name}}" has no matching parameter with in: "path".`);
151
+ }
152
+ }
153
+ for (const name of declaredPathParameters) {
154
+ if (!templateParameters.includes(name)) {
155
+ error(collector, [...base, "parameters"], `Path parameter "${name}" does not appear in the path template "${path}".`);
156
+ }
157
+ }
158
+ for (const tag of operation.tags ?? []) {
159
+ if (!tag) {
160
+ error(collector, [...base, "tags"], "Tags must be non-empty strings.");
161
+ }
162
+ }
92
163
  }
93
- validateOperationIds(document, errors, _warnings) {
94
- const operationIds = new Set();
95
- for (const [path, pathItem] of Object.entries(document.paths)) {
164
+ validateOperationIds(document, collector) {
165
+ const operationIds = new Map();
166
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
96
167
  if (!pathItem)
97
168
  continue;
98
169
  for (const method of OPERATIONS) {
99
170
  const operation = pathItem[method];
100
171
  if (!operation?.operationId)
101
172
  continue;
102
- if (operationIds.has(operation.operationId)) {
103
- errors.push({
104
- path: ["paths", path, method, "operationId"],
105
- message: `Duplicate operationId "${operation.operationId}".`,
106
- severity: "error",
107
- });
173
+ const previous = operationIds.get(operation.operationId);
174
+ if (previous) {
175
+ error(collector, ["paths", path, method, "operationId"], `Duplicate operationId "${operation.operationId}" (also used by ${previous}).`);
108
176
  }
109
- operationIds.add(operation.operationId);
177
+ else {
178
+ operationIds.set(operation.operationId, `${method.toUpperCase()} ${path}`);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ /**
184
+ * Checks that every security requirement names a declared scheme.
185
+ *
186
+ * A typo here produces a document that *looks* protected: tooling shows a
187
+ * lock icon, generated clients send nothing, and the mismatch is invisible
188
+ * without this check.
189
+ */
190
+ validateSecurity(document, collector) {
191
+ const schemes = new Set(Object.keys(document.components?.securitySchemes ?? {}));
192
+ /** Every scheme some requirement actually names. */
193
+ const required = new Set();
194
+ const check = (requirements, path) => {
195
+ for (const requirement of requirements) {
196
+ for (const name of Object.keys(requirement)) {
197
+ required.add(name);
198
+ if (!schemes.has(name)) {
199
+ error(collector, path, `Security requirement "${name}" does not match any scheme in components.securitySchemes.`);
200
+ }
201
+ }
202
+ }
203
+ };
204
+ check(document.security ?? [], ["security"]);
205
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
206
+ if (!pathItem)
207
+ continue;
208
+ for (const method of OPERATIONS) {
209
+ const operation = pathItem[method];
210
+ if (!operation?.security)
211
+ continue;
212
+ check(operation.security, ["paths", path, method, "security"]);
213
+ }
214
+ }
215
+ // The mirror image, and the more dangerous direction: a declared scheme
216
+ // that nothing requires. Viewers render the lock, generated clients offer
217
+ // the credential field, and every operation is in fact unauthenticated.
218
+ for (const name of schemes) {
219
+ if (!required.has(name)) {
220
+ warn(collector, ["components", "securitySchemes", name], `Security scheme "${name}" is declared but no operation or ` +
221
+ `document-level requirement uses it, so nothing it describes is ` +
222
+ `actually required.`);
110
223
  }
111
224
  }
112
225
  }
226
+ /**
227
+ * Checks every `$ref`: that a local one resolves, and that a non-local one
228
+ * is at least not a scheme a resolver should never be pointed at.
229
+ *
230
+ * A `$ref` is an instruction to whatever dereferences the document. A
231
+ * `file:///etc/passwd` or `http://169.254.169.254/...` reference turns any
232
+ * downstream tool — a bundler, a mock server, a client generator — into an
233
+ * arbitrary-file-read or SSRF primitive on behalf of whoever supplied the
234
+ * spec. Accepting those silently, as a check that only looked at refs
235
+ * starting with `#` did, is the whole vulnerability.
236
+ */
237
+ validateReferences(document, collector) {
238
+ const seen = new WeakSet();
239
+ const walk = (value, path) => {
240
+ if (typeof value !== "object" || value === null)
241
+ return;
242
+ if (seen.has(value))
243
+ return;
244
+ seen.add(value);
245
+ if (Array.isArray(value)) {
246
+ value.forEach((entry, index) => walk(entry, [...path, index]));
247
+ return;
248
+ }
249
+ const record = value;
250
+ const ref = record["$ref"];
251
+ if (typeof ref === "string") {
252
+ this.validateReference(document, ref, path, collector);
253
+ return;
254
+ }
255
+ for (const [key, entry] of Object.entries(record)) {
256
+ walk(entry, [...path, key]);
257
+ }
258
+ };
259
+ walk(document.paths ?? {}, ["paths"]);
260
+ walk(document.components ?? {}, ["components"]);
261
+ }
262
+ validateReference(document, ref, path, collector) {
263
+ if (ref.startsWith("#")) {
264
+ if (!resolvePointer(document, ref)) {
265
+ error(collector, path, `Reference "${ref}" does not resolve within the document.`);
266
+ }
267
+ return;
268
+ }
269
+ const scheme = URI_SCHEME.exec(ref)?.[1]?.toLowerCase();
270
+ if (scheme !== undefined && !FETCHABLE_SCHEMES.has(scheme)) {
271
+ error(collector, path, `Reference "${ref}" uses the "${scheme}:" scheme. Only local ` +
272
+ `references and http(s) URLs are allowed; a resolver following ` +
273
+ `this would read from outside the document.`);
274
+ return;
275
+ }
276
+ warn(collector, path, `Reference "${ref}" points outside the document. Whatever dereferences ` +
277
+ `this specification will fetch it — bundle the target into ` +
278
+ `components instead if the source is not fully trusted.`);
279
+ }
280
+ }
281
+ /** Resolves a local JSON Pointer, returning whether the target exists. */
282
+ function resolvePointer(document, ref) {
283
+ const pointer = ref.slice(1);
284
+ if (pointer === "" || pointer === "/")
285
+ return true;
286
+ if (!pointer.startsWith("/"))
287
+ return false;
288
+ let current = document;
289
+ for (const rawSegment of pointer.slice(1).split("/")) {
290
+ // A stray or truncated percent-escape makes `decodeURIComponent` throw a
291
+ // `URIError`. Letting that escape turned `validate()` — whose entire job
292
+ // is to report problems rather than raise them — into a crash on a
293
+ // hand-crafted `$ref`.
294
+ let decoded;
295
+ try {
296
+ decoded = decodeURIComponent(rawSegment);
297
+ }
298
+ catch {
299
+ return false;
300
+ }
301
+ const segment = unescapeJsonPointerSegment(decoded);
302
+ if (typeof current !== "object" || current === null)
303
+ return false;
304
+ if (Array.isArray(current)) {
305
+ const index = Number(segment);
306
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
307
+ return false;
308
+ }
309
+ current = current[index];
310
+ continue;
311
+ }
312
+ if (!(segment in current))
313
+ return false;
314
+ current = current[segment];
315
+ }
316
+ return current !== undefined;
317
+ }
318
+ /** Creates a validator. */
319
+ export function createOpenAPIValidator() {
320
+ return new OpenAPIValidatorImpl();
113
321
  }
114
322
  //# sourceMappingURL=openApiValidator.core.js.map