@scalar/json-schema-validator 0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @scalar/json-schema-validator
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#9967](https://github.com/scalar/scalar/pull/9967): Add `@scalar/json-schema-validator`, a standalone engine that validates documents against a JSON Schema with Ajv and returns human-friendly errors. It is the shared core that `@scalar/openapi-validator` now builds on (and that a future AsyncAPI validator can reuse).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-present Scalar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Scalar JSON Schema Validator
2
+
3
+ [![Version](https://img.shields.io/npm/v/%40scalar/json-schema-validator)](https://www.npmjs.com/package/@scalar/json-schema-validator)
4
+ [![Downloads](https://img.shields.io/npm/dm/%40scalar/json-schema-validator)](https://www.npmjs.com/package/@scalar/json-schema-validator)
5
+ [![License](https://img.shields.io/npm/l/%40scalar%2Fjson-schema-validator)](https://www.npmjs.com/package/@scalar/json-schema-validator)
6
+ [![Discord](https://img.shields.io/discord/1135330207960678410?style=flat&color=5865F2)](https://discord.gg/scalar)
7
+
8
+ Validate documents against a JSON Schema with [Ajv](https://ajv.js.org/) and get short, human-friendly error messages.
9
+
10
+ This is the shared validation engine behind [`@scalar/openapi-validator`](https://www.npmjs.com/package/@scalar/openapi-validator). It knows nothing about OpenAPI or AsyncAPI, so you can use it with any JSON Schema.
11
+
12
+ ---
13
+
14
+ Scalar is an open-source API platform for teams who want beautiful developer interfaces without vendor lock-in.
15
+
16
+ - **[API References](https://scalar.com/products/api-references/getting-started)** — Interactive API documentation from OpenAPI and AsyncAPI specs.
17
+ - **[Developer Docs](https://scalar.com/products/docs/getting-started)** — Write in Markdown/MDX, generate API references, sync with two-way Git.
18
+ - **[SDK Generator](https://scalar.com/products/sdk-generator/getting-started)** — Type-safe SDKs and CLIs in TypeScript, Python, Go, PHP, Java, and Ruby.
19
+ - **[API Client](https://scalar.com/products/api-client/getting-started)** — Open-source, offline-first Postman alternative built on OpenAPI.
20
+
21
+ 20M+ monthly npm installs · 15,500+ GitHub stars · MIT licensed · [scalar.com](https://scalar.com)
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm add @scalar/json-schema-validator
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Pass a document (an object, or a JSON/YAML string) and a JSON Schema:
34
+
35
+ ```ts
36
+ import { validate } from '@scalar/json-schema-validator'
37
+
38
+ const schema = {
39
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
40
+ type: 'object',
41
+ required: ['name'],
42
+ properties: { name: { type: 'string' } },
43
+ }
44
+
45
+ const result = validate({ name: 'Hello' }, schema)
46
+
47
+ console.log(result.valid)
48
+
49
+ if (!result.valid) {
50
+ console.log(result.errors)
51
+ }
52
+ ```
53
+
54
+ The dialect is picked automatically from the schema's `$schema` (JSON Schema draft-04, draft-07, and 2020-12 are supported).
55
+
56
+ ### Reuse a schema
57
+
58
+ When validating many documents against the same schema, compile it once:
59
+
60
+ ```ts
61
+ import { createValidator } from '@scalar/json-schema-validator'
62
+
63
+ const validateUser = createValidator(schema)
64
+
65
+ validateUser({ name: 'Ada' })
66
+ validateUser({ name: 'Grace' })
67
+ ```
68
+
69
+ ### Extra formats
70
+
71
+ Register custom Ajv formats via `formats`:
72
+
73
+ ```ts
74
+ validate(document, schema, {
75
+ formats: {
76
+ 'media-range': true,
77
+ },
78
+ })
79
+ ```
80
+
81
+ Formats are applied when a schema is compiled, and `validate` caches the compiled
82
+ schema by identity. So `formats` only takes effect the first time it sees a given
83
+ schema object — later calls reuse the validator built from that first set. When
84
+ different documents need different formats for the same schema, build a validator
85
+ per format set with `createValidator` instead.
86
+
87
+ ### Throw on error
88
+
89
+ ```ts
90
+ try {
91
+ validate(document, schema, { throwOnError: true })
92
+ } catch (error) {
93
+ // Handle the first validation error
94
+ }
95
+ ```
96
+
97
+ ## Community
98
+
99
+ We are API nerds. You too? Let's chat on Discord: <https://discord.gg/scalar>
100
+
101
+ ## License
102
+
103
+ The source code in this repository is licensed under [MIT](https://github.com/scalar/scalar/blob/main/LICENSE).
@@ -0,0 +1,55 @@
1
+ import type { AnyObject } from '@scalar/types/utils';
2
+ import type { ErrorObject, ValidationOutcome } from './types.js';
3
+ type SchemaObject = Record<string, unknown>;
4
+ /** Options every specification validator accepts. */
5
+ export type SpecificationValidatorOptions = {
6
+ /**
7
+ * If `true`, throw on the first error instead of returning it.
8
+ *
9
+ * @default false
10
+ */
11
+ throwOnError?: boolean;
12
+ };
13
+ /**
14
+ * Everything a single specification (OpenAPI, AsyncAPI, …) contributes on top of
15
+ * the shared JSON Schema engine.
16
+ */
17
+ export type SpecificationValidatorConfig<TVersion extends string, TOptions extends SpecificationValidatorOptions> = {
18
+ /** The JSON Schema for each supported version. */
19
+ schemas: Record<TVersion, SchemaObject>;
20
+ /** Detects the specification version from a parsed document. */
21
+ detectVersion: (document: AnyObject) => TVersion | undefined;
22
+ /** Extra Ajv formats to register for a version, compiled into its validator. */
23
+ formats?: (version: TVersion) => Record<string, unknown> | undefined;
24
+ /** Messages for the two failures that happen before schema validation. */
25
+ errors: {
26
+ /** The input is missing or is not a document. */
27
+ emptyOrInvalid: string;
28
+ /** The document's version is not supported. */
29
+ versionNotSupported: string;
30
+ };
31
+ /**
32
+ * Adjusts the document before schema validation without touching the caller's
33
+ * copy (AsyncAPI, for example, pins `asyncapi` to an exact version). The
34
+ * original document is still what is returned as `schema` and handed to
35
+ * `postValidate`. Defaults to validating the document as-is.
36
+ */
37
+ prepareDocument?: (specification: AnyObject, version: TVersion) => AnyObject;
38
+ /**
39
+ * Extra semantic checks run after a successful schema validation (for example
40
+ * OpenAPI path-template rules the JSON Schema cannot express). Any returned
41
+ * errors mark the document invalid.
42
+ */
43
+ postValidate?: (specification: AnyObject, version: TVersion, options: TOptions | undefined) => ErrorObject[];
44
+ };
45
+ /**
46
+ * Builds a `validate` function for a single specification on top of the shared
47
+ * JSON Schema engine.
48
+ *
49
+ * The returned function parses string input, rejects non-documents, detects the
50
+ * version, validates against the matching schema (compiled once per version and
51
+ * reused), and runs any specification-specific `postValidate` checks.
52
+ */
53
+ export declare function createSpecificationValidator<TVersion extends string, TOptions extends SpecificationValidatorOptions = SpecificationValidatorOptions>(config: SpecificationValidatorConfig<TVersion, TOptions>): (document: string | AnyObject, options?: TOptions) => ValidationOutcome<TVersion>;
54
+ export {};
55
+ //# sourceMappingURL=create-specification-validator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-specification-validator.d.ts","sourceRoot":"","sources":["../src/create-specification-validator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAGpD,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAG7D,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAE3C,qDAAqD;AACrD,MAAM,MAAM,6BAA6B,GAAG;IAC1C;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,4BAA4B,CAAC,QAAQ,SAAS,MAAM,EAAE,QAAQ,SAAS,6BAA6B,IAAI;IAClH,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAA;IACvC,gEAAgE;IAChE,aAAa,EAAE,CAAC,QAAQ,EAAE,SAAS,KAAK,QAAQ,GAAG,SAAS,CAAA;IAC5D,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;IACpE,0EAA0E;IAC1E,MAAM,EAAE;QACN,iDAAiD;QACjD,cAAc,EAAE,MAAM,CAAA;QACtB,+CAA+C;QAC/C,mBAAmB,EAAE,MAAM,CAAA;KAC5B,CAAA;IACD;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,aAAa,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,KAAK,SAAS,CAAA;IAC5E;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,aAAa,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,GAAG,SAAS,KAAK,WAAW,EAAE,CAAA;CAC7G,CAAA;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,SAAS,MAAM,EACvB,QAAQ,SAAS,6BAA6B,GAAG,6BAA6B,EAC9E,MAAM,EAAE,4BAA4B,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAehD,UAAU,MAAM,GAAG,SAAS,EAAE,UAAU,QAAQ,KAAG,iBAAiB,CAAC,QAAQ,CAAC,CAmEvF"}
@@ -0,0 +1,79 @@
1
+ import { isObject } from '@scalar/helpers/object/is-object';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { createValidator } from './validate.js';
4
+ /**
5
+ * Builds a `validate` function for a single specification on top of the shared
6
+ * JSON Schema engine.
7
+ *
8
+ * The returned function parses string input, rejects non-documents, detects the
9
+ * version, validates against the matching schema (compiled once per version and
10
+ * reused), and runs any specification-specific `postValidate` checks.
11
+ */
12
+ export function createSpecificationValidator(config) {
13
+ // Core validators, compiled once per version and reused across calls.
14
+ const validatorsByVersion = new Map();
15
+ const getValidator = (version) => {
16
+ let validator = validatorsByVersion.get(version);
17
+ if (!validator) {
18
+ validator = createValidator(config.schemas[version], { formats: config.formats?.(version) });
19
+ validatorsByVersion.set(version, validator);
20
+ }
21
+ return validator;
22
+ };
23
+ return (document, options) => {
24
+ let specification;
25
+ if (typeof document === 'string') {
26
+ // A malformed JSON/YAML string is a validation failure like any other, so
27
+ // it respects `throwOnError` rather than escaping as a parser exception.
28
+ try {
29
+ specification = parseYaml(document);
30
+ }
31
+ catch (error) {
32
+ if (options?.throwOnError) {
33
+ throw error;
34
+ }
35
+ return { valid: false, errors: [{ message: error instanceof Error ? error.message : String(error) }] };
36
+ }
37
+ }
38
+ else {
39
+ specification = document;
40
+ }
41
+ try {
42
+ // The document is empty or invalid. A YAML/JSON string can parse to a
43
+ // primitive or an array, neither of which is a document, so guard against
44
+ // anything that is not a plain object rather than only null/undefined.
45
+ if (!isObject(specification)) {
46
+ if (options?.throwOnError) {
47
+ throw new Error(config.errors.emptyOrInvalid);
48
+ }
49
+ return { valid: false, errors: [{ message: config.errors.emptyOrInvalid }] };
50
+ }
51
+ const version = config.detectVersion(specification);
52
+ if (!version) {
53
+ if (options?.throwOnError) {
54
+ throw new Error(config.errors.versionNotSupported);
55
+ }
56
+ return { valid: false, errors: [{ message: config.errors.versionNotSupported }] };
57
+ }
58
+ const documentToValidate = config.prepareDocument?.(specification, version) ?? specification;
59
+ const result = getValidator(version)(documentToValidate, options);
60
+ if (!result.valid) {
61
+ return { valid: false, version, errors: result.errors };
62
+ }
63
+ const semanticErrors = config.postValidate?.(specification, version, options) ?? [];
64
+ if (semanticErrors.length > 0) {
65
+ if (options?.throwOnError) {
66
+ throw new Error(semanticErrors[0]?.message ?? 'Validation failed');
67
+ }
68
+ return { valid: false, version, errors: semanticErrors, schema: specification };
69
+ }
70
+ return { valid: true, version, errors: [], schema: specification };
71
+ }
72
+ catch (error) {
73
+ if (options?.throwOnError) {
74
+ throw error;
75
+ }
76
+ return { valid: false, errors: [{ message: error instanceof Error ? error.message : String(error) }] };
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,9 @@
1
+ import type { ErrorObject } from './types.js';
2
+ /**
3
+ * Removes errors that share the same message and path.
4
+ *
5
+ * The `path` may be a JSON Pointer string (schema errors) or a list of segments
6
+ * (semantic errors), so both shapes are normalized to the same key.
7
+ */
8
+ export declare function deduplicateErrors(errors: ErrorObject[]): ErrorObject[];
9
+ //# sourceMappingURL=deduplicate-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deduplicate-errors.d.ts","sourceRoot":"","sources":["../src/deduplicate-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAE1C;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,WAAW,EAAE,CAetE"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Removes errors that share the same message and path.
3
+ *
4
+ * The `path` may be a JSON Pointer string (schema errors) or a list of segments
5
+ * (semantic errors), so both shapes are normalized to the same key.
6
+ */
7
+ export function deduplicateErrors(errors) {
8
+ const seen = new Set();
9
+ return errors.filter((error) => {
10
+ const pathKey = Array.isArray(error.path) ? error.path.join('.') : (error.path ?? '');
11
+ const key = `${error.message}||${pathKey}`;
12
+ if (seen.has(key)) {
13
+ return false;
14
+ }
15
+ seen.add(key);
16
+ return true;
17
+ });
18
+ }
@@ -0,0 +1,6 @@
1
+ export { type SpecificationValidatorConfig, type SpecificationValidatorOptions, createSpecificationValidator, } from './create-specification-validator.js';
2
+ export { deduplicateErrors } from './deduplicate-errors.js';
3
+ export { type AjvError, type PrettyError, prettifyAjvErrors } from './prettify-ajv-errors.js';
4
+ export type { CreateValidatorOptions, ErrorObject, ValidateOptions, ValidationOutcome, ValidationResult, } from './types.js';
5
+ export { createValidator, validate } from './validate.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,4BAA4B,EACjC,KAAK,6BAA6B,EAClC,4BAA4B,GAC7B,MAAM,kCAAkC,CAAA;AACzC,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAC1F,YAAY,EACV,sBAAsB,EACtB,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,SAAS,CAAA;AAChB,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createSpecificationValidator, } from './create-specification-validator.js';
2
+ export { deduplicateErrors } from './deduplicate-errors.js';
3
+ export { prettifyAjvErrors } from './prettify-ajv-errors.js';
4
+ export { createValidator, validate } from './validate.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Turns raw Ajv validation errors into a short list of human-friendly messages.
3
+ *
4
+ * Ajv reports every failing branch of a schema, which for OpenAPI documents
5
+ * (full of `oneOf`/`anyOf`) means a lot of noise. We group the errors into a
6
+ * tree by JSON Pointer, prune the redundant ones so the most actionable message
7
+ * wins, and then word each remaining error per keyword.
8
+ */
9
+ /**
10
+ * A single raw Ajv validation error.
11
+ *
12
+ * This is a forgiving, partial shape: Ajv attaches different fields depending on
13
+ * the keyword, and we only read a handful of them.
14
+ */
15
+ export type AjvError = {
16
+ keyword: string;
17
+ instancePath?: string;
18
+ /** Ajv < 8 used `dataPath` instead of `instancePath`. */
19
+ dataPath?: string;
20
+ schemaPath?: string;
21
+ message?: string;
22
+ propertyName?: string;
23
+ params?: Record<string, any>;
24
+ };
25
+ /** A prettified, human-friendly validation error. */
26
+ export type PrettyError = {
27
+ message: string;
28
+ path?: string;
29
+ };
30
+ /**
31
+ * Prettifies a list of raw Ajv validation errors against the validated document.
32
+ */
33
+ export declare function prettifyAjvErrors(document: unknown, errors: AjvError[]): PrettyError[];
34
+ //# sourceMappingURL=prettify-ajv-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prettify-ajv-errors.d.ts","sourceRoot":"","sources":["../src/prettify-ajv-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC7B,CAAA;AAED,qDAAqD;AACrD,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAyXD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,WAAW,EAAE,CAKtF"}
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Turns raw Ajv validation errors into a short list of human-friendly messages.
3
+ *
4
+ * Ajv reports every failing branch of a schema, which for OpenAPI documents
5
+ * (full of `oneOf`/`anyOf`) means a lot of noise. We group the errors into a
6
+ * tree by JSON Pointer, prune the redundant ones so the most actionable message
7
+ * wins, and then word each remaining error per keyword.
8
+ */
9
+ // Splits an instance path into its `/segment` chunks, keeping a trailing array
10
+ // index attached to its property (`/parameters/0`). The segment class is
11
+ // `[^/]+` (anything up to the next slash) rather than word characters only, so
12
+ // JSON Pointer-escaped keys (`/paths/~1pets`) and keys with dots (`/pet.store`)
13
+ // keep their own node instead of being dropped — dropping them collapsed
14
+ // distinct paths onto the same node and let one error prune away another.
15
+ const JSON_POINTERS_REGEX = /\/[^/]+(\/\d+)?/g;
16
+ const isKeyword = (keyword) => (error) => error.keyword === keyword;
17
+ const isRequiredError = isKeyword('required');
18
+ const isAnyOfError = isKeyword('anyOf');
19
+ const isOneOfError = isKeyword('oneOf');
20
+ const isIfError = isKeyword('if');
21
+ const isEnumError = isKeyword('enum');
22
+ const isAdditionalPropertiesError = isKeyword('additionalProperties');
23
+ const isUnevaluatedPropertiesError = isKeyword('unevaluatedProperties');
24
+ const getChildren = (node) => Object.values(node.children);
25
+ /** Whether the node currently has any child nodes. */
26
+ const hasChildren = (node) => Object.keys(node.children).length > 0;
27
+ /**
28
+ * Whether this node, or anything below it, carries a non-`enum` error.
29
+ *
30
+ * Pruning decisions look at whole subtrees: a sibling that only holds errors on
31
+ * its own children still counts. Only non-`enum` errors count as "more
32
+ * specific", so a bare `enum` error is not dropped merely because a sibling also
33
+ * failed its own `enum` — both of those are equally actionable and must survive.
34
+ */
35
+ const hasMoreSpecificErrorDeep = (node) => node.errors.some((error) => !isEnumError(error)) || getChildren(node).some(hasMoreSpecificErrorDeep);
36
+ /**
37
+ * The JSON Pointer to the value that failed validation. Ajv exposes this as
38
+ * `instancePath`; older versions used `dataPath`.
39
+ */
40
+ const getInstancePath = (error) => error.instancePath !== undefined ? error.instancePath : (error.dataPath ?? '');
41
+ /**
42
+ * The name of the property an error points at.
43
+ *
44
+ * Ajv only sets `propertyName` for the `propertyNames` keyword, so for every
45
+ * other keyword the name has to come from the last segment of the instance
46
+ * path. Returns `undefined` when the error points at the document root or at an
47
+ * array item, neither of which has a property name to report.
48
+ *
49
+ * A numeric segment is only an array index when the value holding it really is
50
+ * an array — OpenAPI is full of numeric object keys, `responses.404` among them
51
+ * — so the document decides, not the shape of the segment.
52
+ */
53
+ const getPropertyName = (error, document) => {
54
+ if (error.propertyName) {
55
+ return error.propertyName;
56
+ }
57
+ const segments = pointerSegments(getInstancePath(error));
58
+ const last = segments.at(-1);
59
+ if (last === undefined) {
60
+ return undefined;
61
+ }
62
+ if (/^\d+$/.test(last) && Array.isArray(resolvePointer(document, segments.slice(0, -1)))) {
63
+ return undefined;
64
+ }
65
+ return last;
66
+ };
67
+ /**
68
+ * Words a single Ajv error, dispatched by keyword.
69
+ */
70
+ function formatError(error, document) {
71
+ const path = getInstancePath(error);
72
+ switch (error.keyword) {
73
+ case 'additionalProperties':
74
+ return { message: `Property ${error.params?.additionalProperty} is not expected to be here`, path };
75
+ case 'unevaluatedProperties':
76
+ return { message: `Property ${error.params?.unevaluatedProperty} is not expected to be here`, path };
77
+ case 'pattern': {
78
+ const propertyName = getPropertyName(error, document);
79
+ return {
80
+ message: propertyName
81
+ ? `Property "${propertyName}" must match pattern ${error.params?.pattern}`
82
+ : `${error.keyword} ${error.message}`,
83
+ path,
84
+ };
85
+ }
86
+ case 'required':
87
+ return { message: `${error.message}`, path };
88
+ case 'format':
89
+ return formatFormatError(error, document);
90
+ default:
91
+ return { message: `${error.keyword} ${error.message}`, path };
92
+ }
93
+ }
94
+ /**
95
+ * Merges the allowed values of one or more `enum` errors into a single message.
96
+ */
97
+ function formatEnumError(allowedValues, error) {
98
+ return { message: `${error.message}: ${allowedValues.join(', ')}`, path: getInstancePath(error) };
99
+ }
100
+ /**
101
+ * Adds context for `format` failures. Currently only `uri-reference` on `$ref`
102
+ * values, where the raw value is worth surfacing (for example non-ASCII
103
+ * characters). Everything else falls back to the default message.
104
+ */
105
+ function formatFormatError(error, document) {
106
+ const path = getInstancePath(error);
107
+ if (error.params?.format === 'uri-reference' && path.endsWith('/$ref')) {
108
+ return { message: uriReferenceMessage(document, path), path };
109
+ }
110
+ return { message: `${error.keyword} ${error.message}`, path };
111
+ }
112
+ function uriReferenceMessage(document, path) {
113
+ const refValue = extractRefValue(document, path);
114
+ if (refValue && /[^\x00-\x7F]/.test(refValue)) {
115
+ return `$ref "${refValue}" contains non-ASCII characters`;
116
+ }
117
+ if (refValue) {
118
+ return `$ref "${refValue}" is not a valid URI reference`;
119
+ }
120
+ return '$ref is not a valid URI reference';
121
+ }
122
+ /** Decodes the `~1` and `~0` escapes of a single JSON Pointer segment. */
123
+ const unescapePointerSegment = (segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~');
124
+ /** Splits a JSON Pointer into its decoded segments. */
125
+ const pointerSegments = (path) => path.split('/').filter(Boolean).map(unescapePointerSegment);
126
+ /**
127
+ * Walks the document along a list of JSON Pointer segments.
128
+ *
129
+ * Returns `undefined` as soon as the path leaves the document.
130
+ */
131
+ function resolvePointer(document, segments) {
132
+ let current = document;
133
+ for (const segment of segments) {
134
+ if (current === null || typeof current !== 'object') {
135
+ return undefined;
136
+ }
137
+ current = current[segment];
138
+ }
139
+ return current;
140
+ }
141
+ /**
142
+ * Walks the document along a JSON Pointer to read the actual `$ref` value.
143
+ */
144
+ function extractRefValue(document, path) {
145
+ if (!document || typeof document !== 'object' || !path) {
146
+ return null;
147
+ }
148
+ const value = resolvePointer(document, pointerSegments(path));
149
+ return typeof value === 'string' ? value : null;
150
+ }
151
+ /**
152
+ * Groups a flat list of Ajv errors into a tree keyed by JSON Pointer segments.
153
+ */
154
+ function makeTree(ajvErrors) {
155
+ const root = { errors: [], children: {} };
156
+ for (const ajvError of ajvErrors) {
157
+ const instancePath = getInstancePath(ajvError);
158
+ // A pointer the pattern somehow cannot split still has to be reported, so
159
+ // give it a node of its own keyed by the whole path. Grouping it under the
160
+ // root instead would expose it to the root's own pruning rules, which drop
161
+ // everything next to a `required` error.
162
+ const paths = instancePath === '' ? [''] : (instancePath.match(JSON_POINTERS_REGEX) ?? [instancePath]);
163
+ paths.reduce((node, path, index) => {
164
+ node.children[path] = node.children[path] ?? { errors: [], children: {} };
165
+ if (index === paths.length - 1) {
166
+ node.children[path].errors.push(ajvError);
167
+ }
168
+ return node.children[path];
169
+ }, root);
170
+ }
171
+ return root;
172
+ }
173
+ /**
174
+ * Resolves the `oneOf`/`required` tangle at a single node.
175
+ *
176
+ * OpenAPI's `oneOf: [Schema, Reference]` unions make `oneOf` and `required` fire
177
+ * together, and the right message depends on what else failed. `errors` is the
178
+ * node's list as first seen; every branch resets from it, so the decision never
179
+ * depends on a prior rule's output.
180
+ */
181
+ function resolveCompositionAndRequired(node, errors, flags) {
182
+ if (flags.hasOneOf && flags.hasRequired) {
183
+ if (flags.hasExtraProperties) {
184
+ // A concrete `additionalProperties`/`unevaluatedProperties` error is the
185
+ // actionable one; drop the `required`/`oneOf` branch noise around it.
186
+ node.errors = errors.filter((error) => !isRequiredError(error) && !isOneOfError(error));
187
+ }
188
+ else if (flags.hasChildren) {
189
+ // The children carry the specific reason, so drop this node's errors.
190
+ node.errors = [];
191
+ }
192
+ else {
193
+ // Both `oneOf` branches produced a `required` error: one from the schema the
194
+ // user most likely intended (e.g. a Response needs `description`) and one
195
+ // from the `Reference` branch (needs `$ref`). Surface the intended error and
196
+ // drop the `$ref` noise, falling back to the generic `oneOf` error only when
197
+ // the sole requirement left is `$ref` (i.e. the value looks like a broken ref).
198
+ const meaningfulRequiredErrors = errors.filter((error) => isRequiredError(error) && error.params?.missingProperty !== '$ref');
199
+ node.errors = meaningfulRequiredErrors.length > 0 ? meaningfulRequiredErrors : errors.filter(isOneOfError);
200
+ }
201
+ }
202
+ else if (flags.hasOneOf && flags.hasChildren) {
203
+ // Only a `oneOf` error with children: let the more specific children surface.
204
+ node.errors = [];
205
+ }
206
+ else if (flags.hasOneOf) {
207
+ // Multiple duplicate `oneOf` errors from different branches: keep just one.
208
+ const oneOfErrors = errors.filter(isOneOfError);
209
+ if (oneOfErrors.length > 1) {
210
+ node.errors = [oneOfErrors[0]];
211
+ }
212
+ }
213
+ else if (flags.hasRequired) {
214
+ // A missing property makes the rest of that object's errors moot, so a
215
+ // `required` error wins outright — over `anyOf`, and over the children.
216
+ node.errors = errors.filter(isRequiredError);
217
+ node.children = {};
218
+ }
219
+ }
220
+ /**
221
+ * Drops a node whose errors are all `enum` errors when a sibling carries a more
222
+ * specific (non-`enum`) error. Two properties each failing their own `enum` are
223
+ * equally actionable, so neither silences the other. The root node's key is the
224
+ * empty string, so compare `key` against `undefined` rather than truthiness.
225
+ */
226
+ function pruneEnumNextToSpecificSibling(node, parent, key) {
227
+ if (!(node.errors.length > 0 && node.errors.every(isEnumError) && parent && key !== undefined)) {
228
+ return;
229
+ }
230
+ // The more specific error is often on a grandchild rather than the sibling
231
+ // itself, so weigh whole subtrees.
232
+ const siblingsHaveMoreSpecificErrors = getChildren(parent)
233
+ .filter((sibling) => sibling !== node)
234
+ .some(hasMoreSpecificErrorDeep);
235
+ if (siblingsHaveMoreSpecificErrors) {
236
+ delete parent.children[key];
237
+ }
238
+ }
239
+ /**
240
+ * Prunes redundant errors from the tree so the most actionable message wins.
241
+ *
242
+ * Ajv reports every failing branch, which for OpenAPI (full of `oneOf`/`anyOf`/
243
+ * `if`) is mostly noise. The rules rank errors by how specific they are:
244
+ *
245
+ * - "container" errors — `oneOf`, `anyOf`, `if` — only report that a branch
246
+ * failed, never why, so they yield to any more specific error that survives.
247
+ * - `required` is specific and terminal: a missing property makes the rest of
248
+ * that object's errors moot (except the `oneOf: [Schema, Reference]` pattern,
249
+ * resolved first).
250
+ * - `enum` is specific but weak: a more specific sibling error wins.
251
+ * - everything else (`type`, `pattern`, `format`, …) is specific and kept.
252
+ *
253
+ * The steps below run in order and mutate the node; the ordering is load-bearing
254
+ * and called out where it matters.
255
+ */
256
+ function filterRedundantErrors(node, parent, key) {
257
+ // Snapshot the errors and the flags derived from them. Later steps reset from
258
+ // this snapshot, so an earlier filter never hides a keyword a later step weighs.
259
+ const errors = node.errors;
260
+ const flags = {
261
+ hasOneOf: errors.some(isOneOfError),
262
+ hasAnyOf: errors.some(isAnyOfError),
263
+ hasRequired: errors.some(isRequiredError),
264
+ hasIf: errors.some(isIfError),
265
+ hasExtraProperties: errors.some(isAdditionalPropertiesError) || errors.some(isUnevaluatedPropertiesError),
266
+ hasChildren: hasChildren(node),
267
+ };
268
+ // 1. An `if` error next to an `additionalProperties`/`unevaluatedProperties`
269
+ // error is just noise from the if/then/else conditional.
270
+ if (flags.hasIf && flags.hasExtraProperties) {
271
+ node.errors = errors.filter((error) => !isIfError(error));
272
+ }
273
+ // 2. Resolve the `oneOf`/`required` composition tangle.
274
+ resolveCompositionAndRequired(node, errors, flags);
275
+ // 3. A container error whose real cause sits in a surviving child is noise.
276
+ // Re-check children live: step 2's `required` branch may have cleared them,
277
+ // and wiping the errors then would drop the actionable `required` message.
278
+ if (flags.hasAnyOf && hasChildren(node)) {
279
+ node.errors = [];
280
+ }
281
+ if (flags.hasIf && hasChildren(node)) {
282
+ node.errors = node.errors.filter((error) => !isIfError(error));
283
+ }
284
+ // 4. An all-`enum` node yields to a more specific sibling.
285
+ pruneEnumNextToSpecificSibling(node, parent, key);
286
+ for (const [childKey, child] of Object.entries(node.children)) {
287
+ filterRedundantErrors(child, node, childKey);
288
+ }
289
+ }
290
+ /**
291
+ * Turns the filtered tree into a flat list of prettified errors.
292
+ */
293
+ function createErrors(node, document) {
294
+ const errors = node.errors;
295
+ // When every error at this node is an `enum` error, merge their allowed values
296
+ // into a single message instead of repeating the same error.
297
+ if (errors.length > 0 && errors.every(isEnumError)) {
298
+ const allowedValues = [...new Set(errors.flatMap((error) => error.params?.allowedValues ?? []))];
299
+ return [formatEnumError(allowedValues, errors[0])];
300
+ }
301
+ const own = errors.map((error) => formatError(error, document));
302
+ const children = getChildren(node).flatMap((child) => createErrors(child, document));
303
+ return [...own, ...children];
304
+ }
305
+ /**
306
+ * Prettifies a list of raw Ajv validation errors against the validated document.
307
+ */
308
+ export function prettifyAjvErrors(document, errors) {
309
+ const tree = makeTree(errors ?? []);
310
+ filterRedundantErrors(tree);
311
+ return createErrors(tree, document);
312
+ }
@@ -0,0 +1,11 @@
1
+ import type { AnyObject } from '@scalar/types/utils';
2
+ import { type AjvError } from './prettify-ajv-errors.js';
3
+ import type { ErrorObject } from './types.js';
4
+ /**
5
+ * Transforms raw Ajv errors into enriched, human-friendly error objects.
6
+ *
7
+ * A plain string is passed through as a single error message. Otherwise the Ajv
8
+ * errors are prettified and deduplicated.
9
+ */
10
+ export declare function transformErrors(specification: AnyObject, errors: string | AjvError[]): ErrorObject[];
11
+ //# sourceMappingURL=transform-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transform-errors.d.ts","sourceRoot":"","sources":["../src/transform-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAGpD,OAAO,EAAE,KAAK,QAAQ,EAAqB,MAAM,uBAAuB,CAAA;AACxE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAE1C;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ,EAAE,GAAG,WAAW,EAAE,CAyCpG"}
@@ -0,0 +1,44 @@
1
+ import { deduplicateErrors } from './deduplicate-errors.js';
2
+ import { prettifyAjvErrors } from './prettify-ajv-errors.js';
3
+ /**
4
+ * Transforms raw Ajv errors into enriched, human-friendly error objects.
5
+ *
6
+ * A plain string is passed through as a single error message. Otherwise the Ajv
7
+ * errors are prettified and deduplicated.
8
+ */
9
+ export function transformErrors(specification, errors) {
10
+ if (typeof errors === 'string') {
11
+ return [{ message: errors }];
12
+ }
13
+ // If the specification is null or invalid, the errors cannot be prettified.
14
+ // This can happen when reference resolution fails.
15
+ if (!specification || typeof specification !== 'object') {
16
+ return [{ message: 'Invalid specification' }];
17
+ }
18
+ // Wrap prettifyAjvErrors in a try-catch since it can fail with malformed schemas.
19
+ let processedErrors;
20
+ try {
21
+ processedErrors = prettifyAjvErrors(specification, errors).map((error) => ({
22
+ ...error,
23
+ message: error.message.trim(),
24
+ }));
25
+ }
26
+ catch (error) {
27
+ console.error(error);
28
+ // If prettifying fails, fall back to the raw Ajv errors. `errors` is always
29
+ // an array here: the string case returned above.
30
+ return errors.map((err) => {
31
+ let message = err.message || 'Validation error';
32
+ // For additionalProperties errors, include the property name
33
+ if (err.keyword === 'additionalProperties' && err.params?.additionalProperty) {
34
+ message = `Property ${err.params.additionalProperty} is not expected to be here`;
35
+ }
36
+ return {
37
+ message,
38
+ path: err.dataPath || err.instancePath,
39
+ };
40
+ });
41
+ }
42
+ // Deduplicate errors with the same message and path
43
+ return deduplicateErrors(processedErrors);
44
+ }
@@ -0,0 +1,64 @@
1
+ import type { UnknownObject } from '@scalar/types/utils';
2
+ /**
3
+ * A single validation error.
4
+ *
5
+ * `path` is either a JSON Pointer string (from schema validation) or a list of
6
+ * path segments (when a caller adds its own semantic errors).
7
+ */
8
+ export type ErrorObject = {
9
+ message: string;
10
+ path?: string | string[];
11
+ code?: string;
12
+ };
13
+ /**
14
+ * The result of validating a document against a specification (OpenAPI,
15
+ * AsyncAPI, …). Generic over the specification's version union so each validator
16
+ * reports its own versions.
17
+ */
18
+ export type ValidationOutcome<TVersion extends string = string> = {
19
+ valid: true;
20
+ version: TVersion;
21
+ errors?: ErrorObject[];
22
+ schema: UnknownObject;
23
+ } | {
24
+ valid: false;
25
+ version?: TVersion;
26
+ errors: ErrorObject[];
27
+ schema?: UnknownObject;
28
+ };
29
+ /**
30
+ * Options for a single validation call.
31
+ */
32
+ export type ValidateOptions = {
33
+ /**
34
+ * If `true`, throw on the first error instead of returning them.
35
+ *
36
+ * @default false
37
+ */
38
+ throwOnError?: boolean;
39
+ };
40
+ /**
41
+ * Options that affect how a schema is compiled into a validator.
42
+ *
43
+ * These only take effect at compile time, so they belong on `createValidator`
44
+ * (or the first `validate` call for a given schema), not on individual calls.
45
+ */
46
+ export type CreateValidatorOptions = {
47
+ /**
48
+ * Extra Ajv formats to register beyond `ajv-formats`, keyed by name.
49
+ *
50
+ * For example OpenAPI 3.1/3.2 documents use a `media-range` format.
51
+ */
52
+ formats?: Record<string, unknown>;
53
+ };
54
+ /**
55
+ * The result of validating a document against a schema.
56
+ */
57
+ export type ValidationResult = {
58
+ valid: true;
59
+ errors: [];
60
+ } | {
61
+ valid: false;
62
+ errors: ErrorObject[];
63
+ };
64
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAExD;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACxB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,CAAC,QAAQ,SAAS,MAAM,GAAG,MAAM,IAC1D;IACE,KAAK,EAAE,IAAI,CAAA;IACX,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,CAAC,EAAE,WAAW,EAAE,CAAA;IACtB,MAAM,EAAE,aAAa,CAAA;CACtB,GACD;IACE,KAAK,EAAE,KAAK,CAAA;IACZ,OAAO,CAAC,EAAE,QAAQ,CAAA;IAClB,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB,CAAA;AAEL;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClC,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GACxB;IACE,KAAK,EAAE,IAAI,CAAA;IACX,MAAM,EAAE,EAAE,CAAA;CACX,GACD;IACE,KAAK,EAAE,KAAK,CAAA;IACZ,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB,CAAA"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { CreateValidatorOptions, ValidateOptions, ValidationResult } from './types.js';
2
+ type SchemaObject = Record<string, any>;
3
+ /**
4
+ * Compiles a JSON Schema once and returns a reusable validator function.
5
+ *
6
+ * Prefer this when validating many documents against the same schema.
7
+ *
8
+ * Compiling happens up front, so a schema Ajv cannot compile throws from this
9
+ * call rather than from the returned function. Use `validate` instead when a
10
+ * schema is untrusted and an uncompilable one should come back as a result.
11
+ */
12
+ export declare function createValidator(schema: SchemaObject, options?: CreateValidatorOptions): (document: unknown, callOptions?: ValidateOptions) => ValidationResult;
13
+ /**
14
+ * Validates a document against a JSON Schema.
15
+ *
16
+ * The document can be an object, or a JSON or YAML string. Compiled schemas are
17
+ * cached by identity, so passing the same schema object again is cheap. Because
18
+ * of that cache, `formats` only take effect the first time a given schema object
19
+ * is validated; reuse `createValidator` when you need per-schema formats.
20
+ */
21
+ export declare function validate(document: unknown, schema: SchemaObject, options?: ValidateOptions & CreateValidatorOptions): ValidationResult;
22
+ export {};
23
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,sBAAsB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAExF,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AA0FvC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,sBAAsB,IAG5E,UAAU,OAAO,EAAE,cAAc,eAAe,KAAG,gBAAgB,CAE5E;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,eAAe,GAAG,sBAAsB,GACjD,gBAAgB,CAyClB"}
@@ -0,0 +1,131 @@
1
+ import Ajv, {} from 'ajv';
2
+ import Ajv2020 from 'ajv/dist/2020.js';
3
+ import Ajv04 from 'ajv-draft-04';
4
+ import addFormats from 'ajv-formats';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { transformErrors } from './transform-errors.js';
7
+ /**
8
+ * Ajv classes keyed by the JSON Schema dialect a schema declares in `$schema`.
9
+ *
10
+ * The keys omit the trailing `#`, which schemas include inconsistently (OpenAPI
11
+ * draft-04 has it, the AsyncAPI draft-07 schemas do not). Lookups normalize the
12
+ * `$schema` value the same way so both variants resolve.
13
+ */
14
+ const ajvClassesByDialect = {
15
+ 'http://json-schema.org/draft-04/schema': Ajv04,
16
+ 'http://json-schema.org/draft-07/schema': Ajv,
17
+ 'https://json-schema.org/draft/2020-12/schema': Ajv2020,
18
+ };
19
+ const compile = (schema, formats) => {
20
+ const dialect = typeof schema.$schema === 'string' ? schema.$schema.replace(/#$/, '') : '';
21
+ const AjvClass = ajvClassesByDialect[dialect] ?? Ajv;
22
+ const ajv = new AjvClass({
23
+ // Ajv is a bit too strict in its strict validation of these schemas.
24
+ strict: false,
25
+ // Enable discriminator support for better oneOf error messages
26
+ discriminator: true,
27
+ // Show all errors, not just the first one
28
+ allErrors: true,
29
+ });
30
+ // Register the standard formats, then any caller-provided extras.
31
+ addFormats(ajv);
32
+ if (formats) {
33
+ for (const [name, definition] of Object.entries(formats)) {
34
+ ajv.addFormat(name, definition);
35
+ }
36
+ }
37
+ return ajv.compile(schema);
38
+ };
39
+ /**
40
+ * Compiled validators cached by schema identity. Compiling a schema is
41
+ * expensive, so repeat validations of the same schema object reuse the result.
42
+ */
43
+ const compiledValidators = new WeakMap();
44
+ /**
45
+ * Schemas that failed to compile, and why. Cached alongside the successes so a
46
+ * broken schema fails fast instead of paying the full Ajv setup cost per call.
47
+ */
48
+ const failedCompilations = new WeakMap();
49
+ const toMessage = (error) => (error instanceof Error ? error.message : String(error));
50
+ const runValidation = (validateFn, document, options) => {
51
+ let value;
52
+ if (typeof document === 'string') {
53
+ // A malformed JSON/YAML string is a validation failure like any other, so it
54
+ // has to respect `throwOnError` rather than escaping as a parser exception.
55
+ try {
56
+ value = parseYaml(document);
57
+ }
58
+ catch (error) {
59
+ if (options?.throwOnError) {
60
+ throw error;
61
+ }
62
+ return { valid: false, errors: [{ message: toMessage(error) }] };
63
+ }
64
+ }
65
+ else {
66
+ value = document;
67
+ }
68
+ if (validateFn(value)) {
69
+ return { valid: true, errors: [] };
70
+ }
71
+ const errors = transformErrors(value, (validateFn.errors ?? []));
72
+ if (options?.throwOnError) {
73
+ throw new Error(errors[0]?.message ?? 'Validation failed');
74
+ }
75
+ return { valid: false, errors };
76
+ };
77
+ /**
78
+ * Compiles a JSON Schema once and returns a reusable validator function.
79
+ *
80
+ * Prefer this when validating many documents against the same schema.
81
+ *
82
+ * Compiling happens up front, so a schema Ajv cannot compile throws from this
83
+ * call rather than from the returned function. Use `validate` instead when a
84
+ * schema is untrusted and an uncompilable one should come back as a result.
85
+ */
86
+ export function createValidator(schema, options) {
87
+ const validateFn = compile(schema, options?.formats);
88
+ return (document, callOptions) => runValidation(validateFn, document, callOptions);
89
+ }
90
+ /**
91
+ * Validates a document against a JSON Schema.
92
+ *
93
+ * The document can be an object, or a JSON or YAML string. Compiled schemas are
94
+ * cached by identity, so passing the same schema object again is cheap. Because
95
+ * of that cache, `formats` only take effect the first time a given schema object
96
+ * is validated; reuse `createValidator` when you need per-schema formats.
97
+ */
98
+ export function validate(document, schema, options) {
99
+ // Booleans are valid JSON Schemas but cannot key a WeakMap, so they skip the
100
+ // caches entirely rather than throwing.
101
+ const isCacheable = typeof schema === 'object' && schema !== null;
102
+ if (isCacheable && failedCompilations.has(schema)) {
103
+ const previousFailure = failedCompilations.get(schema);
104
+ if (options?.throwOnError) {
105
+ throw previousFailure;
106
+ }
107
+ return { valid: false, errors: [{ message: toMessage(previousFailure) }] };
108
+ }
109
+ let validateFn = compiledValidators.get(schema);
110
+ if (!validateFn) {
111
+ // A schema that cannot be compiled (a malformed `pattern`, an unresolvable
112
+ // `$ref`, an unknown dialect) is reported like any other failure, so
113
+ // `throwOnError: false` keeps its promise not to throw.
114
+ try {
115
+ validateFn = compile(schema, options?.formats);
116
+ }
117
+ catch (error) {
118
+ if (isCacheable) {
119
+ failedCompilations.set(schema, error);
120
+ }
121
+ if (options?.throwOnError) {
122
+ throw error;
123
+ }
124
+ return { valid: false, errors: [{ message: toMessage(error) }] };
125
+ }
126
+ if (isCacheable) {
127
+ compiledValidators.set(schema, validateFn);
128
+ }
129
+ }
130
+ return runValidation(validateFn, document, options);
131
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@scalar/json-schema-validator",
3
+ "description": "Validate documents against a JSON Schema with Ajv and human-friendly errors",
4
+ "license": "MIT",
5
+ "author": "Scalar (https://github.com/scalar)",
6
+ "homepage": "https://github.com/scalar/scalar",
7
+ "bugs": "https://github.com/scalar/scalar/issues/new/choose",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/scalar/scalar.git",
11
+ "directory": "packages/json-schema-validator"
12
+ },
13
+ "keywords": [
14
+ "json-schema",
15
+ "scalar",
16
+ "ajv",
17
+ "validator",
18
+ "typescript"
19
+ ],
20
+ "version": "0.1.0",
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "sideEffects": false,
40
+ "dependencies": {
41
+ "ajv": "^8.20.0",
42
+ "ajv-draft-04": "^1.0.0",
43
+ "ajv-formats": "^3.0.1",
44
+ "yaml": "^2.9.0",
45
+ "@scalar/helpers": "0.11.2",
46
+ "@scalar/types": "0.18.3"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^24.1.0",
50
+ "vite": "8.1.5"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
54
+ "test": "vitest --run",
55
+ "types:check": "tsgo --noEmit"
56
+ }
57
+ }