Skip to content

Commit a890716

Browse files
committed
Add support for discriminator identification for anyOf
1 parent 6e113c6 commit a890716

File tree

9 files changed

+204
-35
lines changed

9 files changed

+204
-35
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"dependencies": {
4040
"@fluent/bundle": "^0.19.1",
4141
"@hyperjump/browser": "^1.3.1",
42+
"@hyperjump/json-pointer": "^1.1.1",
4243
"@hyperjump/json-schema": "^1.16.0",
4344
"@hyperjump/pact": "^1.4.0",
4445
"leven": "^4.0.0"

src/error-handlers/anyOf.js

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,38 @@
11
import { getSchema } from "@hyperjump/json-schema/experimental";
2-
import * as Schema from "@hyperjump/browser";
32
import * as Instance from "@hyperjump/json-schema/instance/experimental";
3+
import * as Schema from "@hyperjump/browser";
4+
import * as JsonPointer from "@hyperjump/json-pointer";
45
import { getErrors } from "../error-handling.js";
56

67
/**
78
* @import { ErrorHandler, ErrorObject, NormalizedOutput } from "../index.d.ts"
89
*/
910

1011
/** @type ErrorHandler */
11-
const anyOf = async (normalizedErrors, instance, localization) => {
12+
const anyOfErrorHandler = async (normalizedErrors, instance, localization) => {
1213
/** @type ErrorObject[] */
1314
const errors = [];
1415

1516
if (normalizedErrors["https://json-schema.org/keyword/anyOf"]) {
1617
for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/anyOf"]) {
18+
const allAlternatives = normalizedErrors["https://json-schema.org/keyword/anyOf"][schemaLocation];
19+
if (typeof allAlternatives === "boolean") {
20+
continue;
21+
}
22+
1723
/** @type NormalizedOutput[] */
1824
const alternatives = [];
19-
const allAlternatives = /** @type NormalizedOutput[] */ (normalizedErrors["https://json-schema.org/keyword/anyOf"][schemaLocation]);
2025
for (const alternative of allAlternatives) {
21-
if (Object.values(alternative[Instance.uri(instance)]["https://json-schema.org/keyword/type"]).every((valid) => valid)) {
26+
if (Object.values(alternative[Instance.uri(instance)]["https://json-schema.org/keyword/type"] ?? {}).every((valid) => valid)) {
2227
alternatives.push(alternative);
2328
}
2429
}
25-
// case 1 where no. alternative matched the type of the instance.
30+
31+
// No alternative matched the type of the instance.
2632
if (alternatives.length === 0) {
2733
/** @type Set<string> */
2834
const expectedTypes = new Set();
35+
2936
for (const alternative of allAlternatives) {
3037
for (const instanceLocation in alternative) {
3138
if (instanceLocation === Instance.uri(instance)) {
@@ -37,28 +44,114 @@ const anyOf = async (normalizedErrors, instance, localization) => {
3744
}
3845
}
3946
}
47+
4048
errors.push({
4149
message: localization.getTypeErrorMessage([...expectedTypes], Instance.typeOf(instance)),
4250
instanceLocation: Instance.uri(instance),
4351
schemaLocation: schemaLocation
4452
});
45-
} else if (alternatives.length === 1) { // case 2 when only one type match
46-
return getErrors(alternatives[0], instance, localization);
47-
} else if (instance.type === "object") {
48-
let targetAlternativeIndex = -1;
49-
for (const alternative of alternatives) {
50-
targetAlternativeIndex++;
53+
continue;
54+
}
55+
56+
// Only one alternative matches the type of the instance
57+
if (alternatives.length === 1) {
58+
errors.push(...await getErrors(alternatives[0], instance, localization));
59+
continue;
60+
}
61+
62+
if (instance.type === "object") {
63+
const definedProperties = allAlternatives.map((alternative) => {
64+
/** @type Set<string> */
65+
const alternativeProperties = new Set();
66+
5167
for (const instanceLocation in alternative) {
52-
if (instanceLocation !== "#") {
53-
return getErrors(alternatives[targetAlternativeIndex], instance, localization);
68+
const pointer = instanceLocation.slice(Instance.uri(instance).length + 1);
69+
if (pointer.length > 0) {
70+
const position = pointer.indexOf("/");
71+
const propertyName = pointer.slice(0, position === -1 ? undefined : position);
72+
const location = JsonPointer.append(propertyName, Instance.uri(instance));
73+
alternativeProperties.add(location);
5474
}
5575
}
76+
77+
return alternativeProperties;
78+
});
79+
80+
const discriminator = definedProperties.reduce((acc, properties) => {
81+
return acc.intersection(properties);
82+
}, definedProperties[0]);
83+
84+
const discriminatedAlternatives = alternatives.filter((alternative) => {
85+
for (const instanceLocation in alternative) {
86+
if (!discriminator.has(instanceLocation)) {
87+
continue;
88+
}
89+
90+
let valid = true;
91+
for (const keyword in alternative[instanceLocation]) {
92+
for (const schemaLocation in alternative[instanceLocation][keyword]) {
93+
if (alternative[instanceLocation][keyword][schemaLocation] !== true) {
94+
valid = false;
95+
break;
96+
}
97+
}
98+
}
99+
if (valid) {
100+
return true;
101+
}
102+
}
103+
return false;
104+
});
105+
106+
// Discriminator match
107+
if (discriminatedAlternatives.length === 1) {
108+
errors.push(...await getErrors(discriminatedAlternatives[0], instance, localization));
109+
continue;
56110
}
111+
112+
// Discriminator identified, but none of the alternatives match
113+
if (discriminatedAlternatives.length === 0) {
114+
// TODO: How do we handle this case?
115+
}
116+
117+
// Last resort, select the alternative with the most properties matching the instance
118+
// TODO: We shouldn't use this strategy if alternatives have the same number of matching instances
119+
const instanceProperties = new Set(Instance.values(instance)
120+
.map((node) => Instance.uri(node)));
121+
let maxMatches = -1;
122+
let selectedIndex = 0;
123+
let index = -1;
124+
for (const alternativeProperties of definedProperties) {
125+
index++;
126+
const matches = alternativeProperties.intersection(instanceProperties).size;
127+
if (matches > maxMatches) {
128+
selectedIndex = index;
129+
}
130+
}
131+
132+
errors.push(...await getErrors(alternatives[selectedIndex], instance, localization));
133+
continue;
57134
}
135+
136+
// TODO: Handle alternatives with const
137+
// TODO: Handle alternatives with enum
138+
// TODO: Handle null alternatives
139+
// TODO: Handle boolean alternatives
140+
// TODO: Handle string alternatives
141+
// TODO: Handle array alternatives
142+
// TODO: Handle alternatives without a type
143+
144+
// TODO: If we get here, we don't know what else to do and give a very generic message
145+
// Ideally this should be replace by something that can handle whatever case is missing.
146+
errors.push({
147+
message: localization.getAnyOfErrorMessage(),
148+
instanceLocation: Instance.uri(instance),
149+
schemaLocation: schemaLocation
150+
});
58151
}
59152
}
60153

61154
return errors;
62155
};
63156

64-
export default anyOf;
157+
export default anyOfErrorHandler;

src/keyword-error-message.test.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ describe("Error messages", async () => {
961961
]);
962962
});
963963

964-
test.skip("anyOf - const-based discriminator mismatch", async () => {
964+
test.skip("anyOf - discriminator with no matches", async () => {
965965
registerSchema({
966966
$schema: "https://json-schema.org/draft/2020-12/schema",
967967
anyOf: [
@@ -1022,6 +1022,65 @@ describe("Error messages", async () => {
10221022
]);
10231023
});
10241024

1025+
test("anyOf - discriminator with one match", async () => {
1026+
registerSchema({
1027+
$schema: "https://json-schema.org/draft/2020-12/schema",
1028+
anyOf: [
1029+
{
1030+
type: "object",
1031+
properties: {
1032+
type: { const: "a" },
1033+
apple: { type: "string" }
1034+
},
1035+
required: ["type"]
1036+
},
1037+
{
1038+
type: "object",
1039+
properties: {
1040+
type: { const: "b" },
1041+
banana: { type: "string" }
1042+
},
1043+
required: ["type"]
1044+
}
1045+
]
1046+
}, schemaUri);
1047+
1048+
const instance = {
1049+
type: "a",
1050+
apple: 42,
1051+
banana: "yellow"
1052+
};
1053+
1054+
/** @type OutputFormat */
1055+
const output = {
1056+
valid: false,
1057+
errors: [
1058+
{
1059+
absoluteKeywordLocation: `https://example.com/main#/anyOf/0/properties/apple/type`,
1060+
instanceLocation: "#/apple"
1061+
},
1062+
{
1063+
absoluteKeywordLocation: `https://example.com/main#/anyOf/1/properties/type/const`,
1064+
instanceLocation: "#/type"
1065+
},
1066+
{
1067+
absoluteKeywordLocation: `https://example.com/main#/anyOf`,
1068+
instanceLocation: "#"
1069+
}
1070+
]
1071+
};
1072+
1073+
const result = await betterJsonSchemaErrors(output, schemaUri, instance);
1074+
1075+
expect(result.errors).to.eql([
1076+
{
1077+
schemaLocation: `https://example.com/main#/anyOf/0/properties/apple/type`,
1078+
instanceLocation: "#/apple",
1079+
message: localization.getTypeErrorMessage("string", "number")
1080+
}
1081+
]);
1082+
});
1083+
10251084
test("anyOf - using $ref in alternatives", async () => {
10261085
const subjectUri = "https://example.com/main";
10271086

src/localization.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,9 @@ export class Localization {
259259

260260
return this._formatMessage("enum-error", formattedArgs);
261261
}
262+
263+
/** @type () => string */
264+
getAnyOfErrorMessage() {
265+
return this._formatMessage("anyOf-error");
266+
}
262267
}

src/normalization-handlers/properties.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { evaluateSchema } from "../normalized-output.js";
21
import * as Instance from "@hyperjump/json-schema/instance/experimental";
2+
import * as JsonPointer from "@hyperjump/json-pointer";
3+
import { evaluateSchema } from "../normalized-output.js";
34

45
/**
56
* @import { KeywordHandler, NormalizedOutput } from "../index.d.ts"
@@ -15,10 +16,13 @@ const properties = {
1516
for (const propertyName in properties) {
1617
const propertyNode = Instance.step(propertyName, instance);
1718
if (!propertyNode) {
18-
continue;
19+
errors.push({
20+
[JsonPointer.append(propertyName, Instance.uri(instance))]: {}
21+
});
22+
} else {
23+
errors.push(evaluateSchema(properties[propertyName], propertyNode, context));
24+
context.evaluatedProperties?.add(propertyName);
1925
}
20-
errors.push(evaluateSchema(properties[propertyName], propertyNode, context));
21-
context.evaluatedProperties?.add(propertyName);
2226
}
2327

2428
return errors;

src/normalized-output.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ export const evaluateSchema = (schemaLocation, instance, context) => {
8585
/** @type (a: API.NormalizedOutput, b: API.NormalizedOutput) => void */
8686
const mergeOutput = (a, b) => {
8787
for (const instanceLocation in b) {
88+
a[instanceLocation] ??= {};
8889
for (const keywordUri in b[instanceLocation]) {
89-
a[instanceLocation] ??= {};
9090
a[instanceLocation][keywordUri] ??= {};
9191

9292
Object.assign(a[instanceLocation][keywordUri], b[instanceLocation][keywordUri]);

src/normalized-output.test.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ describe("Error Output Normalization", async () => {
138138
"https://example.com/main#/type": true
139139
}
140140
},
141+
"#/name": {},
141142
"#/age": {
142143
"https://json-schema.org/keyword/type": {
143144
"https://example.com/main#/properties/age/type": false
@@ -213,7 +214,8 @@ describe("Error Output Normalization", async () => {
213214
"https://json-schema.org/keyword/type": {
214215
"https://example.com/main#/$defs/profile/properties/name/type": false
215216
}
216-
}
217+
},
218+
"#/profile/age": {}
217219
});
218220
});
219221

@@ -313,7 +315,8 @@ describe("Error Output Normalization", async () => {
313315
"https://json-schema.org/keyword/type": {
314316
"https://example.com/main#/$defs/profile/properties/name/type": false
315317
}
316-
}
318+
},
319+
"#/profile/age": {}
317320
});
318321
});
319322

src/translations/en-US.ftl

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,38 @@
1+
# Non-type specific messages
12
type-error = The instance should be of type {$expected} but found {$actual}.
3+
const-error = The instance should be equal to {$expectedValue}.
4+
enum-error = { $variant ->
5+
[suggestion] Unexpected value {$instanceValue}. Did you mean {$suggestion}?
6+
*[fallback] Unexpected value {$instanceValue}. Expected one of: {$allowedValues}.
7+
}
28
9+
# String messages
310
string-error = Expected a string {$constraints}.
411
string-error-minLength = at least {$minLength} characters long
512
string-error-maxLength = at most {$maxLength} characters long
13+
pattern-error = The instance should match the pattern: {$pattern}.
14+
format-error = The instance should match the format: {$format}.
615
16+
# Number messages
717
number-error = Expected a number {$constraints}.
818
number-error-minimum = greater than {$minimum}
919
number-error-exclusive-minimum = greater than or equal to {$minimum}
1020
number-error-maximum = less than {$maximum}
1121
number-error-exclusive-maximum = less than or equal to {$maximum}
12-
13-
required-error = This instance is missing required property(s): {$missingProperties}.
1422
multiple-of-error = The instance should be a multiple of {$divisor}.
1523
24+
# Object messages
1625
properties-error = Expected object to have {$constraints}
1726
properties-error-max = at most {$maxProperties} properties.
1827
properties-error-min = at least {$minProperties} properties.
28+
required-error = This instance is missing required property(s): {$missingProperties}.
29+
additional-properties-error = The property "{$propertyName}" is not allowed.
1930
20-
const-error = The instance should be equal to {$expectedValue}.
21-
31+
# Array messages
2232
array-error = Expected the array to have {$constraints}.
2333
array-error-min = at least {$minItems} items
2434
array-error-max = at most {$maxItems} items
25-
2635
unique-items-error = The instance should have unique items in the array.
27-
format-error = The instance should match the format: {$format}.
28-
pattern-error = The instance should match the pattern: {$pattern}.
29-
3036
contains-error-min = The array must contain at least {$minContains ->
3137
[one] item that passes
3238
*[other] items that pass
@@ -36,9 +42,6 @@ contains-error-min-max = The array must contain at least {$minContains} and at m
3642
*[other] items that pass
3743
} the 'contains' schema.
3844
45+
# Conditional messages
46+
anyOf-error = The instance must pass at least one of the given schemas.
3947
not-error = The instance is not allowed to be used in this schema.
40-
additional-properties-error = The property "{$propertyName}" is not allowed.
41-
enum-error = { $variant ->
42-
[suggestion] Unexpected value {$instanceValue}. Did you mean {$suggestion}?
43-
*[fallback] Unexpected value {$instanceValue}. Expected one of: {$allowedValues}.
44-
}

0 commit comments

Comments
 (0)