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
144 changes: 120 additions & 24 deletions src/hyperjump-json-schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import "@hyperjump/json-schema/draft-07";
import "@hyperjump/json-schema/draft-06";
import "@hyperjump/json-schema/draft-04";
import "@hyperjump/json-schema/formats";
import "@hyperjump/json-schema/openapi-3-0";
import "@hyperjump/json-schema/openapi-3-1";
import "@hyperjump/json-schema/openapi-3-2";
import { BASIC } from "@hyperjump/json-schema/experimental";
import { JSE, jsonSchemaErrors } from "../src/index.js";
import { FluentBundle, FluentResource } from "@fluent/bundle";
Expand Down Expand Up @@ -55,8 +58,8 @@ import { translations } from "./translations/index.js";
* }} TestSuite
*/

/** @type (dialectUri: string, dialect: number) => void */
const runTests = (dialectUri, dialect) => {
/** @type (dialectUri: string, dialect: number, suites?: string[], skip?: Record<string, string[]>) => void */
const runTests = (dialectUri, dialect, suites = ["tests"], skip = {}) => {
describe(`JSON Schema Errors - ${dialectUri}`, async () => {
setShouldValidateFormat(true);

Expand All @@ -66,33 +69,36 @@ const runTests = (dialectUri, dialect) => {
unregisterSchema(schemaUri);
});

for (const entry of await readdir(`${import.meta.dirname}/test-suite/tests`, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}

const file = `${entry.parentPath}/${entry.name}`;
const suite = /** @type TestSuite */ (JSON.parse(await readFile(file, "utf8")));

for (const testCase of suite.tests) {
if (!isCompatible(testCase.compatibility, dialect)) {
for (const suite of suites) {
for (const entry of await readdir(`${import.meta.dirname}/test-suite/${suite}`, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
test(testCase.description, async () => {
registerSchema(testCase.schema, schemaUri, dialectUri);

const instance = testCase.instance;
const output = await validate(schemaUri, instance, BASIC);
const errors = await jsonSchemaErrors(output, schemaUri, instance, { isFormatAsserted: true });
const file = `${entry.parentPath}/${entry.name}`;
const testSuite = /** @type TestSuite */ (JSON.parse(await readFile(file, "utf8")));

expect(errors).to.eql(buildErrors(testCase.errors, schemaUri));
expectNestedLocations(errors);
for (const testCase of testSuite.tests) {
if (!isCompatible(testCase.compatibility, dialect)) {
continue;
}
const testOrSkip = skip[`${suite}/${entry.name}`]?.includes(testCase.description) ? test.skip : test;
testOrSkip(testCase.description, async () => {
registerSchema(testCase.schema, schemaUri, dialectUri);

const result = await validate(schemaUri, instance, JSE);
const fullResultsErrors = result.valid ? [] : result.errors;
expect(fullResultsErrors).to.eql(buildErrors(testCase.errorsWithFullResults ?? testCase.errors, schemaUri));
expectNestedLocations(fullResultsErrors);
});
const instance = testCase.instance;
const output = await validate(schemaUri, instance, BASIC);
const errors = await jsonSchemaErrors(output, schemaUri, instance, { isFormatAsserted: true });

expect(errors).to.eql(buildErrors(testCase.errors, schemaUri));
expectNestedLocations(errors);

const result = await validate(schemaUri, instance, JSE);
const fullResultsErrors = result.valid ? [] : result.errors;
expect(fullResultsErrors).to.eql(buildErrors(testCase.errorsWithFullResults ?? testCase.errors, schemaUri));
expectNestedLocations(fullResultsErrors);
});
}
}
}
});
Expand Down Expand Up @@ -210,8 +216,98 @@ const getMessage = (function () {
};
}());

/**
* OpenAPI 3.0 is draft-04 without some keywords, such as `patternProperties`,
* `dependencies`, `additionalItems`, and `$comment`. It also doesn't allow
* unknown keywords, `$defs`, array-form `items`, or an array `type`. These
* tests have schemas that aren't valid OpenAPI 3.0.
*
* @type Record<string, string[]>
*/
const oas30Skip = {
"tests/additionalProperties.json": [
"additionalProperties with false schema",
"additionalProperties with object schema",
"additionalProperties on a non-object",
"additionalProperties pass"
],
"tests/anyOf.json": [
"anyOf with $ref alternatives filters by type"
],
"tests/comment.json": [
"format"
],
"tests/contains.json": [
"contains passing"
],
"tests/dependencies.json": [
"array-form dependencies",
"array-form dependencies pass",
"schema-form dependencies",
"schema-form dependencies on a non-object",
"dependentSchemas pass",
"schem-form dependencies pass",
"schema-form and array-form dependencies together"
],
"tests/enum.json": [
"enum with integer and string types filters fractions",
"enum with integer and string types passes for strings"
],
"tests/items.json": [
"items/additionalItems with false schema",
"items/additionalItems with object schema",
"additionalItems on a non-array",
"additionalItems pass"
],
"tests/not.json": [
"not with multiple types",
"not with dependencies",
"not with schema dependencies",
"not with an unknown keyword",
"not with schema dependencies when the property is absent",
"not with array-form items that aren't present",
"not with additionalItems",
"not with patternProperties that aren't present",
"not with descriptions nested too deeply to show"
],
"tests/oneOf.json": [
"oneOf with $ref alternatives filters by type",
"oneOf more than one match with array-form items that aren't present",
"oneOf more than one match with patternProperties that aren't present"
],
"tests/patternProperties.json": [
"patternProperties",
"patternProperties on a non-object",
"patternProperties pass"
],
"tests/prefixItems.json": [
"array-form items",
"array-form items with fewer items than are defined",
"array-form items on an non-array",
"prefixItems pass",
"array-form items pass"
],
"tests/propertyNames.json": [
"propertyNames pass"
],
"tests/ref.json": [
"$ref",
"$ref pass"
],
"tests/type.json": [
"multiple types",
"collapsed type errors"
],
"tests/unknown.json": [
"unknown keyword"
]
};

runTests("https://json-schema.org/draft/2020-12/schema", 2020);
runTests("https://json-schema.org/draft/2019-09/schema", 2019);
runTests("http://json-schema.org/draft-07/schema", 7);
runTests("http://json-schema.org/draft-06/schema", 6);
runTests("http://json-schema.org/draft-04/schema", 4);
runTests("https://spec.openapis.org/oas/3.2/dialect", 2020, ["tests", "openapi"]);
runTests("https://spec.openapis.org/oas/3.1/dialect/base", 2020, ["tests", "openapi"]);
runTests("https://spec.openapis.org/oas/3.0/dialect", 4, ["tests", "openapi"], oas30Skip);
9 changes: 9 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export type KeywordDefinition<Value = unknown, Context extends EvaluationContext
*/
annotation?: true;

/**
* The URI of a keyword that means the same thing. The keyword's results are
* normalized under that URI, so everything that handles that keyword handles
* this one too. The compiled values must have the same shape. For example,
* the OpenAPI 3.0 `type` keyword is an alias of the JSON Schema `type`
* keyword.
*/
alias?: string;

/**
* The message for an occurrence of the keyword that failed.
*
Expand Down
12 changes: 12 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ defineKeyword("https://json-schema.org/keyword/comment", { annotation: true });
defineKeyword("https://json-schema.org/keyword/const", {});
defineKeyword("https://json-schema.org/keyword/contains", containsKeyword);
defineKeyword("https://json-schema.org/keyword/draft-06/contains", containsDraft06Keyword);
defineKeyword("https://json-schema.org/keyword/contentEncoding", { annotation: true });
defineKeyword("https://json-schema.org/keyword/contentMediaType", { annotation: true });
defineKeyword("https://json-schema.org/keyword/contentSchema", { annotation: true });
defineKeyword("https://json-schema.org/keyword/default", { annotation: true });
defineKeyword("https://json-schema.org/keyword/definitions", { annotation: true });
defineKeyword("https://json-schema.org/keyword/draft-04/dependencies", dependenciesKeyword);
Expand Down Expand Up @@ -150,6 +153,15 @@ defineKeyword("https://json-schema.org/keyword/uniqueItems", {});
defineKeyword("https://json-schema.org/keyword/unknown", { annotation: true });
defineKeyword("https://json-schema.org/keyword/writeOnly", { annotation: true });

// OpenAPI keywords
defineKeyword("https://spec.openapis.org/oas/3.0/keyword/discriminator", { annotation: true });
defineKeyword("https://spec.openapis.org/oas/3.0/keyword/example", { annotation: true });
defineKeyword("https://spec.openapis.org/oas/3.0/keyword/externalDocs", { annotation: true });
defineKeyword("https://spec.openapis.org/oas/3.0/keyword/nullable", { annotation: true });
// Compiles `nullable` into its value, so it has the same shape as `type`
defineKeyword("https://spec.openapis.org/oas/3.0/keyword/type", { alias: "https://json-schema.org/keyword/type" });
defineKeyword("https://spec.openapis.org/oas/3.0/keyword/xml", { annotation: true });

export {
allTrue,
countTrue,
Expand Down
16 changes: 14 additions & 2 deletions src/json-schema-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export const jsonSchemaErrors = async (errorOutput, schemaUri, instance, options
/** @type Record<string, API.KeywordDefinition<any>> */
const keywordDefinitions = {};

/**
* The URI a keyword's results are normalized under. An alias's results are
* normalized under the keyword it's an alias of.
*
* @type (keywordUri: string) => string
*/
export const toOutputKeywordUri = (keywordUri) => {
const normalizedKeywordUri = toAbsoluteIri(keywordUri);
return keywordDefinitions[normalizedKeywordUri]?.alias ?? normalizedKeywordUri;
};

/** @type (schemaLocation: string, ast: AST) => boolean */
export const allowsAnyValue = (schemaLocation, ast) => {
const schemaNode = ast[schemaLocation];
Expand Down Expand Up @@ -136,6 +147,7 @@ export const evaluateSchema = (schemaLocation, instance, context) => {
throw Error(`Encountered unsupported keyword ${keywordUri}. Use the 'defineKeyword' function to add support for this keyword.`);
}
const keyword = keywordDefinitions[normalizedKeywordUri];
const outputKeywordUri = toOutputKeywordUri(keywordUri);

const validationKeyword = getKeyword(keywordUri);

Expand Down Expand Up @@ -168,8 +180,8 @@ export const evaluateSchema = (schemaLocation, instance, context) => {
}

output[instanceLocation] ??= {};
output[instanceLocation][normalizedKeywordUri] ??= {};
output[instanceLocation][normalizedKeywordUri][keywordLocation] = keywordOutput
output[instanceLocation][outputKeywordUri] ??= {};
output[instanceLocation][outputKeywordUri][keywordLocation] = keywordOutput
? { valid: isKeywordValid, value: keywordValue, outputs: keywordOutput }
: { valid: isKeywordValid, value: keywordValue };

Expand Down
6 changes: 4 additions & 2 deletions src/output-plugin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Instance from "@hyperjump/json-schema/instance/experimental";
import { toOutputKeywordUri } from "./json-schema-errors.js";

/**
* @import { EvaluationPlugin, Node, ValidationContext } from "@hyperjump/json-schema/experimental"
Expand Down Expand Up @@ -86,8 +87,9 @@ const toNormalizedOutput = ({ url, instance, valid, isBoolean, keywords }) => {
} else if (keywords.length > 0) {
const instanceOutput = output[Instance.uri(instance)] = Object.create(null);
for (const { node: [keywordUri, schemaLocation, value], valid, subschemas } of keywords) {
instanceOutput[keywordUri] ??= Object.create(null);
instanceOutput[keywordUri][schemaLocation] = subschemas
const outputKeywordUri = toOutputKeywordUri(keywordUri);
instanceOutput[outputKeywordUri] ??= Object.create(null);
instanceOutput[outputKeywordUri][schemaLocation] = subschemas
? { valid, value, outputs: subschemas.map(toNormalizedOutput) }
: { valid, value };
}
Expand Down
12 changes: 12 additions & 0 deletions src/test-suite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,15 @@ Some of the message parameters take a list combined using "or". For example,
parameters, you would use `{ "or": ["string", "number", "boolean"]}`. The test
runner should combine those using an "or" appropriate for the language under
test.

## OpenAPI

OpenAPI dialects use the number of the JSON Schema release they're based on:
`4` for OpenAPI 3.0 and `2020` for OpenAPI 3.1 and 3.2. They run the tests in
the `tests` folder and the tests in the `openapi` folder, which has tests for
the keywords OpenAPI adds. The `openapi` folder isn't run for JSON Schema
dialects, so in those tests, `"=4"` means OpenAPI 3.0. OpenAPI 3.0 doesn't have
every draft-04 keyword, so the test runner skips tests with schemas that aren't
valid OpenAPI 3.0.

**Example**: `"compatibility": "=4"`
41 changes: 41 additions & 0 deletions src/test-suite/openapi/annotations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"$schema": "../test-suite.schema.json",

"description": "The OpenAPI annotation keywords",
"tests": [
{
"description": "OpenAPI annotation keywords are ignored",
"schema": {
"type": "string",
"example": "an example",
"externalDocs": { "url": "https://example.com" },
"xml": { "name": "value" },
"discriminator": { "propertyName": "value" }
},
"instance": 5,
"errors": [
{
"messageId": "type-message",
"messageParams": {
"count": 1,
"type": "string",
"expectedTypes": { "or": ["string"] }
},
"instanceLocation": "#",
"schemaLocations": ["#/type"]
}
]
},
{
"description": "a schema with only OpenAPI annotation keywords allows any value",
"schema": {
"example": "an example",
"externalDocs": { "url": "https://example.com" },
"xml": { "name": "value" },
"discriminator": { "propertyName": "value" }
},
"instance": 5,
"errors": []
}
]
}
38 changes: 38 additions & 0 deletions src/test-suite/openapi/nullable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"$schema": "../test-suite.schema.json",

"description": "The OpenAPI nullable keyword",
"tests": [
{
"description": "nullable expands the allowed types",
"compatibility": "=4",
"schema": {
"type": "string",
"nullable": true
},
"instance": 5,
"errors": [
{
"messageId": "type-message",
"messageParams": {
"count": 2,
"type": "null",
"expectedTypes": { "or": ["null", "string"] }
},
"instanceLocation": "#",
"schemaLocations": ["#/type"]
}
]
},
{
"description": "nullable allows null",
"compatibility": "=4",
"schema": {
"type": "string",
"nullable": true
},
"instance": null,
"errors": []
}
]
}
Loading
Loading