@zudojs/openapi 0.1.0 → 1.2.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 +340 -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 +129 -25
  24. package/dist/openApiHttp/openApiHttpAdapter.core.js +226 -16
  25. package/dist/openApiRegistry/openApiRegistry.core.d.ts +47 -5
  26. package/dist/openApiRegistry/openApiRegistry.core.js +180 -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 +61 -3
  42. package/dist/openApiSchema/schemaConverter.core.js +523 -117
  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 +158 -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 +157 -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 +295 -66
  61. package/package.json +29 -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,157 @@
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
+ /**
42
+ * Rejects URLs that could execute script when placed in `src`/`href`.
43
+ *
44
+ * The scheme is read after stripping ASCII control characters, because the
45
+ * URL parser browsers apply does the same: `java\nscript:alert(1)` is a
46
+ * `javascript:` URL to every browser and nothing to a regex that only
47
+ * looks at the string as written. `data:` is accepted for images only —
48
+ * the assets base is interpolated into `<script src>`, where a
49
+ * `data:text/javascript,` base would run inline.
50
+ */
51
+ function safeUrl(value) {
52
+ const trimmed = value.trim();
53
+ const normalized = trimmed.replace(/[\u0000-\u0020\u007f]/g, "");
54
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
55
+ if (scheme === "javascript" || scheme === "vbscript") {
56
+ throw new TypeError(`Refusing to render a "${scheme}:" URL`);
57
+ }
58
+ if (scheme === "data" && !/^data:image\//i.test(normalized)) {
59
+ throw new TypeError('Refusing to render a non-image "data:" URL; only data:image/* is allowed.');
60
+ }
61
+ // Attributes are always double-quoted here, so a single quote (common in
62
+ // data URIs) can stay as-is.
63
+ return trimmed
64
+ .replace(/&/g, "&amp;")
65
+ .replace(/</g, "&lt;")
66
+ .replace(/>/g, "&gt;")
67
+ .replace(/"/g, "&quot;");
68
+ }
69
+ /**
70
+ * Guards the one place caller-supplied text reaches the page unescaped.
71
+ *
72
+ * A `<style>` element ends at the first `</style`, whatever the CSS around it
73
+ * says, so `customCss` containing that sequence closes the block early and
74
+ * everything after it is parsed as HTML — a script tag included. There is no
75
+ * escape that keeps the CSS valid, so this refuses rather than mangles.
76
+ */
77
+ function safeCss(css) {
78
+ if (css === undefined)
79
+ return "";
80
+ if (/<\/\s*style/i.test(css)) {
81
+ throw new TypeError("customCss may not contain a closing </style> tag: it would end the " +
82
+ "style block and let the rest be parsed as HTML.");
83
+ }
84
+ return css;
85
+ }
86
+ const THEME_CSS = `
87
+ :root{--zd-ink:#1A1A2E;--zd-red:#C0392B;--zd-bg:#FAFAF9;--zd-border:#E5E7EB}
88
+ *{border-radius:0!important}
89
+ html,body{margin:0;background:var(--zd-bg);font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif}
90
+ .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)}
91
+ .zudo-bar a{display:inline-flex;align-items:center;color:inherit;text-decoration:none}
92
+ .zudo-bar img{height:22px;width:auto;display:block}
93
+ .zudo-bar .zudo-sep{width:1px;height:22px;background:rgba(250,250,249,.25)}
94
+ .zudo-bar .zudo-title{font-weight:800;font-size:14px;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
95
+ .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}
96
+ .zudo-bar .zudo-spec:hover{border-color:var(--zd-bg);color:var(--zd-bg)}
97
+ .zudo-foot{padding:16px 20px;border-top:1px solid var(--zd-border);color:#6B7280;font-size:12px;display:flex;gap:8px;align-items:center}
98
+ .zudo-foot img{height:14px;width:auto}
99
+ .swagger-ui .topbar{display:none}
100
+ .swagger-ui .info .title{font-weight:900;letter-spacing:-.01em;color:var(--zd-ink)}
101
+ .swagger-ui .opblock{border-width:2px;box-shadow:none}
102
+ .swagger-ui .btn{border-width:2px;font-weight:700}
103
+ .swagger-ui .btn.execute{background:var(--zd-red);border-color:var(--zd-red)}
104
+ .swagger-ui .scheme-container{box-shadow:none;border-bottom:1px solid var(--zd-border);background:#fff}
105
+ `;
106
+ function header(options, title) {
107
+ const logo = options.logo === undefined ? zudoLogo({ url: ZUDO_WORDMARK_DARK_DATA_URI }) : options.logo;
108
+ const logoHtml = logo === false
109
+ ? ""
110
+ : `<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>`;
111
+ return (`<header class="zudo-bar">${logoHtml}<span class="zudo-title">${escapeHtml(title)}</span>` +
112
+ `<a class="zudo-spec" href="${safeUrl(options.specUrl)}">Open spec</a></header>`);
113
+ }
114
+ function footer() {
115
+ 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>`;
116
+ }
117
+ function head(options, title, extra) {
118
+ const favicon = options.favicon === false
119
+ ? ""
120
+ : `<link rel="icon" href="${safeUrl(options.favicon ?? ZUDO_FAVICON_DATA_URI)}">`;
121
+ return (`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
122
+ `<meta name="viewport" content="width=device-width,initial-scale=1">` +
123
+ `<meta name="theme-color" content="#1A1A2E">` +
124
+ `<title>${escapeHtml(title)}</title>${favicon}${extra}` +
125
+ `<style>${THEME_CSS}${safeCss(options.customCss)}</style></head>`);
126
+ }
127
+ /** Renders a complete, branded documentation page for `options.specUrl`. */
128
+ export function renderOpenAPIUI(options) {
129
+ if (!options || typeof options.specUrl !== "string" || !options.specUrl.trim()) {
130
+ throw new TypeError("renderOpenAPIUI requires a specUrl");
131
+ }
132
+ const title = options.title ?? DEFAULT_TITLE;
133
+ const renderer = options.renderer ?? "swagger";
134
+ if (renderer === "redoc") {
135
+ const base = (options.assetsBaseUrl ?? REDOC_ASSETS).replace(/(?<!\/)\/+$/, "");
136
+ return (head(options, title, "") +
137
+ `<body>${header(options, title)}` +
138
+ `<redoc spec-url="${safeUrl(options.specUrl)}" hide-hostname></redoc>` +
139
+ `<script src="${safeUrl(base)}/redoc.standalone.js"></script>` +
140
+ `${footer()}</body></html>`);
141
+ }
142
+ const base = (options.assetsBaseUrl ?? SWAGGER_ASSETS).replace(/(?<!\/)\/+$/, "");
143
+ const config = {
144
+ url: options.specUrl,
145
+ dom_id: "#zudo-openapi",
146
+ deepLinking: true,
147
+ displayRequestDuration: true,
148
+ tryItOutEnabled: true,
149
+ ...options.swaggerOptions,
150
+ };
151
+ return (head(options, title, `<link rel="stylesheet" href="${safeUrl(base)}/swagger-ui.css">`) +
152
+ `<body>${header(options, title)}<div id="zudo-openapi"></div>` +
153
+ `<script src="${safeUrl(base)}/swagger-ui-bundle.js" crossorigin></script>` +
154
+ `<script>window.addEventListener("load",function(){window.ui=SwaggerUIBundle(${jsLiteral(config)});});</script>` +
155
+ `${footer()}</body></html>`);
156
+ }
157
+ //# 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, PATH_TEMPLATE_PARAMETER, 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,332 @@ 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
+ /** Paths seen so far, keyed by their template with parameter names erased. */
81
+ const shapes = new Map();
82
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
68
83
  if (!pathItem)
69
84
  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
- });
85
+ // "/users/{id}" and "/users/{userId}" have the same hierarchy and
86
+ // differ only in template names; the specification forbids both
87
+ // existing, since a router cannot tell them apart.
88
+ const shape = path.replace(PATH_TEMPLATE_PARAMETER, "{}");
89
+ const twin = shapes.get(shape);
90
+ if (twin !== undefined && twin !== path) {
91
+ error(collector, ["paths", path], `Path "${path}" is identical to "${twin}" apart from its template parameter names; such paths must not both exist.`);
92
+ }
93
+ else {
94
+ shapes.set(shape, path);
95
+ }
96
+ if (!path.startsWith("/")) {
97
+ error(collector, ["paths", path], `Path "${path}" must start with "/".`);
98
+ }
99
+ // Catches "/users/:id" and "/a/:id/{b}" alike, which a bare
100
+ // `includes(":") && !includes("{")` test misses.
101
+ if (COLON_SEGMENT.test(path)) {
102
+ error(collector, ["paths", path], `Path "${path}" contains un-converted path parameters. Use "/users/{id}" instead of "/users/:id".`);
103
+ }
104
+ const templateParameters = extractPathParameters(path);
105
+ const duplicates = templateParameters.filter((name, index) => templateParameters.indexOf(name) !== index);
106
+ for (const duplicate of duplicates) {
107
+ error(collector, ["paths", path], `Path "${path}" declares the template parameter "{${duplicate}}" more than once.`);
76
108
  }
77
109
  for (const method of OPERATIONS) {
78
110
  const operation = pathItem[method];
79
111
  if (!operation)
80
112
  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
- }
113
+ this.validateOperation(path, method, operation, pathItem, templateParameters, collector);
114
+ }
115
+ }
116
+ }
117
+ validateOperation(path, method, operation, pathItem, templateParameters, collector) {
118
+ const base = ["paths", path, method];
119
+ if (!operation.responses || Object.keys(operation.responses).length === 0) {
120
+ error(collector, [...base, "responses"], "Every operation must declare at least one response.");
121
+ }
122
+ else {
123
+ for (const key of Object.keys(operation.responses)) {
124
+ if (!RESPONSE_KEY_PATTERN.test(key)) {
125
+ error(collector, [...base, "responses", key], `Response key "${key}" is not a status code, a range such as "4XX", or "default".`);
126
+ }
127
+ const response = operation.responses[key];
128
+ if (response && typeof response.description !== "string") {
129
+ error(collector, [...base, "responses", key, "description"], "A response must have a description.");
130
+ }
131
+ }
132
+ }
133
+ if (operation.operationId &&
134
+ operation.operationId.length > MAX_OPERATION_ID_LENGTH) {
135
+ error(collector, [...base, "operationId"], `operationId "${operation.operationId}" exceeds the maximum length of ${MAX_OPERATION_ID_LENGTH}.`);
136
+ }
137
+ // Uniqueness is per list: the specification forbids duplicates within
138
+ // the path item's list and within the operation's list, while an
139
+ // operation-level parameter *overrides* a path-level one with the same
140
+ // name and location. Treating that override as a duplicate rejected
141
+ // legal documents.
142
+ const parameters = new Map();
143
+ for (const list of [pathItem.parameters, operation.parameters]) {
144
+ const seen = new Set();
145
+ for (const parameter of list ?? []) {
146
+ if (!parameter.name) {
147
+ error(collector, [...base, "parameters"], "Every parameter must have a name.");
148
+ continue;
149
+ }
150
+ const key = `${parameter.in}:${parameter.name}`;
151
+ if (seen.has(key)) {
152
+ error(collector, [...base, "parameters"], `Duplicate parameter "${parameter.name}" in "${parameter.in}".`);
153
+ }
154
+ seen.add(key);
155
+ parameters.set(key, parameter);
156
+ if (parameter.in === "path" && parameter.required !== true) {
157
+ error(collector, [...base, "parameters"], `Path parameter "${parameter.name}" must be required.`);
89
158
  }
90
159
  }
91
160
  }
161
+ // The classic OpenAPI mistake: a templated path with no matching
162
+ // parameter, or a path parameter that no template slot refers to.
163
+ const declaredPathParameters = new Set([...parameters.values()]
164
+ .filter((parameter) => parameter.in === "path")
165
+ .map((parameter) => parameter.name));
166
+ for (const name of templateParameters) {
167
+ if (!declaredPathParameters.has(name)) {
168
+ error(collector, [...base, "parameters"], `Path template "{${name}}" has no matching parameter with in: "path".`);
169
+ }
170
+ }
171
+ for (const name of declaredPathParameters) {
172
+ if (!templateParameters.includes(name)) {
173
+ error(collector, [...base, "parameters"], `Path parameter "${name}" does not appear in the path template "${path}".`);
174
+ }
175
+ }
176
+ for (const tag of operation.tags ?? []) {
177
+ if (!tag) {
178
+ error(collector, [...base, "tags"], "Tags must be non-empty strings.");
179
+ }
180
+ }
92
181
  }
93
- validateOperationIds(document, errors, _warnings) {
94
- const operationIds = new Set();
95
- for (const [path, pathItem] of Object.entries(document.paths)) {
182
+ validateOperationIds(document, collector) {
183
+ const operationIds = new Map();
184
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
96
185
  if (!pathItem)
97
186
  continue;
98
187
  for (const method of OPERATIONS) {
99
188
  const operation = pathItem[method];
100
189
  if (!operation?.operationId)
101
190
  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
- });
191
+ const previous = operationIds.get(operation.operationId);
192
+ if (previous) {
193
+ error(collector, ["paths", path, method, "operationId"], `Duplicate operationId "${operation.operationId}" (also used by ${previous}).`);
194
+ }
195
+ else {
196
+ operationIds.set(operation.operationId, `${method.toUpperCase()} ${path}`);
108
197
  }
109
- operationIds.add(operation.operationId);
110
198
  }
111
199
  }
112
200
  }
201
+ /**
202
+ * Checks that every security requirement names a declared scheme.
203
+ *
204
+ * A typo here produces a document that *looks* protected: tooling shows a
205
+ * lock icon, generated clients send nothing, and the mismatch is invisible
206
+ * without this check.
207
+ */
208
+ validateSecurity(document, collector) {
209
+ const schemes = new Set(Object.keys(document.components?.securitySchemes ?? {}));
210
+ /** Every scheme some requirement actually names. */
211
+ const required = new Set();
212
+ const check = (requirements, path) => {
213
+ for (const requirement of requirements) {
214
+ for (const name of Object.keys(requirement)) {
215
+ required.add(name);
216
+ if (!schemes.has(name)) {
217
+ error(collector, path, `Security requirement "${name}" does not match any scheme in components.securitySchemes.`);
218
+ }
219
+ }
220
+ }
221
+ };
222
+ check(document.security ?? [], ["security"]);
223
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
224
+ if (!pathItem)
225
+ continue;
226
+ for (const method of OPERATIONS) {
227
+ const operation = pathItem[method];
228
+ if (!operation?.security)
229
+ continue;
230
+ check(operation.security, ["paths", path, method, "security"]);
231
+ }
232
+ }
233
+ // The mirror image, and the more dangerous direction: a declared scheme
234
+ // that nothing requires. Viewers render the lock, generated clients offer
235
+ // the credential field, and every operation is in fact unauthenticated.
236
+ for (const name of schemes) {
237
+ if (!required.has(name)) {
238
+ warn(collector, ["components", "securitySchemes", name], `Security scheme "${name}" is declared but no operation or ` +
239
+ `document-level requirement uses it, so nothing it describes is ` +
240
+ `actually required.`);
241
+ }
242
+ }
243
+ }
244
+ /**
245
+ * Checks every `$ref`: that a local one resolves, and that a non-local one
246
+ * is at least not a scheme a resolver should never be pointed at.
247
+ *
248
+ * A `$ref` is an instruction to whatever dereferences the document. A
249
+ * `file:///etc/passwd` or `http://169.254.169.254/...` reference turns any
250
+ * downstream tool — a bundler, a mock server, a client generator — into an
251
+ * arbitrary-file-read or SSRF primitive on behalf of whoever supplied the
252
+ * spec. Accepting those silently, as a check that only looked at refs
253
+ * starting with `#` did, is the whole vulnerability.
254
+ */
255
+ validateReferences(document, collector) {
256
+ const seen = new WeakSet();
257
+ const walk = (value, path) => {
258
+ if (typeof value !== "object" || value === null)
259
+ return;
260
+ if (seen.has(value))
261
+ return;
262
+ seen.add(value);
263
+ if (Array.isArray(value)) {
264
+ value.forEach((entry, index) => walk(entry, [...path, index]));
265
+ return;
266
+ }
267
+ const record = value;
268
+ const ref = record["$ref"];
269
+ if (typeof ref === "string") {
270
+ this.validateReference(document, ref, path, collector);
271
+ return;
272
+ }
273
+ for (const [key, entry] of Object.entries(record)) {
274
+ walk(entry, [...path, key]);
275
+ }
276
+ };
277
+ walk(document.paths ?? {}, ["paths"]);
278
+ walk(document.components ?? {}, ["components"]);
279
+ }
280
+ validateReference(document, ref, path, collector) {
281
+ if (ref.startsWith("#")) {
282
+ if (!resolvePointer(document, ref)) {
283
+ error(collector, path, `Reference "${ref}" does not resolve within the document.`);
284
+ }
285
+ return;
286
+ }
287
+ const scheme = URI_SCHEME.exec(ref)?.[1]?.toLowerCase();
288
+ if (scheme !== undefined && !FETCHABLE_SCHEMES.has(scheme)) {
289
+ error(collector, path, `Reference "${ref}" uses the "${scheme}:" scheme. Only local ` +
290
+ `references and http(s) URLs are allowed; a resolver following ` +
291
+ `this would read from outside the document.`);
292
+ return;
293
+ }
294
+ warn(collector, path, `Reference "${ref}" points outside the document. Whatever dereferences ` +
295
+ `this specification will fetch it — bundle the target into ` +
296
+ `components instead if the source is not fully trusted.`);
297
+ }
298
+ }
299
+ /** Resolves a local JSON Pointer, returning whether the target exists. */
300
+ function resolvePointer(document, ref) {
301
+ const pointer = ref.slice(1);
302
+ if (pointer === "" || pointer === "/")
303
+ return true;
304
+ if (!pointer.startsWith("/"))
305
+ return false;
306
+ let current = document;
307
+ for (const rawSegment of pointer.slice(1).split("/")) {
308
+ // A stray or truncated percent-escape makes `decodeURIComponent` throw a
309
+ // `URIError`. Letting that escape turned `validate()` — whose entire job
310
+ // is to report problems rather than raise them — into a crash on a
311
+ // hand-crafted `$ref`.
312
+ let decoded;
313
+ try {
314
+ decoded = decodeURIComponent(rawSegment);
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ const segment = unescapeJsonPointerSegment(decoded);
320
+ if (typeof current !== "object" || current === null)
321
+ return false;
322
+ if (Array.isArray(current)) {
323
+ const index = Number(segment);
324
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
325
+ return false;
326
+ }
327
+ current = current[index];
328
+ continue;
329
+ }
330
+ // Own properties only: `in` walks the prototype chain, so a reference
331
+ // to `#/components/schemas/constructor` resolved to
332
+ // `Object.prototype.constructor` and validated as present.
333
+ if (!Object.prototype.hasOwnProperty.call(current, segment))
334
+ return false;
335
+ current = current[segment];
336
+ }
337
+ return current !== undefined;
338
+ }
339
+ /** Creates a validator. */
340
+ export function createOpenAPIValidator() {
341
+ return new OpenAPIValidatorImpl();
113
342
  }
114
343
  //# sourceMappingURL=openApiValidator.core.js.map