Skip to content

Commit d178b6a

Browse files
committed
Add the ability to pass EvaluationPlugins to the validate function
1 parent a16960f commit d178b6a

17 files changed

+225
-139
lines changed

README.md

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,57 @@ addMediaTypePlugin("application/schema+yaml", {
187187
});
188188
```
189189
190+
**EvaluationPlugins**
191+
192+
EvaluationPlugins allow you to hook into the validation process for various
193+
purposes. There are hooks for before an after schema evaluation and before and
194+
after keyword evaluation. (See the API section for the full interface) The
195+
following is a simple example to record all the schema locations that were
196+
evaluated. This could be used as part of a solution for determining test
197+
coverage for a schema.
198+
199+
```JavaScript
200+
import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
201+
import { BASIC } from "@hyperjump/json-schema/experimental.js";
202+
203+
class EvaluatedKeywordsPlugin {
204+
constructor() {
205+
this.schemaLocations = new Set();
206+
}
207+
208+
beforeKeyword([, schemaUri]) {
209+
this.schemaLocations.add(schemaUri);
210+
}
211+
}
212+
213+
registerSchema({
214+
$schema: "https://json-schema.org/draft/2020-12/schema",
215+
type: "object",
216+
properties: {
217+
foo: { type: "number" },
218+
bar: { type: "boolean" }
219+
},
220+
required: ["foo"]
221+
}, "https://schemas.hyperjump.io/main");
222+
223+
const evaluatedKeywordPlugin = new EvaluatedKeywordsPlugin();
224+
225+
await validate("https://schemas.hyperjump.io/main", { foo: 42 }, {
226+
outputFormat: BASIC,
227+
plugins: [evaluatedKeywordPlugin]
228+
});
229+
230+
console.log(evaluatedKeywordPlugin.schemaLocations);
231+
// Set(4) {
232+
// 'https://schemas.hyperjump.io/main#/type',
233+
// 'https://schemas.hyperjump.io/main#/properties',
234+
// 'https://schemas.hyperjump.io/main#/properties/foo/type',
235+
// 'https://schemas.hyperjump.io/main#/required'
236+
// }
237+
238+
// NOTE: #/properties/bar is not in the list because the instance doesn't include that property.
239+
```
240+
190241
### API
191242
192243
These are available from any of the exports that refer to a version of JSON
@@ -212,11 +263,11 @@ Schema, such as `@hyperjump/json-schema/draft-2020-12`.
212263
Load a schema manually rather than fetching it from the filesystem or over
213264
the network. Any schema already registered with the same identifier will be
214265
replaced with no warning.
215-
* **validate**: (schemaURI: string, instance: any, outputFormat: OutputFormat = FLAG) => Promise\<OutputUnit>
266+
* **validate**: (schemaURI: string, instance: any, outputFormat: ValidationOptions | OutputFormat = FLAG) => Promise\<OutputUnit>
216267
217268
Validate an instance against a schema. This function is curried to allow
218269
compiling the schema once and applying it to multiple instances.
219-
* **validate**: (schemaURI: string) => Promise\<(instance: any, outputFormat: OutputFormat = FLAG) => OutputUnit>
270+
* **validate**: (schemaURI: string) => Promise\<(instance: any, outputFormat: ValidationOptions | OutputFormat = FLAG) => OutputUnit>
220271
221272
Compiling a schema to a validation function.
222273
* **FLAG**: "FLAG"
@@ -255,6 +306,16 @@ The following types are used in the above definitions
255306
Output is an experimental feature of the JSON Schema specification. There
256307
may be additional fields present in the OutputUnit, but only the `valid`
257308
property should be considered part of the Stable API.
309+
* **ValidationOptions**:
310+
311+
* outputFormat?: OutputFormat
312+
* plugins?: EvaluationPlugin[]
313+
314+
* **EvaluationPlugin**: object
315+
* beforeSchema?(url: string, instance: JsonNode, context: Context): void
316+
* beforeKeyword?(keywordNode: Node<any>, instance: JsonNode, context: Context, schemaContext: Context, keyword: Keyword): void
317+
* afterKeyword?(keywordNode: Node<any>, instance: JsonNode, context: Context, valid: boolean, schemaContext: Context, keyword: Keyword): void
318+
* afterSchema?(url: string, instance: JsonNode, context: Context, valid: boolean): void
258319
259320
## Bundling
260321
@@ -546,12 +607,6 @@ These are available from the `@hyperjump/json-schema/experimental` export.
546607
* **ValidationContext**: object
547608
* ast: AST
548609
* plugins: EvaluationPlugins[]
549-
550-
* **EvaluationPlugin**: object
551-
* beforeSchema(url: string, instance: JsonNode, context: Context): void
552-
* beforeKeyword(keywordNode: Node<any>, instance: JsonNode, context: Context, schemaContext: Context, keyword: Keyword): void
553-
* afterKeyword(keywordNode: Node<any>, instance: JsonNode, context: Context, valid: boolean, schemaContext: Context, keyword: Keyword): void
554-
* afterSchema(url: string, instance: JsonNode, context: Context, valid: boolean): void
555610
* **defineVocabulary**: (id: string, keywords: { [keyword: string]: string }) => void
556611
557612
Define a vocabulary that maps keyword name to keyword URIs defined using

annotations/index.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import type { OutputFormat, OutputUnit } from "../lib/index.js";
1+
import type { OutputFormat, OutputUnit, ValidationOptions } from "../lib/index.js";
22
import type { CompiledSchema } from "../lib/experimental.js";
33
import type { JsonNode } from "../lib/json-node.js";
44
import type { Json } from "@hyperjump/json-pointer";
55

66

77
export const annotate: (
8-
(schemaUrl: string, value: Json, outputFormat?: OutputFormat) => Promise<JsonNode>
8+
(schemaUrl: string, value: Json, options?: OutputFormat | ValidationOptions) => Promise<JsonNode>
99
) & (
1010
(schemaUrl: string) => Promise<Annotator>
1111
);
1212

13-
export type Annotator = (value: Json, outputFormat?: OutputFormat) => JsonNode;
13+
export type Annotator = (value: Json, options?: OutputFormat | ValidationOptions) => JsonNode;
1414

15-
export const interpret: (compiledSchema: CompiledSchema, value: JsonNode, outputFormat?: OutputFormat) => JsonNode;
15+
export const interpret: (compiledSchema: CompiledSchema, value: JsonNode, options?: OutputFormat | ValidationOptions) => JsonNode;
1616

1717
export class ValidationError extends Error {
1818
public output: OutputUnit;

annotations/index.js

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,36 @@
1-
import { FLAG } from "../lib/index.js";
21
import { ValidationError } from "./validation-error.js";
32
import {
43
getSchema,
54
compile,
5+
interpret as validate,
66
BASIC,
7-
DETAILED,
8-
annotationsPlugin,
9-
basicOutputPlugin,
10-
detailedOutputPlugin
7+
AnnotationsPlugin
118
} from "../lib/experimental.js";
12-
import Validation from "../lib/keywords/validation.js";
139
import * as Instance from "../lib/instance.js";
1410

1511

16-
export const annotate = async (schemaUri, json = undefined, outputFormat = undefined) => {
12+
export const annotate = async (schemaUri, json = undefined, options = undefined) => {
1713
const schema = await getSchema(schemaUri);
1814
const compiled = await compile(schema);
19-
const interpretAst = (json, outputFormat) => interpret(compiled, Instance.fromJs(json), outputFormat);
15+
const interpretAst = (json, options) => interpret(compiled, Instance.fromJs(json), options);
2016

21-
return json === undefined ? interpretAst : interpretAst(json, outputFormat);
17+
return json === undefined ? interpretAst : interpretAst(json, options);
2218
};
2319

24-
export const interpret = ({ ast, schemaUri }, instance, outputFormat = BASIC) => {
25-
const context = { ast, plugins: [annotationsPlugin, ...ast.plugins] };
26-
27-
switch (outputFormat) {
28-
case FLAG:
29-
break;
30-
case BASIC:
31-
context.plugins.push(basicOutputPlugin);
32-
break;
33-
case DETAILED:
34-
context.plugins.push(detailedOutputPlugin);
35-
break;
36-
default:
37-
throw Error(`Unsupported output format '${outputFormat}'`);
38-
}
20+
export const interpret = (compiledSchema, instance, options = BASIC) => {
21+
const annotationsPlugin = new AnnotationsPlugin();
22+
const plugins = options.plugins ?? [];
3923

40-
const valid = Validation.interpret(schemaUri, instance, context);
24+
const output = validate(compiledSchema, instance, {
25+
outputFormat: typeof options === "string" ? options : options.outputFormat ?? BASIC,
26+
plugins: [annotationsPlugin, ...plugins]
27+
});
4128

42-
if (!valid) {
43-
const result = !valid && "errors" in context ? { valid, errors: context.errors } : { valid };
44-
throw new ValidationError(result);
29+
if (!output.valid) {
30+
throw new ValidationError(output);
4531
}
4632

47-
for (const annotation of context.annotations) {
33+
for (const annotation of annotationsPlugin.annotations) {
4834
const node = Instance.get(annotation.instanceLocation, instance);
4935
const keyword = annotation.keyword;
5036
if (!node.annotations[keyword]) {

bundle/generate-snapshots.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { writeFile, mkdir, rm } from "node:fs/promises";
22
import { isCompatible, md5, loadSchemas, testSuite, unloadSchemas } from "./test-utils.js";
3-
import { annotationsPlugin, compile, detailedOutputPlugin, getSchema, Validation } from "../lib/experimental.js";
3+
import { AnnotationsPlugin, compile, DetailedOutputPlugin, getSchema, Validation } from "../lib/experimental.js";
44
import "../stable/index.js";
55
import "../draft-2020-12/index.js";
66
import "../draft-2019-09/index.js";
@@ -26,15 +26,17 @@ const snapshotGenerator = async (version, dialect) => {
2626

2727
const schema = await getSchema(mainSchemaUri);
2828
const { ast, schemaUri } = await compile(schema);
29+
const annotationsPlugin = new AnnotationsPlugin();
30+
const detailedOutputPlugin = new DetailedOutputPlugin();
2931

3032
const instance = Instance.fromJs(test.instance);
3133
const context = { ast, plugins: [detailedOutputPlugin, annotationsPlugin, ...ast.plugins] };
3234
const valid = Validation.interpret(schemaUri, instance, context);
3335

3436
const expectedOutput = {
3537
valid,
36-
errors: context.errors,
37-
annotations: context.annotations
38+
errors: detailedOutputPlugin.errors,
39+
annotations: annotationsPlugin.annotations
3840
};
3941

4042
unloadSchemas(testCase, mainSchemaUri);

bundle/test-suite.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
33
import { isCompatible, md5, loadSchemas, unloadSchemas, testSuite } from "./test-utils.js";
44
import { registerSchema, unregisterSchema } from "../lib/index.js";
55
import {
6-
annotationsPlugin,
6+
AnnotationsPlugin,
77
compile,
8-
detailedOutputPlugin,
8+
DetailedOutputPlugin,
99
getKeywordName,
1010
getSchema,
1111
Validation
@@ -65,6 +65,8 @@ const testRunner = (version: number, dialect: string) => {
6565
const schema = await getSchema(mainSchemaUri);
6666
const { ast, schemaUri } = await compile(schema);
6767

68+
const annotationsPlugin = new AnnotationsPlugin();
69+
const detailedOutputPlugin = new DetailedOutputPlugin();
6870
const instance = Instance.fromJs(test.instance);
6971
const context = {
7072
ast,
@@ -74,8 +76,8 @@ const testRunner = (version: number, dialect: string) => {
7476

7577
const output = {
7678
valid,
77-
errors: context.errors,
78-
annotations: context.annotations
79+
errors: detailedOutputPlugin.errors,
80+
annotations: annotationsPlugin.annotations
7981
};
8082

8183
const testId = md5(`${version}|${dialect}|${testCase.description}|${testIndex}`);

draft-2020-12/dynamicRef.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ const plugin = {
3232
},
3333
beforeKeyword(_url, _instance, context, schemaContext) {
3434
context.dynamicAnchors = schemaContext.dynamicAnchors;
35-
},
36-
afterKeyword() {
37-
},
38-
afterSchema() {
3935
}
4036
};
4137

lib/core.js

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,19 @@ import { InvalidSchemaError } from "./invalid-schema-error.js";
1111
import { getSchema, registerSchema, unregisterSchema as schemaUnregister } from "./schema.js";
1212
import { getKeywordName } from "./keywords.js";
1313
import Validation from "./keywords/validation.js";
14-
import { basicOutputPlugin } from "./evaluation-plugins/basic-output.js";
15-
import { detailedOutputPlugin } from "./evaluation-plugins/detailed-output.js";
14+
import { BasicOutputPlugin } from "./evaluation-plugins/basic-output.js";
15+
import { DetailedOutputPlugin } from "./evaluation-plugins/detailed-output.js";
1616

1717

1818
export const FLAG = "FLAG", BASIC = "BASIC", DETAILED = "DETAILED";
1919
setMetaSchemaOutputFormat(FLAG);
2020

21-
export const validate = async (url, value = undefined, outputFormat = undefined) => {
21+
export const validate = async (url, value = undefined, options = undefined) => {
2222
const schema = await getSchema(url);
2323
const compiled = await compile(schema);
24-
const interpretAst = (value, outputFormat) => interpret(compiled, Instance.fromJs(value), outputFormat);
24+
const interpretAst = (value, options) => interpret(compiled, Instance.fromJs(value), options);
2525

26-
return value === undefined ? interpretAst : interpretAst(value, outputFormat);
26+
return value === undefined ? interpretAst : interpretAst(value, options);
2727
};
2828

2929
export const compile = async (schema) => {
@@ -32,24 +32,30 @@ export const compile = async (schema) => {
3232
return { ast, schemaUri };
3333
};
3434

35-
export const interpret = curry(({ ast, schemaUri }, instance, outputFormat = FLAG) => {
36-
const context = { ast, plugins: [...ast.plugins] };
35+
export const interpret = curry(({ ast, schemaUri }, instance, options = FLAG) => {
36+
const outputFormat = typeof options === "string" ? options : options.outputFormat ?? FLAG;
37+
const plugins = options.plugins ?? [];
3738

39+
const context = { ast, plugins: [...ast.plugins, ...plugins] };
40+
41+
let outputPlugin;
3842
switch (outputFormat) {
3943
case FLAG:
4044
break;
4145
case BASIC:
42-
context.plugins.push(basicOutputPlugin);
46+
outputPlugin = new BasicOutputPlugin();
47+
context.plugins.push(outputPlugin);
4348
break;
4449
case DETAILED:
45-
context.plugins.push(detailedOutputPlugin);
50+
outputPlugin = new DetailedOutputPlugin();
51+
context.plugins.push(outputPlugin);
4652
break;
4753
default:
4854
throw Error(`Unsupported output format '${outputFormat}'`);
4955
}
5056

5157
const valid = Validation.interpret(schemaUri, instance, context);
52-
return !valid && "errors" in context ? { valid, errors: context.errors } : { valid };
58+
return !valid && outputPlugin ? { valid, errors: outputPlugin.errors } : { valid };
5359
});
5460

5561
const metaValidators = {};

lib/evaluation-plugins/annotations.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import * as Instance from "../instance.js";
22

33

4-
export const annotationsPlugin = {
4+
export class AnnotationsPlugin {
55
beforeSchema(_url, _instance, context) {
66
context.annotations ??= [];
77
context.schemaAnnotations = [];
8-
},
8+
}
9+
910
beforeKeyword(_node, _instance, context) {
1011
context.annotations = [];
11-
},
12+
}
13+
1214
afterKeyword(node, instance, context, valid, schemaContext, keyword) {
1315
if (valid) {
1416
const [keywordId, schemaUri, keywordValue] = node;
@@ -23,10 +25,13 @@ export const annotationsPlugin = {
2325
}
2426
schemaContext.schemaAnnotations.push(...context.annotations);
2527
}
26-
},
28+
}
29+
2730
afterSchema(_schemaNode, _instanceNode, context, valid) {
2831
if (valid) {
2932
context.annotations.push(...context.schemaAnnotations);
3033
}
34+
35+
this.annotations = context.annotations;
3136
}
32-
};
37+
}

lib/evaluation-plugins/basic-output.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { Validation } from "../experimental.js";
22
import * as Instance from "../instance.js";
33

44

5-
export const basicOutputPlugin = {
5+
export class BasicOutputPlugin {
66
beforeSchema(_url, _intance, context) {
77
context.errors ??= [];
8-
},
8+
}
9+
910
beforeKeyword(_node, _instance, context) {
1011
context.errors = [];
11-
},
12+
}
13+
1214
afterKeyword(node, instance, context, valid, schemaContext, keyword) {
1315
if (!valid) {
1416
if (!keyword.simpleApplicator) {
@@ -21,7 +23,8 @@ export const basicOutputPlugin = {
2123
}
2224
schemaContext.errors.push(...context.errors);
2325
}
24-
},
26+
}
27+
2528
afterSchema(url, instance, context, valid) {
2629
if (typeof context.ast[url] === "boolean" && !valid) {
2730
context.errors.push({
@@ -30,5 +33,7 @@ export const basicOutputPlugin = {
3033
instanceLocation: Instance.uri(instance)
3134
});
3235
}
36+
37+
this.errors = context.errors;
3338
}
34-
};
39+
}

0 commit comments

Comments
 (0)