diff --git a/src/hyperjump-json-schema.test.js b/src/hyperjump-json-schema.test.js index 5a89947..de50e4b 100644 --- a/src/hyperjump-json-schema.test.js +++ b/src/hyperjump-json-schema.test.js @@ -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"; @@ -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) => void */ +const runTests = (dialectUri, dialect, suites = ["tests"], skip = {}) => { describe(`JSON Schema Errors - ${dialectUri}`, async () => { setShouldValidateFormat(true); @@ -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); + }); + } } } }); @@ -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 + */ +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); diff --git a/src/index.d.ts b/src/index.d.ts index 621a5ef..da1dd28 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -59,6 +59,15 @@ export type KeywordDefinition> */ 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]; @@ -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); @@ -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 }; diff --git a/src/output-plugin.js b/src/output-plugin.js index 036ac62..83e9e80 100644 --- a/src/output-plugin.js +++ b/src/output-plugin.js @@ -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" @@ -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 }; } diff --git a/src/test-suite/README.md b/src/test-suite/README.md index 90fbe86..6882c08 100644 --- a/src/test-suite/README.md +++ b/src/test-suite/README.md @@ -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"` diff --git a/src/test-suite/openapi/annotations.json b/src/test-suite/openapi/annotations.json new file mode 100644 index 0000000..efe4ab7 --- /dev/null +++ b/src/test-suite/openapi/annotations.json @@ -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": [] + } + ] +} diff --git a/src/test-suite/openapi/nullable.json b/src/test-suite/openapi/nullable.json new file mode 100644 index 0000000..65f20eb --- /dev/null +++ b/src/test-suite/openapi/nullable.json @@ -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": [] + } + ] +} diff --git a/src/test-suite/openapi/type.json b/src/test-suite/openapi/type.json new file mode 100644 index 0000000..ce12cc1 --- /dev/null +++ b/src/test-suite/openapi/type.json @@ -0,0 +1,74 @@ +{ + "$schema": "../test-suite.schema.json", + + "description": "The OpenAPI type keyword", + "tests": [ + { + "description": "OpenAPI type", + "schema": { + "type": "string" + }, + "instance": 5, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "count": 1, + "type": "string", + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#", + "schemaLocations": ["#/type"] + } + ] + }, + { + "description": "OpenAPI type pass", + "schema": { + "type": "string" + }, + "instance": "foo", + "errors": [] + }, + { + "description": "anyOf drops alternatives that don't match the OpenAPI type", + "schema": { + "anyOf": [ + { "type": "string", "minLength": 3 }, + { "type": "number", "minimum": 10 } + ] + }, + "instance": "a", + "errors": [ + { + "messageId": "minLength-message", + "messageParams": { + "minLength": 3 + }, + "instanceLocation": "#", + "schemaLocations": ["#/anyOf/0/minLength"] + } + ] + }, + { + "description": "oneOf drops alternatives that don't match the OpenAPI type", + "schema": { + "oneOf": [ + { "type": "string", "minLength": 3 }, + { "type": "number", "minimum": 10 } + ] + }, + "instance": "a", + "errors": [ + { + "messageId": "minLength-message", + "messageParams": { + "minLength": 3 + }, + "instanceLocation": "#", + "schemaLocations": ["#/oneOf/0/minLength"] + } + ] + } + ] +} diff --git a/src/test-suite/tests/content.json b/src/test-suite/tests/content.json new file mode 100644 index 0000000..c3ba05b --- /dev/null +++ b/src/test-suite/tests/content.json @@ -0,0 +1,61 @@ +{ + "$schema": "../test-suite.schema.json", + + "description": "The content keywords", + "tests": [ + { + "description": "contentMediaType and contentEncoding are annotations", + "compatibility": "7", + "schema": { + "type": "string", + "contentMediaType": "application/json", + "contentEncoding": "base64" + }, + "instance": 5, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "count": 1, + "type": "string", + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#", + "schemaLocations": ["#/type"] + } + ] + }, + { + "description": "contentSchema is an annotation", + "compatibility": "2019", + "schema": { + "type": "string", + "contentMediaType": "application/json", + "contentSchema": { "type": "object" } + }, + "instance": 5, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "count": 1, + "type": "string", + "expectedTypes": { "or": ["string"] } + }, + "instanceLocation": "#", + "schemaLocations": ["#/type"] + } + ] + }, + { + "description": "a schema with only content keywords allows any value", + "compatibility": "7", + "schema": { + "contentMediaType": "application/json", + "contentEncoding": "base64" + }, + "instance": 5, + "errors": [] + } + ] +}