Skip to content

Commit 2327144

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

17 files changed

+230
-139
lines changed

README.md

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,11 @@ Schema, such as `@hyperjump/json-schema/draft-2020-12`.
212212
Load a schema manually rather than fetching it from the filesystem or over
213213
the network. Any schema already registered with the same identifier will be
214214
replaced with no warning.
215-
* **validate**: (schemaURI: string, instance: any, outputFormat: OutputFormat = FLAG) => Promise\<OutputUnit>
215+
* **validate**: (schemaURI: string, instance: any, outputFormat: ValidationOptions | OutputFormat = FLAG) => Promise\<OutputUnit>
216216
217217
Validate an instance against a schema. This function is curried to allow
218218
compiling the schema once and applying it to multiple instances.
219-
* **validate**: (schemaURI: string) => Promise\<(instance: any, outputFormat: OutputFormat = FLAG) => OutputUnit>
219+
* **validate**: (schemaURI: string) => Promise\<(instance: any, outputFormat: ValidationOptions | OutputFormat = FLAG) => OutputUnit>
220220
221221
Compiling a schema to a validation function.
222222
* **FLAG**: "FLAG"
@@ -255,6 +255,10 @@ The following types are used in the above definitions
255255
Output is an experimental feature of the JSON Schema specification. There
256256
may be additional fields present in the OutputUnit, but only the `valid`
257257
property should be considered part of the Stable API.
258+
* **ValidationOptions**:
259+
260+
* outputFormat?: OutputFormat
261+
* plugins?: EvaluationPlugin[]
258262
259263
## Bundling
260264
@@ -504,6 +508,57 @@ registerSchema({
504508
const output = await validate("https://example.com/schema1", 42); // Expect InvalidSchemaError
505509
```
506510
511+
### EvaluationPlugins
512+
513+
EvaluationPlugins allow you to hook into the validation process for various
514+
purposes. There are hooks for before an after schema evaluation and before and
515+
after keyword evaluation. (See the API section for the full interface) The
516+
following is a simple example to record all the schema locations that were
517+
evaluated. This could be used as part of a solution for determining test
518+
coverage for a schema.
519+
520+
```JavaScript
521+
import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
522+
import { BASIC } from "@hyperjump/json-schema/experimental.js";
523+
524+
class EvaluatedKeywordsPlugin {
525+
constructor() {
526+
this.schemaLocations = new Set();
527+
}
528+
529+
beforeKeyword([, schemaUri]) {
530+
this.schemaLocations.add(schemaUri);
531+
}
532+
}
533+
534+
registerSchema({
535+
$schema: "https://json-schema.org/draft/2020-12/schema",
536+
type: "object",
537+
properties: {
538+
foo: { type: "number" },
539+
bar: { type: "boolean" }
540+
},
541+
required: ["foo"]
542+
}, "https://schemas.hyperjump.io/main");
543+
544+
const evaluatedKeywordPlugin = new EvaluatedKeywordsPlugin();
545+
546+
await validate("https://schemas.hyperjump.io/main", { foo: 42 }, {
547+
outputFormat: BASIC,
548+
plugins: [evaluatedKeywordPlugin]
549+
});
550+
551+
console.log(evaluatedKeywordPlugin.schemaLocations);
552+
// Set(4) {
553+
// 'https://schemas.hyperjump.io/main#/type',
554+
// 'https://schemas.hyperjump.io/main#/properties',
555+
// 'https://schemas.hyperjump.io/main#/properties/foo/type',
556+
// 'https://schemas.hyperjump.io/main#/required'
557+
// }
558+
559+
// NOTE: #/properties/bar is not in the list because the instance doesn't include that property.
560+
```
561+
507562
### API
508563
509564
These are available from the `@hyperjump/json-schema/experimental` export.
@@ -543,15 +598,13 @@ These are available from the `@hyperjump/json-schema/experimental` export.
543598
function to return the annotation.
544599
* plugin?: EvaluationPlugin
545600
601+
If the keyword needs to track state during the evaluation process, you
602+
can include an EvaluationPlugin that will get added only when this
603+
keyword is present in the schema.
604+
546605
* **ValidationContext**: object
547606
* ast: AST
548607
* 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
555608
* **defineVocabulary**: (id: string, keywords: { [keyword: string]: string }) => void
556609
557610
Define a vocabulary that maps keyword name to keyword URIs defined using
@@ -630,6 +683,12 @@ These are available from the `@hyperjump/json-schema/experimental` export.
630683
include annotations or human readable error messages. The output can be
631684
processed to create human readable error messages as needed.
632685
686+
* **EvaluationPlugin**: object
687+
* beforeSchema?(url: string, instance: JsonNode, context: Context): void
688+
* beforeKeyword?(keywordNode: CompiledSchemaNode, instance: JsonNode, context: Context, schemaContext: Context, keyword: Keyword): void
689+
* afterKeyword?(keywordNode: CompiledSchemaNode, instance: JsonNode, context: Context, valid: boolean, schemaContext: Context, keyword: Keyword): void
690+
* afterSchema?(url: string, instance: JsonNode, context: Context, valid: boolean): void
691+
633692
## Instance API (experimental)
634693
635694
These functions are available from the

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+
}

0 commit comments

Comments
 (0)