Skip to content

Commit 8b60f29

Browse files
authored
Merge pull request #30 from arpitkuriyal/complexKeyword
completed 3 cases for anyOf keyword
2 parents a690522 + d230dd2 commit 8b60f29

File tree

3 files changed

+280
-72
lines changed

3 files changed

+280
-72
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ docs/
44

55
scratch/
66
TODO*
7+
.DS_Store

src/index.js

Lines changed: 141 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { normalizeOutputFormat } from "./normalizeOutputFormat/normalizeOutput.js";
22
import * as Schema from "@hyperjump/browser";
3-
import { getSchema } from "@hyperjump/json-schema/experimental";
3+
import { getKeywordByName, getSchema } from "@hyperjump/json-schema/experimental";
44
import * as Instance from "@hyperjump/json-pointer";
55
import leven from "leven";
66

@@ -19,7 +19,7 @@ export async function betterJsonSchemaErrors(instance, errorOutput, schemaUri) {
1919
const output = { errors: [] };
2020

2121
for (const errorHandler of errorHandlers) {
22-
const errorObject = await errorHandler(normalizedErrors, instance);
22+
const errorObject = await errorHandler(normalizedErrors, instance, schema);
2323
if (errorObject) {
2424
output.errors.push(...errorObject);
2525
}
@@ -29,31 +29,64 @@ export async function betterJsonSchemaErrors(instance, errorOutput, schemaUri) {
2929
}
3030

3131
/**
32-
* @typedef {(normalizedErrors: NormalizedError[], instance: Json) => Promise<ErrorObject[]>} ErrorHandler
32+
* @typedef {(normalizedErrors: NormalizedError[], instance: Json, schema: Browser<SchemaDocument>) => Promise<ErrorObject[]>} ErrorHandler
3333
*/
3434

3535
/** @type ErrorHandler[] */
3636
const errorHandlers = [
37-
// async (normalizedErrors) => {
38-
// /** @type ErrorObject[] */
39-
// const errors = [];
40-
// for (const error of normalizedErrors) {
41-
// if (error.keyword === "https://json-schema.org/keyword/anyOf") {
42-
// // const outputArray = applicatorChildErrors(outputUnit.absoluteKeywordLocation, normalizedErrors);
43-
// // const failingTypeErrors = outputArray
44-
// // .filter((err) => err.keyword === "https://json-schema.org/keyword/type")
45-
// // .map((err) => err.instanceLocation);
46-
// // const numberOfAlternatives = /** @type any[] */ (Schema.value(schema)).length;
47-
// errors.push({
48-
// message: `The instance must be a 'string' or 'number'. Found 'boolean'`,
49-
// instanceLocation: error.instanceLocation,
50-
// schemaLocation: error.absoluteKeywordLocation
51-
// });
52-
// }
53-
// }
54-
55-
// return errors;
56-
// },
37+
38+
// `anyOf` handler
39+
async (normalizedErrors, instance, schema) => {
40+
/** @type ErrorObject[] */
41+
const errors = [];
42+
43+
for (const error of normalizedErrors) {
44+
if (error.keyword === "https://json-schema.org/keyword/anyOf") {
45+
const anyOfSchema = await getSchema(error.absoluteKeywordLocation);
46+
const numberOfAlternatives = Schema.length(anyOfSchema);
47+
// const discriminatorKeys = await findDiscriminatorKeywords(anyOfSchema);
48+
const outputArray = applicatorChildErrors(error.absoluteKeywordLocation, normalizedErrors);
49+
50+
const keyword = getKeywordByName("type", schema.document.dialectId);
51+
const matchingKeywordErrors = outputArray.filter((e) => e.keyword === keyword.id);
52+
53+
if (isOnlyOneTypeValid(matchingKeywordErrors, numberOfAlternatives)) {
54+
// all the matchingKeywordErrors are filter out from the outputArray and push in the normalizedErrors array to produce the output.
55+
const remainingErrors = outputArray.filter((err) => {
56+
return !matchingKeywordErrors.some((matchingErr) => {
57+
return matchingErr.absoluteKeywordLocation === err.absoluteKeywordLocation;
58+
});
59+
});
60+
normalizedErrors.push(...remainingErrors);
61+
} else if (matchingKeywordErrors.length === numberOfAlternatives) {
62+
const noMatchFound = await noDiscriminatorKeyMatchError(matchingKeywordErrors, error, instance);
63+
errors.push(noMatchFound);
64+
} else if (false) {
65+
// Discriminator cases
66+
} else if (jsonTypeOf(instance) === "object") {
67+
// Number of matching properties
68+
const selectedAlternative = outputArray.find((error) => {
69+
return error.keyword = "https://json-schema.org/keyword/properties";
70+
})?.absoluteKeywordLocation;
71+
const remainingErrors = outputArray.filter((err) => {
72+
return err.absoluteKeywordLocation.startsWith(/** @type string */ (selectedAlternative));
73+
});
74+
normalizedErrors.push(...remainingErrors);
75+
} else {
76+
// I don't know yet what to do
77+
78+
// {
79+
// "$schema": "https://json-schema.org/draft/2020-12/schema",
80+
// "anyOf": [
81+
// { "required": [ "foo" ] },
82+
// { "required": [ "bar" ] }
83+
// ]
84+
// }
85+
}
86+
}
87+
}
88+
return errors;
89+
},
5790

5891
async (normalizedErrors) => {
5992
/** @type ErrorObject[] */
@@ -393,14 +426,88 @@ const errorHandlers = [
393426
}
394427
];
395428

396-
// /**
397-
// * Groups errors whose absoluteKeywordLocation starts with a given prefix.
398-
// * @param {string} parentKeywordLocation
399-
// * @param {NormalizedError[]} allErrors
400-
// * @returns {NormalizedError[]}
401-
// */
402-
// function applicatorChildErrors(parentKeywordLocation, allErrors) {
403-
// return allErrors.filter((err) =>
404-
// /** @type string */ (err.absoluteKeywordLocation).startsWith(parentKeywordLocation + "/")
405-
// );
406-
// }
429+
/**
430+
* Groups errors whose absoluteKeywordLocation starts with a given prefix.
431+
* @param {string} parentKeywordLocation
432+
* @param {NormalizedError[]} allErrors
433+
* @returns {NormalizedError[]}
434+
*/
435+
function applicatorChildErrors(parentKeywordLocation, allErrors) {
436+
const matching = [];
437+
438+
for (let i = allErrors.length - 1; i >= 0; i--) {
439+
const err = allErrors[i];
440+
if (err.absoluteKeywordLocation.startsWith(parentKeywordLocation + "/")) {
441+
matching.push(err);
442+
allErrors.splice(i, 1);
443+
}
444+
}
445+
446+
return matching;
447+
}
448+
449+
/**
450+
* @param {NormalizedError[]} matchingErrors
451+
* @param {number} numOfAlternatives
452+
* @returns {boolean}
453+
*/
454+
function isOnlyOneTypeValid(matchingErrors, numOfAlternatives) {
455+
const typeErrors = matchingErrors.filter(
456+
(e) => e.keyword === "https://json-schema.org/keyword/type"
457+
);
458+
return numOfAlternatives - typeErrors.length === 1;
459+
}
460+
461+
/**
462+
* @param {NormalizedError[]} matchingErrors
463+
* @param {NormalizedError} parentError
464+
* @param {Json} instance
465+
* @returns {Promise<ErrorObject>}
466+
*/
467+
async function noDiscriminatorKeyMatchError(matchingErrors, parentError, instance) {
468+
const expectedTypes = [];
469+
470+
for (const err of matchingErrors) {
471+
const typeSchema = await getSchema(err.absoluteKeywordLocation);
472+
const typeValue = /** @type any[] */ (Schema.value(typeSchema));
473+
expectedTypes.push(typeValue);
474+
}
475+
476+
const pointer = parentError.instanceLocation.replace(/^#/, "");
477+
const actualValue = /** @type Json */ (Instance.get(pointer, instance));
478+
const actualType = jsonTypeOf(actualValue);
479+
480+
const expectedString = expectedTypes.join(" or ");
481+
482+
return {
483+
message: `The instance must be a ${expectedString}. Found '${actualType}'.`,
484+
instanceLocation: parentError.instanceLocation,
485+
schemaLocation: parentError.absoluteKeywordLocation
486+
};
487+
}
488+
489+
/** @type (value: Json) => "null" | "boolean" | "number" | "string" | "array" | "object" | "undefined" */
490+
const jsonTypeOf = (value) => {
491+
const jsType = typeof value;
492+
493+
switch (jsType) {
494+
case "number":
495+
case "string":
496+
case "boolean":
497+
case "undefined":
498+
return jsType;
499+
case "object":
500+
if (Array.isArray(value)) {
501+
return "array";
502+
} else if (value === null) {
503+
return "null";
504+
} else if (Object.getPrototypeOf(value) === Object.prototype) {
505+
return "object";
506+
}
507+
default: {
508+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
509+
const type = jsType === "object" ? Object.getPrototypeOf(value).constructor.name ?? "anonymous" : jsType;
510+
throw Error(`Not a JSON compatible type: ${type}`);
511+
}
512+
}
513+
};

src/keywordErrorMessage.test.js

Lines changed: 138 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -607,42 +607,142 @@ describe("Error messages", () => {
607607
}]);
608608
});
609609

610-
// test("anyOf where the instance doesn't match type of either of the alternatives", async () => {
611-
// registerSchema({
612-
// $schema: "https://json-schema.org/draft/2020-12/schema",
613-
// anyOf: [
614-
// { type: "string" },
615-
// { type: "number" }
616-
// ]
617-
// }, schemaUri);
618-
// const instance = false;
619-
620-
// /** @type OutputFormat */
621-
// const output = {
622-
// valid: false,
623-
// errors: [
624-
// {
625-
// absoluteKeywordLocation: "https://example.com/main#/anyOf/0/type",
626-
// instanceLocation: "#"
627-
// },
628-
// {
629-
// absoluteKeywordLocation: "https://example.com/main#/anyOf/1/type",
630-
// instanceLocation: "#"
631-
// },
632-
// {
633-
// absoluteKeywordLocation: "https://example.com/main#/anyOf",
634-
// instanceLocation: "#"
635-
// }
636-
// ]
637-
// };
638-
639-
// const result = await betterJsonSchemaErrors(instance, output, schemaUri);
640-
// expect(result.errors).to.eql([
641-
// {
642-
// schemaLocation: "https://example.com/main#/anyOf",
643-
// instanceLocation: "#",
644-
// message: "The instance must be a 'string' or 'number'. Found 'boolean'"
645-
// }
646-
// ]);
647-
// });
610+
test("anyOf where the instance doesn't match type of either of the alternatives", async () => {
611+
registerSchema({
612+
$schema: "https://json-schema.org/draft/2020-12/schema",
613+
anyOf: [
614+
{ type: "string" },
615+
{ type: "number" }
616+
]
617+
}, schemaUri);
618+
const instance = false;
619+
620+
/** @type OutputFormat */
621+
const output = {
622+
valid: false,
623+
errors: [
624+
{
625+
absoluteKeywordLocation: "https://example.com/main#/anyOf/0/type",
626+
instanceLocation: "#"
627+
},
628+
{
629+
absoluteKeywordLocation: "https://example.com/main#/anyOf/1/type",
630+
instanceLocation: "#"
631+
},
632+
{
633+
absoluteKeywordLocation: "https://example.com/main#/anyOf",
634+
instanceLocation: "#"
635+
}
636+
]
637+
};
638+
639+
const result = await betterJsonSchemaErrors(instance, output, schemaUri);
640+
expect(result.errors).to.eql([
641+
{
642+
schemaLocation: "https://example.com/main#/anyOf",
643+
instanceLocation: "#",
644+
message: `The instance must be a number or string. Found 'boolean'.`
645+
}
646+
]);
647+
});
648+
649+
test("anyOf - one type matches, but fails constraint (minLength)", async () => {
650+
registerSchema({
651+
$schema: "https://json-schema.org/draft/2020-12/schema",
652+
anyOf: [
653+
{ type: "string", minLength: 5 },
654+
{ type: "number" }
655+
]
656+
}, schemaUri);
657+
658+
const instance = "abc";
659+
660+
const output = {
661+
valid: false,
662+
errors: [
663+
{
664+
absoluteKeywordLocation: "https://example.com/main#/anyOf/0/minLength",
665+
instanceLocation: "#"
666+
},
667+
{
668+
absoluteKeywordLocation: "https://example.com/main#/anyOf/1/type",
669+
instanceLocation: "#"
670+
},
671+
{
672+
absoluteKeywordLocation: "https://example.com/main#/anyOf",
673+
instanceLocation: "#"
674+
}
675+
]
676+
};
677+
678+
const result = await betterJsonSchemaErrors(instance, output, schemaUri);
679+
expect(result.errors).to.eql([
680+
{
681+
schemaLocation: `https://example.com/main#/anyOf/0/minLength`,
682+
instanceLocation: "#",
683+
message: "The instance should be at least 5 characters"
684+
}
685+
]);
686+
});
687+
688+
test("anyOf - multiple types match, pick based on field overlap", async () => {
689+
registerSchema({
690+
$schema: "https://json-schema.org/draft/2020-12/schema",
691+
anyOf: [
692+
{
693+
type: "object",
694+
properties: {
695+
name: { type: "string" },
696+
age: { type: "number" }
697+
},
698+
required: ["name", "age"]
699+
},
700+
{
701+
type: "object",
702+
properties: {
703+
title: { type: "string" },
704+
author: { type: "string" },
705+
ID: { type: "string", pattern: "^[0-9\\-]+$" }
706+
},
707+
required: ["title", "author", "ID"]
708+
}
709+
]
710+
}, schemaUri);
711+
712+
const instance = {
713+
title: "Clean Code",
714+
author: "Robert Martin",
715+
ID: "NotValidId"
716+
};
717+
718+
const output = {
719+
valid: false,
720+
errors: [
721+
{
722+
absoluteKeywordLocation: "https://example.com/main#/anyOf/1/properties/ID/pattern",
723+
instanceLocation: "#/ID"
724+
},
725+
{
726+
absoluteKeywordLocation: "https://example.com/main#/anyOf/0/required",
727+
instanceLocation: "#"
728+
},
729+
{
730+
absoluteKeywordLocation: "https://example.com/main#/anyOf/1/properties",
731+
instanceLocation: "#"
732+
},
733+
{
734+
absoluteKeywordLocation: "https://example.com/main#/anyOf",
735+
instanceLocation: "#"
736+
}
737+
]
738+
};
739+
const result = await betterJsonSchemaErrors(instance, output, schemaUri);
740+
expect(result.errors).to.eql([
741+
{
742+
schemaLocation: `${schemaUri}#/anyOf/1/properties/ID/pattern`,
743+
instanceLocation: "#/ID",
744+
message: "The instance should match the pattern: ^[0-9\\-]+$."
745+
}
746+
]);
747+
});
648748
});

0 commit comments

Comments
 (0)