Skip to content

Commit 839e6c2

Browse files
committed
Add support for discriminator identification for anyOf
1 parent 93923de commit 839e6c2

File tree

9 files changed

+204
-36
lines changed

9 files changed

+204
-36
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
@@ -959,7 +959,7 @@ describe("Error messages", async () => {
959959
]);
960960
});
961961

962-
test.skip("anyOf - const-based discriminator mismatch", async () => {
962+
test.skip("anyOf - discriminator with no matches", async () => {
963963
registerSchema({
964964
$schema: "https://json-schema.org/draft/2020-12/schema",
965965
anyOf: [
@@ -1020,6 +1020,65 @@ describe("Error messages", async () => {
10201020
]);
10211021
});
10221022

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

src/localization.js

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

269269
return this._formatMessage("enum-error", formattedArgs);
270270
}
271+
272+
/** @type () => string */
273+
getAnyOfErrorMessage() {
274+
return this._formatMessage("anyOf-error");
275+
}
271276
}

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 & 14 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 = "{$instanceLocation}" 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 = "{$instanceLocation}" 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,10 +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-
dependent-required-error = Property "{$property}" requires property(s): {$missingDependents}.
42-
enum-error = { $variant ->
43-
[suggestion] Unexpected value {$instanceValue}. Did you mean {$suggestion}?
44-
*[fallback] Unexpected value {$instanceValue}. Expected one of: {$allowedValues}.
45-
}

0 commit comments

Comments
 (0)