@zudojs/openapi 1.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.
- package/README.md +20 -7
- package/dist/openApiHttp/openApiHttpAdapter.core.d.ts +2 -0
- package/dist/openApiHttp/openApiHttpAdapter.core.js +15 -1
- package/dist/openApiRegistry/openApiRegistry.core.d.ts +9 -0
- package/dist/openApiRegistry/openApiRegistry.core.js +13 -0
- package/dist/openApiSchema/schemaConverter.core.d.ts +12 -2
- package/dist/openApiSchema/schemaConverter.core.js +57 -8
- package/dist/openApiSerialization/openApiSerializer.core.js +25 -2
- package/dist/openApiUi/openApiUi.core.js +19 -5
- package/dist/openApiValidation/openApiValidator.core.js +41 -20
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -101,9 +101,12 @@ manager.toUIResponse({ specUrl: "/openapi.json", assetsBaseUrl: "/vendor/swagger
|
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
Caller-supplied text is escaped, and input that would break out of the page is
|
|
104
|
-
refused rather than mangled: a `javascript:` or `vbscript:` URL throws
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
refused rather than mangled: a `javascript:` or `vbscript:` URL throws (the
|
|
105
|
+
scheme is read after stripping the control characters browsers ignore, so
|
|
106
|
+
`java\nscript:` is caught too), a `data:` URL that is not an image throws
|
|
107
|
+
(the assets base lands in `<script src>`), an empty `specUrl` throws, and
|
|
108
|
+
`customCss` containing `</style>` throws — that sequence ends the style block
|
|
109
|
+
and lets the rest be parsed as HTML.
|
|
107
110
|
|
|
108
111
|
## Branding
|
|
109
112
|
|
|
@@ -180,9 +183,16 @@ produces
|
|
|
180
183
|
|
|
181
184
|
Objects, arrays, enums, literals, unions, discriminated unions,
|
|
182
185
|
intersections, records, tuples, sets, optionals, nullables, defaults,
|
|
183
|
-
refinements, transforms, lazy schemas and the coercion wrappers are
|
|
184
|
-
converted, along with string and number constraints (`min`, `max`,
|
|
185
|
-
`pattern`, `format`, `int`, `multipleOf`, `gt`, `lt`).
|
|
186
|
+
refinements, transforms, lazy schemas, bigints and the coercion wrappers are
|
|
187
|
+
all converted, along with string and number constraints (`min`, `max`,
|
|
188
|
+
`length`, `pattern`, `format`, `int`, `multipleOf`, `gt`, `lt`). Constraints
|
|
189
|
+
on a coercing schema (`coerce.number().int().min(1)`) are carried through.
|
|
190
|
+
|
|
191
|
+
A property is listed in `required` exactly when the object parser rejects
|
|
192
|
+
its absence: fields wrapped in `optional`, fields with a `default`, and
|
|
193
|
+
`any` / `unknown` fields are left out, and `.required()` forces every key
|
|
194
|
+
back on. A default supplied as a factory (`.default(() => new Date())`) is
|
|
195
|
+
invoked once and its value emitted.
|
|
186
196
|
|
|
187
197
|
Anything that cannot be expressed exactly produces a **warning** rather than a
|
|
188
198
|
silent `{}`:
|
|
@@ -259,7 +269,10 @@ The validator checks:
|
|
|
259
269
|
- that path templates and `in: "path"` parameters agree in both directions —
|
|
260
270
|
the classic "`{id}` is in the path but nowhere in `parameters`" mistake
|
|
261
271
|
- that path parameters are marked required, and that no parameter is declared
|
|
262
|
-
twice
|
|
272
|
+
twice in one list (an operation-level parameter may override a path-level
|
|
273
|
+
one with the same name and location)
|
|
274
|
+
- that no two paths are identical apart from their template parameter names
|
|
275
|
+
(`/users/{id}` next to `/users/{userId}`)
|
|
263
276
|
- `operationId` uniqueness and length
|
|
264
277
|
- that every `security` requirement names a scheme declared in
|
|
265
278
|
`components.securitySchemes` — a typo there yields a document that _looks_
|
|
@@ -57,6 +57,8 @@ export declare class OpenAPIManager {
|
|
|
57
57
|
private readonly cacheTtlMs;
|
|
58
58
|
private readonly now;
|
|
59
59
|
private readonly logo;
|
|
60
|
+
/** True when `branding` was a caller-supplied logo rather than the default. */
|
|
61
|
+
private readonly customLogo;
|
|
60
62
|
private cachedDocument?;
|
|
61
63
|
private cachedAt;
|
|
62
64
|
/** Whether the cached document was produced by a validating generate. */
|
|
@@ -21,6 +21,8 @@ export class OpenAPIManager {
|
|
|
21
21
|
cacheTtlMs;
|
|
22
22
|
now;
|
|
23
23
|
logo;
|
|
24
|
+
/** True when `branding` was a caller-supplied logo rather than the default. */
|
|
25
|
+
customLogo;
|
|
24
26
|
cachedDocument;
|
|
25
27
|
cachedAt = 0;
|
|
26
28
|
/** Whether the cached document was produced by a validating generate. */
|
|
@@ -44,6 +46,8 @@ export class OpenAPIManager {
|
|
|
44
46
|
: options.branding === true || options.branding === undefined
|
|
45
47
|
? zudoLogo()
|
|
46
48
|
: options.branding;
|
|
49
|
+
this.customLogo =
|
|
50
|
+
typeof options.branding === "object" && options.branding !== null;
|
|
47
51
|
if (options.info)
|
|
48
52
|
this.registry.setInfo(options.info);
|
|
49
53
|
for (const server of options.servers ?? [])
|
|
@@ -124,6 +128,11 @@ export class OpenAPIManager {
|
|
|
124
128
|
* Safe to call repeatedly: routes are replaced rather than re-added.
|
|
125
129
|
*/
|
|
126
130
|
generate(validate = false) {
|
|
131
|
+
// The scanner is the source of truth for routes. Re-setting on top of
|
|
132
|
+
// the previous route set kept routes that had since been removed or
|
|
133
|
+
// hidden, so `removeRoute()` had no effect once a document had been
|
|
134
|
+
// generated.
|
|
135
|
+
this.registry.clearRoutes();
|
|
127
136
|
for (const route of this.scanner.scan()) {
|
|
128
137
|
this.registry.setRoute(route);
|
|
129
138
|
}
|
|
@@ -234,11 +243,16 @@ export class OpenAPIManager {
|
|
|
234
243
|
? `${info.title} · API reference`
|
|
235
244
|
: undefined,
|
|
236
245
|
...options,
|
|
246
|
+
// Precedence: an explicit page logo, then the manager's `branding`
|
|
247
|
+
// (a custom logo is used as-is; `false` renders none), then the
|
|
248
|
+
// page's own default wordmark.
|
|
237
249
|
logo: options.logo !== undefined
|
|
238
250
|
? options.logo
|
|
239
251
|
: this.logo === undefined
|
|
240
252
|
? false
|
|
241
|
-
:
|
|
253
|
+
: this.customLogo
|
|
254
|
+
? this.logo
|
|
255
|
+
: undefined,
|
|
242
256
|
});
|
|
243
257
|
return Object.freeze({
|
|
244
258
|
status: 200,
|
|
@@ -35,6 +35,15 @@ export declare class OpenAPIRegistryImpl implements OpenAPIRegistry {
|
|
|
35
35
|
* `generate()` twice failed.
|
|
36
36
|
*/
|
|
37
37
|
setRoute(route: OpenAPIRoute): void;
|
|
38
|
+
/** Removes a route. Returns whether one was registered. */
|
|
39
|
+
removeRoute(method: string, path: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Drops every registered route while keeping components, servers, tags
|
|
42
|
+
* and security. `OpenAPIManager.generate()` rebuilds the route set from
|
|
43
|
+
* its scanner on each call; without this a route removed from the
|
|
44
|
+
* scanner lived on in the registry and in every later document.
|
|
45
|
+
*/
|
|
46
|
+
clearRoutes(): void;
|
|
38
47
|
private static register;
|
|
39
48
|
registerSchema(name: string, schema: OpenAPISchema): void;
|
|
40
49
|
registerResponse(name: string, response: OpenAPIResponse): void;
|
|
@@ -77,6 +77,19 @@ export class OpenAPIRegistryImpl {
|
|
|
77
77
|
}),
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
/** Removes a route. Returns whether one was registered. */
|
|
81
|
+
removeRoute(method, path) {
|
|
82
|
+
return this.routes.delete(OpenAPIRegistryImpl.routeKey({ method, path }));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Drops every registered route while keeping components, servers, tags
|
|
86
|
+
* and security. `OpenAPIManager.generate()` rebuilds the route set from
|
|
87
|
+
* its scanner on each call; without this a route removed from the
|
|
88
|
+
* scanner lived on in the registry and in every later document.
|
|
89
|
+
*/
|
|
90
|
+
clearRoutes() {
|
|
91
|
+
this.routes.clear();
|
|
92
|
+
}
|
|
80
93
|
static register(map, section, name, value) {
|
|
81
94
|
if (map.has(name)) {
|
|
82
95
|
throw new OpenAPIComponentConflictError(section, name);
|
|
@@ -14,14 +14,24 @@ import type { OpenAPISchema } from "../openApiTypes/openApiTypes.core.js";
|
|
|
14
14
|
* array `_config.itemSchema`, `_config.min`, `_config.max`, `_config.length`
|
|
15
15
|
* string `_config.min|max|length|pattern|format`
|
|
16
16
|
* number `_config.min|max|int|gt|lt|multipleOf`
|
|
17
|
+
* coerce.string / coerce.number
|
|
18
|
+
* `_constraints` — the wrapped StringSchema / NumberSchema that
|
|
19
|
+
* carries the `_config` above
|
|
17
20
|
* union `_schemas` intersection `_left` / `_right`
|
|
18
21
|
* enum `_values` literal `_expected`
|
|
19
22
|
* optional `_inner` nullable `_inner`
|
|
20
|
-
* default `_inner`, `_defaultValue`
|
|
21
|
-
*
|
|
23
|
+
* default `_inner`, `_defaultValue` (a value or a factory function)
|
|
24
|
+
* refine `_inner`
|
|
25
|
+
* transform `_inner` (`schema.transform(...)`, TransformModifierSchema)
|
|
26
|
+
* or `_base` (the standalone TransformSchema class)
|
|
27
|
+
* lazy `_factory` / `_inner`
|
|
22
28
|
* record `_keySchema`, `_valueSchema` tuple `_schemas`
|
|
23
29
|
* map `_keySchema`, `_valueSchema` set `_valueSchema`
|
|
24
30
|
* metadata `_metadata` (description, example, title, deprecated)
|
|
31
|
+
*
|
|
32
|
+
* Object parsing accepts a missing key when the field schema is one of
|
|
33
|
+
* `optional`, `default`, `any` or `unknown` (`ACCEPTS_UNDEFINED` in
|
|
34
|
+
* schemaObject.core.ts), so exactly those are left out of `required`.
|
|
25
35
|
*/
|
|
26
36
|
export interface SchemaConversionResult {
|
|
27
37
|
readonly schema: OpenAPISchema;
|
|
@@ -33,8 +33,50 @@ function isSchemaLike(value) {
|
|
|
33
33
|
value !== null &&
|
|
34
34
|
typeof value._type === "string");
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Field schemas for which `ObjectSchema` accepts a missing key. Mirrors
|
|
38
|
+
* `ACCEPTS_UNDEFINED` in `@zudojs/schema`: a field with a default is filled
|
|
39
|
+
* in when absent, so documenting it as `required` publishes a contract
|
|
40
|
+
* stricter than the code that validates against it.
|
|
41
|
+
*/
|
|
42
|
+
const ACCEPTS_MISSING_KEY = new Set([
|
|
43
|
+
"optional",
|
|
44
|
+
"default",
|
|
45
|
+
"any",
|
|
46
|
+
"unknown",
|
|
47
|
+
]);
|
|
48
|
+
function acceptsMissingKey(value) {
|
|
49
|
+
return isSchemaLike(value) && ACCEPTS_MISSING_KEY.has(value._type);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The schema a `coerce.*` wrapper delegates its constraints to.
|
|
53
|
+
*
|
|
54
|
+
* `s.coerce.number().int().min(1)` keeps `int` and `min` on the wrapped
|
|
55
|
+
* `NumberSchema` under `_constraints`, not on the wrapper's own `_config`;
|
|
56
|
+
* reading the wrapper alone yields a bare `{ type: "number" }`.
|
|
57
|
+
*/
|
|
58
|
+
function coercionTarget(schema) {
|
|
59
|
+
const constraints = schema["_constraints"];
|
|
60
|
+
return isSchemaLike(constraints) ? constraints : schema;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolves a `default` schema's value, which may be a factory function.
|
|
64
|
+
*
|
|
65
|
+
* A factory (`.default(() => new Date())`) is invoked once for the document.
|
|
66
|
+
* Emitting the function itself produces a `default` that `JSON.stringify`
|
|
67
|
+
* silently drops and the YAML serializer renders as source text.
|
|
68
|
+
*/
|
|
69
|
+
function resolveDefaultValue(schema, state) {
|
|
70
|
+
const raw = schema["_defaultValue"];
|
|
71
|
+
if (typeof raw !== "function")
|
|
72
|
+
return raw;
|
|
73
|
+
try {
|
|
74
|
+
return raw();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
state.warnings.push(`A \`default\` factory threw and its value was omitted: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
38
80
|
}
|
|
39
81
|
function extractMeta(schema) {
|
|
40
82
|
const meta = schema._metadata;
|
|
@@ -225,14 +267,17 @@ function convertSchemaNode(schema, state) {
|
|
|
225
267
|
const c = config(schema);
|
|
226
268
|
switch (schema._type) {
|
|
227
269
|
case "string":
|
|
228
|
-
case "coerce.string":
|
|
229
270
|
return convertString(schema, state);
|
|
271
|
+
case "coerce.string":
|
|
272
|
+
return convertString(coercionTarget(schema), state);
|
|
230
273
|
case "number":
|
|
231
|
-
case "coerce.number":
|
|
232
274
|
return convertNumber(schema, state);
|
|
275
|
+
case "coerce.number":
|
|
276
|
+
return convertNumber(coercionTarget(schema), state);
|
|
233
277
|
case "boolean":
|
|
234
278
|
case "coerce.boolean":
|
|
235
279
|
return { type: "boolean" };
|
|
280
|
+
case "bigint":
|
|
236
281
|
case "coerce.bigint":
|
|
237
282
|
return { type: "string", format: "int64" };
|
|
238
283
|
case "null":
|
|
@@ -256,10 +301,11 @@ function convertSchemaNode(schema, state) {
|
|
|
256
301
|
const required = [];
|
|
257
302
|
for (const [key, value] of Object.entries(shape)) {
|
|
258
303
|
defineProperty(properties, key, convertNode(value, state));
|
|
259
|
-
// A field is required unless
|
|
304
|
+
// A field is required unless the object parser accepts its absence
|
|
305
|
+
// (`optional`, `default`, `any`, `unknown`). An explicit
|
|
260
306
|
// `requiredKeys` set (from `.required()`) forces it back on.
|
|
261
307
|
const forced = requiredKeys instanceof Set && requiredKeys.has(key);
|
|
262
|
-
if (forced || !
|
|
308
|
+
if (forced || !acceptsMissingKey(value))
|
|
263
309
|
required.push(key);
|
|
264
310
|
}
|
|
265
311
|
const unknownKeys = c["unknownKeys"];
|
|
@@ -403,7 +449,7 @@ function convertSchemaNode(schema, state) {
|
|
|
403
449
|
return applyNullable(convertInner(schema["_inner"], state, "nullable"), state.version);
|
|
404
450
|
case "default": {
|
|
405
451
|
const inner = convertInner(schema["_inner"], state, "default");
|
|
406
|
-
const defaultValue = schema
|
|
452
|
+
const defaultValue = resolveDefaultValue(schema, state);
|
|
407
453
|
return defaultValue === undefined
|
|
408
454
|
? inner
|
|
409
455
|
: { ...inner, default: defaultValue };
|
|
@@ -413,7 +459,10 @@ function convertSchemaNode(schema, state) {
|
|
|
413
459
|
// inner shape is still the right description of the data.
|
|
414
460
|
return convertInner(schema["_inner"], state, "refine");
|
|
415
461
|
case "transform":
|
|
416
|
-
|
|
462
|
+
// `schema.transform(fn)` / `s.transform(schema, fn)` build a
|
|
463
|
+
// TransformModifierSchema, whose source is `_inner`; the standalone
|
|
464
|
+
// TransformSchema class names it `_base`.
|
|
465
|
+
return convertInner(schema["_inner"] ?? schema["_base"], state, "transform");
|
|
417
466
|
case "lazy": {
|
|
418
467
|
const resolved = schema["_inner"] ?? resolveLazy(schema, state);
|
|
419
468
|
if (resolved === undefined)
|
|
@@ -31,8 +31,30 @@ const YAML_RESERVED = new Set([
|
|
|
31
31
|
]);
|
|
32
32
|
/** A bare (unquoted) YAML scalar may not start with these. */
|
|
33
33
|
const YAML_UNSAFE_START = /^[-?:,[\]{}#&*!|>'"%@`\s]/;
|
|
34
|
-
|
|
35
|
-
const
|
|
34
|
+
// Control characters have no plain-scalar form; `JSON.stringify` escapes them.
|
|
35
|
+
const YAML_UNSAFE_ANYWHERE = /[:#\u0000-\u001f\u007f]|: |\s#/;
|
|
36
|
+
const YAML_NUMERIC = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/;
|
|
37
|
+
/**
|
|
38
|
+
* Other strings a YAML parser resolves to a non-string: the infinity and
|
|
39
|
+
* not-a-number forms, hexadecimal, octal and binary integers (1.1 and 1.2
|
|
40
|
+
* core schema), digit groups with `_`, sexagesimal numbers, timestamps
|
|
41
|
+
* and dates (1.1), the `=` value key and the `<<` merge key. A `version:
|
|
42
|
+
* 2024-01-01` or an enum value of `0x1F` came back from the parser as a
|
|
43
|
+
* date or the number 31.
|
|
44
|
+
*/
|
|
45
|
+
const YAML_SPECIAL_SCALARS = [
|
|
46
|
+
/^[-+]?\.(?:inf|nan)$/i,
|
|
47
|
+
/^[-+]?0x[0-9a-f_]+$/i,
|
|
48
|
+
/^[-+]?0o?[0-7_]+$/i,
|
|
49
|
+
/^[-+]?0b[01_]+$/i,
|
|
50
|
+
/^[-+]?[0-9][0-9_]*(?:\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?$/,
|
|
51
|
+
/^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+(?:\.[0-9_]*)?$/,
|
|
52
|
+
/^\d{4}-\d{1,2}-\d{1,2}(?:[Tt ]|$)/,
|
|
53
|
+
/^(?:=|<<)$/,
|
|
54
|
+
];
|
|
55
|
+
function isSpecialScalar(value) {
|
|
56
|
+
return YAML_SPECIAL_SCALARS.some((pattern) => pattern.test(value));
|
|
57
|
+
}
|
|
36
58
|
function quoteYamlString(value) {
|
|
37
59
|
// Double quotes with JSON escaping is always valid YAML and needs no
|
|
38
60
|
// decision about block scalars or line folding.
|
|
@@ -43,6 +65,7 @@ function yamlScalar(value) {
|
|
|
43
65
|
YAML_UNSAFE_START.test(value) ||
|
|
44
66
|
YAML_UNSAFE_ANYWHERE.test(value) ||
|
|
45
67
|
YAML_NUMERIC.test(value) ||
|
|
68
|
+
isSpecialScalar(value) ||
|
|
46
69
|
value !== value.trim()) {
|
|
47
70
|
return quoteYamlString(value);
|
|
48
71
|
}
|
|
@@ -38,11 +38,25 @@ function jsLiteral(value) {
|
|
|
38
38
|
.replace(/\u2028/g, "\\u2028")
|
|
39
39
|
.replace(/\u2029/g, "\\u2029");
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
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
|
+
*/
|
|
42
51
|
function safeUrl(value) {
|
|
43
52
|
const trimmed = value.trim();
|
|
44
|
-
|
|
45
|
-
|
|
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.');
|
|
46
60
|
}
|
|
47
61
|
// Attributes are always double-quoted here, so a single quote (common in
|
|
48
62
|
// data URIs) can stay as-is.
|
|
@@ -118,14 +132,14 @@ export function renderOpenAPIUI(options) {
|
|
|
118
132
|
const title = options.title ?? DEFAULT_TITLE;
|
|
119
133
|
const renderer = options.renderer ?? "swagger";
|
|
120
134
|
if (renderer === "redoc") {
|
|
121
|
-
const base = (options.assetsBaseUrl ?? REDOC_ASSETS).replace(
|
|
135
|
+
const base = (options.assetsBaseUrl ?? REDOC_ASSETS).replace(/(?<!\/)\/+$/, "");
|
|
122
136
|
return (head(options, title, "") +
|
|
123
137
|
`<body>${header(options, title)}` +
|
|
124
138
|
`<redoc spec-url="${safeUrl(options.specUrl)}" hide-hostname></redoc>` +
|
|
125
139
|
`<script src="${safeUrl(base)}/redoc.standalone.js"></script>` +
|
|
126
140
|
`${footer()}</body></html>`);
|
|
127
141
|
}
|
|
128
|
-
const base = (options.assetsBaseUrl ?? SWAGGER_ASSETS).replace(
|
|
142
|
+
const base = (options.assetsBaseUrl ?? SWAGGER_ASSETS).replace(/(?<!\/)\/+$/, "");
|
|
129
143
|
const config = {
|
|
130
144
|
url: options.specUrl,
|
|
131
145
|
dom_id: "#zudo-openapi",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
import { MAX_OPERATION_ID_LENGTH, PATH_TEMPLATE_PARAMETER, RESPONSE_KEY_PATTERN, SUPPORTED_OPENAPI_VERSIONS, } from "../openApiConstants/openApiConstants.core.js";
|
|
3
3
|
import { extractPathParameters } from "../openApiRouting/routeConverter.core.js";
|
|
4
4
|
import { unescapeJsonPointerSegment } from "../openApiSchema/references.core.js";
|
|
5
5
|
const OPERATIONS = [
|
|
@@ -77,9 +77,22 @@ export class OpenAPIValidatorImpl {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
validatePaths(document, collector) {
|
|
80
|
+
/** Paths seen so far, keyed by their template with parameter names erased. */
|
|
81
|
+
const shapes = new Map();
|
|
80
82
|
for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
|
|
81
83
|
if (!pathItem)
|
|
82
84
|
continue;
|
|
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
|
+
}
|
|
83
96
|
if (!path.startsWith("/")) {
|
|
84
97
|
error(collector, ["paths", path], `Path "${path}" must start with "/".`);
|
|
85
98
|
}
|
|
@@ -121,28 +134,33 @@ export class OpenAPIValidatorImpl {
|
|
|
121
134
|
operation.operationId.length > MAX_OPERATION_ID_LENGTH) {
|
|
122
135
|
error(collector, [...base, "operationId"], `operationId "${operation.operationId}" exceeds the maximum length of ${MAX_OPERATION_ID_LENGTH}.`);
|
|
123
136
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.`);
|
|
158
|
+
}
|
|
141
159
|
}
|
|
142
160
|
}
|
|
143
161
|
// The classic OpenAPI mistake: a templated path with no matching
|
|
144
162
|
// parameter, or a path parameter that no template slot refers to.
|
|
145
|
-
const declaredPathParameters = new Set(parameters
|
|
163
|
+
const declaredPathParameters = new Set([...parameters.values()]
|
|
146
164
|
.filter((parameter) => parameter.in === "path")
|
|
147
165
|
.map((parameter) => parameter.name));
|
|
148
166
|
for (const name of templateParameters) {
|
|
@@ -309,7 +327,10 @@ function resolvePointer(document, ref) {
|
|
|
309
327
|
current = current[index];
|
|
310
328
|
continue;
|
|
311
329
|
}
|
|
312
|
-
|
|
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))
|
|
313
334
|
return false;
|
|
314
335
|
current = current[segment];
|
|
315
336
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/openapi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "OpenAPI 3.0 and 3.1 specification generation, validation, and serialization for Zudojs applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oluwayemi Oyinlola",
|
|
8
|
+
"url": "https://github.com/oyinlola-tech"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.js",
|
|
8
12
|
"module": "./dist/index.js",
|
|
@@ -22,7 +26,7 @@
|
|
|
22
26
|
"!dist/.tsbuildinfo"
|
|
23
27
|
],
|
|
24
28
|
"dependencies": {
|
|
25
|
-
"@zudojs/errors": "1.0.
|
|
29
|
+
"@zudojs/errors": "1.0.1"
|
|
26
30
|
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"@types/node": "^26.4.1",
|