Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 150 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ validation. Importing this package adds a `JSE` output format to
`@hyperjump/json-schema`. It uses the full results of evaluation rather than
only what the standard output formats report. Use the `locale` option to choose
the language of the messages. The default is `en-US`, which is currently the
only locale available.
only locale included. See [Messages and Translations](#messages-and-translations)
to add a locale.

```TypeScript
import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
Expand Down Expand Up @@ -129,83 +130,181 @@ applies if formats are validated. The `JSE` output format works this out from
const errors = await jsonSchemaErrors(output, schemaUri, instance, { isFormatAsserted: true });
```

## API
## Messages and Translations

https://json-schema-errors.hyperjump.io
Messages are written in [Fluent](https://projectfluent.org/). Use
`addTranslation` to add a locale or to change messages. A message with the
same id as an existing message replaces it, and messages that aren't translated
for a locale fall back to `en-US`. See
[`src/translations/en-US.js`](src/translations/en-US.js) for the message ids
and the variables each message gets.

## Custom Keywords and Error Handlers
```TypeScript
import { addTranslation } from "@hyperjump/json-schema-errors";

// Change a message
addTranslation("en-US", `
required-message = Missing {$count ->
[one] the required property {$required}
*[other] the required properties {$required}
}
`);

// Add a locale. Use the direction option for right-to-left languages.
addTranslation("fr-FR", `
type-message = Une valeur de type {$expectedTypes} est attendue
`);
```

`@hyperjump/json-schema-errors` uses a two phase process. In order to support a
custom keyword we'll need to register a handler for each phase of the process.
## API

1. **Normalization**: This phase takes the raw error output from the validator
and converts it to a `NormalizedOutput`.
https://json-schema-errors.hyperjump.io

2. **Error Handling**: This phase takes the `NormalizedOutput` and uses it to
generate the error messages that will be presented to the user.
## Custom Keywords

Here's an example of adding support for a simple keyword called `startsWith`.
`startsWith` takes a string and asserts that a string JSON instance starts with
the value of `startsWith`.
A custom keyword needs to be defined with `@hyperjump/json-schema`'s
`addKeyword` and included in a dialect. Then use `defineKeyword` to add messages
for it. Here's an example for a keyword called `startsWith` that asserts that a
string starts with the keyword's value.

Messages for a keyword use the ids `{keyword}-message` for errors and
`{keyword}-success-message` and `{keyword}-negated-message` for describing what
the keyword requires.

```TypeScript
import { setNormalizationHandler, addErrorHandler } from "@hyperjump/json-schema-errors";
import { getSchema } from "@hyperjump/json-schema/experimental";
import * as Schema from "@hyperjump/browser";
import * as Instance from "@hyperjump/json-schema/instance/experimental";
import type { ErrorObject } from "@hyperjump/json-schema-errors";
import { addTranslation, defineKeyword } from "@hyperjump/json-schema-errors";

const KEYWORD_URI = "https://example.com/keyword/startsWith";
addTranslation("en-US", `
startsWith-message = Expected a string that starts with '{$prefix}'
startsWith-success-message = The value is either not a string or starts with '{$prefix}'
startsWith-negated-message = The value is a string that doesn't start with '{$prefix}'
`);

setNormalizationHandler(KEYWORD_URI, {
evaluate() {
// Only applicator keywords need to return a value
}
defineKeyword<string>("https://example.com/keyword/startsWith", {
error: (prefix, localization) => localization.format("startsWith-message", { prefix }),
requirement: (prefix, localization) => localization.formatRequirement("startsWith", { prefix })
});
```

addErrorHandler(async (normalizedErrors, instance, localization) => {
const errors: ErrorObject = [];
`error` is the message for each occurrence of the keyword that failed. It gets
the keyword's value as compiled by its `@hyperjump/json-schema` definition and
the value that failed.

for (const schemaLocation in normalizedErrors[KEYWORD_URI]) {
if (normalizedErrors[KEYWORD_URI][schemaLocation]) {
continue;
}
`requirement` describes what the keyword requires. It's used to explain failures
caused by a subschema passing, such as with `not`. Then, it describes what would
make the keyword fail instead, so `formatRequirement` picks the success or
negated message. A keyword without a `requirement` can't be described, so
messages for keywords like `not` and `oneOf` will be less specific.

const keyword = await getSchema(schemaLocation);
const startsWith = Schema.value(keyword) as string;
Keywords that are only annotations, like `title`, never fail and don't require
anything.

errors.push({
message: "Expected a string that starts with '${startsWith}'",
instanceLocation: Instance.uri(instance),
schemaLocations: [schemaLocation]
});
}

return errors;
});
```TypeScript
defineKeyword("https://example.com/keyword/note", { annotation: true });
```

Simple applicator keywords that just evaluate subschemas and don't make any
assertions of their own don't need an error handler, only a normalization
handler. Whether a keyword is a simple applicator comes from the
`simpleApplicator` property of its `@hyperjump/json-schema` keyword definition.
The results of its subschemas are merged into the results of the parent schema.
For example, support for the `allOf` keyword could look like the following.
assertions of their own only need to evaluate their subschemas. Whether a
keyword is a simple applicator comes from the `simpleApplicator` property of its
`@hyperjump/json-schema` keyword definition. The results of its subschemas are
treated as results of the parent schema. For example, support for the `allOf`
keyword could look like the following.

```TypeScript
import { setNormalizationHandler, evaluateSchema } from "@hyperjump/json-schema-errors";

const KEYWORD_URI = "https://json-schema.org/keyword/allOf";
import { defineKeyword, evaluateSchema } from "@hyperjump/json-schema-errors";

setNormalizationHandler(KEYWORD_URI, {
defineKeyword<string[]>("https://json-schema.org/keyword/allOf", {
evaluate(allOf, instance, context) {
return allOf.map((schemaLocation) => evaluateSchema(schemaLocation, instance, context));
}
});
```

See the `anyOf` or `oneOf` normalization and error handlers for an example of
implementing an applicator that also asserts.
### Error Handlers

Some keywords need more control over their messages, such as a keyword that
combines its occurrences into one message, keywords that are described together,
or an applicator that makes assertions of their own. These keywords are still
defined with `defineKeyword`, but without `error` or `requirement`. Their
messages come from an error handler instead.

An error handler, set with `setErrorHandler`, builds messages from the
`NormalizedOutput` of a schema. A handler can handle any number of keywords, so
it gets the results of every keyword that applies to a location in the instance
and picks out the ones it handles. A result's `valid` is `false` if the keyword
failed, `true` if it passed, and `undefined` if the result isn't known. Its
`value` is the keyword's compiled value. `error` describes keywords that failed
and `success` describes what keywords require. In a negated context, `success`
describes what would make the keywords fail instead.

The `startsWith` keyword from the previous section could also be written with an
error handler.

```TypeScript
import * as Instance from "@hyperjump/json-schema/instance/experimental";
import { defineKeyword, setErrorHandler } from "@hyperjump/json-schema-errors";
import type { ErrorObject } from "@hyperjump/json-schema-errors";

const KEYWORD_URI = "https://example.com/keyword/startsWith";

defineKeyword(KEYWORD_URI, {});

setErrorHandler("https://example.com/error-handler/startsWith", {
error: (normalizedErrors, instance, context) => {
const errors: ErrorObject[] = [];

for (const schemaLocation in normalizedErrors[KEYWORD_URI]) {
const { valid, value } = normalizedErrors[KEYWORD_URI][schemaLocation];
if (valid !== false) {
continue;
}

const prefix = value as string;
errors.push({
message: context.localization.format("startsWith-message", { prefix }),
instanceLocation: Instance.uri(instance),
schemaLocations: [schemaLocation]
});
}

return errors;
},

success: (normalizedOutput, instance, context) => {
const successes: ErrorObject[] = [];

for (const schemaLocation in normalizedOutput[KEYWORD_URI]) {
const prefix = normalizedOutput[KEYWORD_URI][schemaLocation].value as string;
successes.push({
message: context.localization.formatRequirement("startsWith", { prefix }),
instanceLocation: Instance.uri(instance),
schemaLocations: [schemaLocation]
});
}

return successes;
}
});
```

Applicators that make assertions of their own, like `not` or `anyOf`, need an
error handler that describes their subschemas. A keyword result's `outputs` has
the normalized output of each subschema. These functions help describe them.

- `getErrors` and `getSuccesses` describe a subschema's errors or what it
requires. `getSuccesses` can describe a subschema the validator didn't
evaluate from its schema location.
- `negate` gives a context that describes what would make a subschema fail
instead of what it requires.
- `getValidity` says whether a subschema passed, failed, or if it isn't known.
- `someTrue`, `allTrue`, and `countTrue` group descriptions of subschemas, such
as when at least one of them needs to be true.
- `getPlaceholder`, `isPlaceholder`, `describeConditional`, and `describeScope`
describe values that aren't present, such as a property that would only need
to match a subschema if it were present.

Long lists of messages are shortened automatically. See the `not`, `anyOf`, and
`properties` error handlers for examples.

## Examples

Expand Down
130 changes: 130 additions & 0 deletions src/define-keyword.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { beforeAll, describe, expect, test } from "vitest";
import { addKeyword, defineVocabulary } from "@hyperjump/json-schema/experimental";
import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
import * as Instance from "@hyperjump/json-schema/instance/experimental";
import * as Browser from "@hyperjump/browser";
import { addTranslation, defineKeyword, JSE } from "./index.js";

/**
* @import { ErrorObject } from "./index.js"
*/

const dialectUri = "https://example.com/define-keyword/dialect";
const startsWithUri = "https://example.com/keyword/startsWith";
const noteUri = "https://example.com/keyword/note";
const neverUri = "https://example.com/keyword/never";

/** @type (errors: ErrorObject[]) => unknown[] */
const messages = (errors) => errors.map(({ message, alternatives }) => {
return alternatives ? [message, alternatives.map(messages)] : message;
});

/** @type (schema: Record<string, unknown>, instance: unknown) => Promise<unknown[]> */
const getMessages = async (schema, instance) => {
const schemaUri = `https://example.com/define-keyword/${crypto.randomUUID()}`;
registerSchema({ $schema: dialectUri, ...schema }, schemaUri);
const output = await validate(schemaUri, /** @type any */ (instance), JSE);
return output.valid ? [] : messages(output.errors);
};

describe("defineKeyword", () => {
beforeAll(() => {
addKeyword({
id: startsWithUri,
compile: async (schema) => /** @type string */ (Browser.value(schema)),
interpret: (prefix, instance) => Instance.typeOf(instance) !== "string"
|| /** @type string */ (Instance.value(instance)).startsWith(prefix)
});
addKeyword({
id: noteUri,
compile: async (schema) => Browser.value(schema),
interpret: () => true
});
addKeyword({
id: neverUri,
compile: async (schema) => Browser.value(schema),
interpret: () => false
});
defineVocabulary("https://example.com/define-keyword/vocab", {
startsWith: startsWithUri,
note: noteUri,
never: neverUri
});
registerSchema({
$id: dialectUri,
$schema: "https://json-schema.org/draft/2020-12/schema",
$vocabulary: {
"https://json-schema.org/draft/2020-12/vocab/core": true,
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
"https://json-schema.org/draft/2020-12/vocab/validation": true,
"https://example.com/define-keyword/vocab": true
},
$dynamicAnchor: "meta",
$ref: "https://json-schema.org/draft/2020-12/schema"
});

addTranslation("en-US", `
fx-startsWith-message = Expected a string that starts with '{$prefix}'
fx-startsWith-success-message = The value is either not a string or starts with '{$prefix}'
fx-startsWith-negated-message = The value is a string that doesn't start with '{$prefix}'
`);

defineKeyword(startsWithUri, {
error: (/** @type string */ prefix, localization) => localization.format("fx-startsWith-message", { prefix }),
requirement: (/** @type string */ prefix, localization) => localization.formatRequirement("fx-startsWith", { prefix })
});

defineKeyword(noteUri, { annotation: true });
});

test("error", async () => {
expect(await getMessages({ startsWith: "foo" }, "bar")).to.eql([
"Expected a string that starts with 'foo'"
]);
});

test("passing", async () => {
expect(await getMessages({ startsWith: "foo" }, "foobar")).to.eql([]);
});

test("each occurrence that fails gets a message", async () => {
expect(await getMessages({ allOf: [{ startsWith: "foo" }, { startsWith: "ba" }, { startsWith: "baz" }] }, "bar")).to.eql([
"Expected a string that starts with 'foo'",
"Expected a string that starts with 'baz'"
]);
});

test("negated requirement", async () => {
expect(await getMessages({ not: { startsWith: "foo" } }, "foobar")).to.eql([
["Expected the following to be true", [["The value is a string that doesn't start with 'foo'"]]]
]);
});

test("requirement", async () => {
expect(await getMessages({ not: { not: { startsWith: "foo" } } }, "bar")).to.eql([
["Expected the following to be true", [["The value is either not a string or starts with 'foo'"]]]
]);
});

test("annotation", async () => {
// A schema with only annotations allows any value
expect(await getMessages({ oneOf: [{ note: "anything" }, { type: "string" }] }, "foo")).to.eql([
["Expected the value to satisfy only one of the following options", [
["Any value is allowed"],
["The value is a string"]
]]
]);
});

test("redefining without messages removes them", async () => {
defineKeyword(neverUri, {
error: () => "Never passes"
});
expect(await getMessages({ never: true }, 42)).to.eql([
"Never passes"
]);

defineKeyword(neverUri, {});
expect(await getMessages({ never: true }, 42)).to.eql([]);
});
});
Loading
Loading