diff --git a/src/error-handlers/typeConstEnum.js b/src/error-handlers/typeConstEnum.js index d20aa56..9db89e0 100644 --- a/src/error-handlers/typeConstEnum.js +++ b/src/error-handlers/typeConstEnum.js @@ -7,6 +7,11 @@ import { describeKeyword } from "../json-schema-errors.js"; const ALL_TYPES = new Set(["null", "boolean", "number", "string", "array", "object", "integer"]); +const TYPE_KEYWORDS = [ + "https://json-schema.org/keyword/type", + "https://spec.openapis.org/oas/3.0/keyword/type" +]; + /** @type {ErrorHandler} */ const typeConstEnumErrorHandler = { error: (normalizedErrors, instance, context) => { @@ -14,19 +19,21 @@ const typeConstEnumErrorHandler = { /** @type {string[]} */ const failedTypeLocations = []; - for (const schemaLocation in normalizedErrors["https://json-schema.org/keyword/type"]) { - if (normalizedErrors["https://json-schema.org/keyword/type"][schemaLocation].valid === false) { - failedTypeLocations.push(schemaLocation); - - /** @type {string | string[]} */ - const value = /** @type {string | string[]} */ (normalizedErrors["https://json-schema.org/keyword/type"][schemaLocation].value); - const types = Array.isArray(value) ? value : [value]; - /** @type {Set} */ - const keywordTypes = new Set(types); - if (keywordTypes.has("number")) { - keywordTypes.add("integer"); + for (const typeKeyword of TYPE_KEYWORDS) { + for (const schemaLocation in normalizedErrors[typeKeyword] ?? {}) { + if (normalizedErrors[typeKeyword][schemaLocation].valid === false) { + failedTypeLocations.push(schemaLocation); + + /** @type {string | string[]} */ + const value = /** @type {string | string[]} */ (normalizedErrors[typeKeyword][schemaLocation].value); + const types = Array.isArray(value) ? value : [value]; + /** @type {Set} */ + const keywordTypes = new Set(types); + if (keywordTypes.has("number")) { + keywordTypes.add("integer"); + } + allowedTypes = allowedTypes.intersection(keywordTypes); } - allowedTypes = allowedTypes.intersection(keywordTypes); } } if (allowedTypes.has("number")) { @@ -114,9 +121,9 @@ const typeConstEnumErrorHandler = { success: (normalizedOutput, instance, context) => { return [ - ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/type", instance, (/** @type string | string[] */ type) => { + ...TYPE_KEYWORDS.flatMap((typeKeyword) => describeKeyword(normalizedOutput, typeKeyword, instance, (/** @type string | string[] */ type) => { return context.localization.getTypeSuccessMessage(Array.isArray(type) ? type : [type]); - }), + })), ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/const", instance, (/** @type string */ constJson) => { return context.localization.getEnumSuccessMessage([JSON.parse(constJson)]); }), diff --git a/src/hyperjump-json-schema.test.js b/src/hyperjump-json-schema.test.js index 5a89947..4c01e7b 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, suite?: string) => void */ +const runTests = (dialectUri, dialect, suite = "tests") => { describe(`JSON Schema Errors - ${dialectUri}`, async () => { setShouldValidateFormat(true); @@ -66,7 +69,7 @@ const runTests = (dialectUri, dialect) => { unregisterSchema(schemaUri); }); - for (const entry of await readdir(`${import.meta.dirname}/test-suite/tests`, { withFileTypes: true })) { + for (const entry of await readdir(`${import.meta.dirname}/test-suite/${suite}`, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith(".json")) { continue; } @@ -215,3 +218,9 @@ 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); +runTests("https://spec.openapis.org/oas/3.1/dialect/base", 2020); + +runTests("https://spec.openapis.org/oas/3.2/dialect", 32, "openapi"); +runTests("https://spec.openapis.org/oas/3.1/dialect/base", 31, "openapi"); +runTests("https://spec.openapis.org/oas/3.0/dialect", 30, "openapi"); diff --git a/src/index.js b/src/index.js index d739b61..e1ab429 100644 --- a/src/index.js +++ b/src/index.js @@ -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); @@ -150,6 +153,14 @@ 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 }); +defineKeyword("https://spec.openapis.org/oas/3.0/keyword/type", {}); +defineKeyword("https://spec.openapis.org/oas/3.0/keyword/xml", { annotation: true }); + export { allTrue, countTrue, diff --git a/src/test-suite/README.md b/src/test-suite/README.md index 90fbe86..4d34c46 100644 --- a/src/test-suite/README.md +++ b/src/test-suite/README.md @@ -63,3 +63,12 @@ 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 + +The tests in the `tests` folder are also run against the OpenAPI 3.1 and 3.2 +dialects. The `openapi` folder has tests for the keywords OpenAPI adds and is +only run against the OpenAPI dialects. In those tests, `compatibility` uses +`30`, `31`, and `32` for OpenAPI 3.0, 3.1, and 3.2. + +**Example**: `"compatibility": "=30"` 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..273bc5c --- /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": "=30", + "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": "=30", + "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..5dd9e21 --- /dev/null +++ b/src/test-suite/openapi/type.json @@ -0,0 +1,34 @@ +{ + "$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": [] + } + ] +} 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": [] + } + ] +}