From 6b0b40f764892c69854f2c9305630325f35e9851 Mon Sep 17 00:00:00 2001 From: MohitSAGAR11 Date: Mon, 19 Oct 2026 06:57:18 +0530 Subject: [PATCH 1/6] fix: register content keywords as annotations The contentMediaType, contentEncoding, and contentSchema keywords are annotation-only, but they weren't registered, so any schema using them threw 'Encountered unsupported keyword'. Register them as annotations like the other annotation keywords. Closes #294 --- src/index.js | 3 ++ src/test-suite/tests/content.json | 61 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/test-suite/tests/content.json diff --git a/src/index.js b/src/index.js index d739b61..870112a 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); 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": [] + } + ] +} From 92a7fe5f7bdb879c7761e5b5f9557d3999e74da2 Mon Sep 17 00:00:00 2001 From: MohitSAGAR11 Date: Mon, 19 Oct 2026 07:04:27 +0530 Subject: [PATCH 2/6] fix: support OpenAPI keywords The OpenAPI dialects use their own keyword URIs, so any schema using them threw 'Encountered unsupported keyword'. Register the annotation-only keywords (discriminator, example, externalDocs, nullable, xml) and teach the type/const/enum error handler about the OpenAPI 3.0 type keyword so it produces the same messages as the standard type keyword. --- src/error-handlers/typeConstEnum.js | 35 +++++---- src/index.js | 8 ++ src/openapi.test.js | 111 ++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 src/openapi.test.js 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/index.js b/src/index.js index 870112a..e1ab429 100644 --- a/src/index.js +++ b/src/index.js @@ -153,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/openapi.test.js b/src/openapi.test.js new file mode 100644 index 0000000..572ee31 --- /dev/null +++ b/src/openapi.test.js @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; +import { registerSchema as registerOpenApi30, validate as validateOpenApi30 } from "@hyperjump/json-schema/openapi-3-0"; +import { registerSchema as registerOpenApi31, validate as validateOpenApi31 } from "@hyperjump/json-schema/openapi-3-1"; +import { registerSchema as registerOpenApi32, validate as validateOpenApi32 } from "@hyperjump/json-schema/openapi-3-2"; +import { BASIC } from "@hyperjump/json-schema/experimental"; +import { jsonSchemaErrors } from "./index.js"; + +/** + * @import { Json, ErrorObject } from "./index.js" + * @import { SchemaObject } from "@hyperjump/json-schema" + */ + +const dialect30 = "https://spec.openapis.org/oas/3.0/dialect"; +const dialect31 = "https://spec.openapis.org/oas/3.1/dialect/base"; +const dialect32 = "https://spec.openapis.org/oas/3.2/dialect"; + +/** @type (errors: ErrorObject[]) => string[] */ +const messages = (errors) => errors.map(({ message }) => message); + +/** @type (errors: ErrorObject[], schemaUri: string) => string[] */ +const relativeLocations = (errors, schemaUri) => errors + .flatMap(({ schemaLocations }) => schemaLocations) + .map((schemaLocation) => schemaLocation.replace(schemaUri, "")); + +/** @type (register: (schema: any, uri: string, dialect: string) => void, validate: (uri: string, instance: Json, format: typeof BASIC) => Promise, dialectUri: string, schema: SchemaObject, instance: Json) => Promise<{ schemaUri: string; errors: ErrorObject[] }> */ +const getErrors = async (register, validate, dialectUri, schema, instance) => { + const schemaUri = `https://example.com/openapi/${crypto.randomUUID()}`; + register(schema, schemaUri, dialectUri); + const output = await validate(schemaUri, instance, BASIC); + return { schemaUri, errors: await jsonSchemaErrors(output, schemaUri, instance) }; +}; + +const annotationKeywords = { + example: "an example", + externalDocs: { url: "https://example.com" }, + xml: { name: "value" }, + discriminator: { propertyName: "value" } +}; + +describe("OpenAPI 3.0", () => { + test("type is asserted", async () => { + const { schemaUri, errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { + type: "string" + }, 5); + + expect(messages(errors)).to.eql(["Expected a string"]); + expect(relativeLocations(errors, schemaUri)).to.eql(["#/type"]); + }); + + test("null is allowed when nullable", async () => { + const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { + type: "string", + nullable: true + }, null); + + expect(errors).to.eql([]); + }); + + test("nullable expands the allowed types", async () => { + const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { + type: "string", + nullable: true + }, 5); + + expect(messages(errors)).to.eql(["Expected a value of type null or string"]); + }); + + test("annotation keywords are ignored", async () => { + const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { + type: "string", + ...annotationKeywords + }, 5); + + expect(messages(errors)).to.eql(["Expected a string"]); + }); + + test("annotations allow any value when used alone", async () => { + const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { + example: "an example", + externalDocs: { url: "https://example.com" }, + xml: { name: "value" }, + discriminator: { propertyName: "value" } + }, 5); + + expect(errors).to.eql([]); + }); +}); + +describe("OpenAPI 3.1", () => { + test("type is asserted and annotation keywords are ignored", async () => { + const { schemaUri, errors } = await getErrors(registerOpenApi31, validateOpenApi31, dialect31, { + type: "string", + ...annotationKeywords + }, 5); + + expect(messages(errors)).to.eql(["Expected a string"]); + expect(relativeLocations(errors, schemaUri)).to.eql(["#/type"]); + }); +}); + +describe("OpenAPI 3.2", () => { + test("type is asserted and annotation keywords are ignored", async () => { + const { schemaUri, errors } = await getErrors(registerOpenApi32, validateOpenApi32, dialect32, { + type: "string", + ...annotationKeywords + }, 5); + + expect(messages(errors)).to.eql(["Expected a string"]); + expect(relativeLocations(errors, schemaUri)).to.eql(["#/type"]); + }); +}); From aedf6c5ea8266b224b80f75e133e121b7dd64708 Mon Sep 17 00:00:00 2001 From: MohitSAGAR11 Date: Thu, 22 Oct 2026 08:16:45 +0530 Subject: [PATCH 3/6] test: move the OpenAPI tests to the JSON test suite --- src/hyperjump-json-schema.test.js | 15 +++- src/openapi.test.js | 111 ------------------------ src/test-suite/README.md | 9 ++ src/test-suite/openapi/annotations.json | 41 +++++++++ src/test-suite/openapi/nullable.json | 38 ++++++++ src/test-suite/openapi/type.json | 34 ++++++++ 6 files changed, 134 insertions(+), 114 deletions(-) delete mode 100644 src/openapi.test.js create mode 100644 src/test-suite/openapi/annotations.json create mode 100644 src/test-suite/openapi/nullable.json create mode 100644 src/test-suite/openapi/type.json 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/openapi.test.js b/src/openapi.test.js deleted file mode 100644 index 572ee31..0000000 --- a/src/openapi.test.js +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { registerSchema as registerOpenApi30, validate as validateOpenApi30 } from "@hyperjump/json-schema/openapi-3-0"; -import { registerSchema as registerOpenApi31, validate as validateOpenApi31 } from "@hyperjump/json-schema/openapi-3-1"; -import { registerSchema as registerOpenApi32, validate as validateOpenApi32 } from "@hyperjump/json-schema/openapi-3-2"; -import { BASIC } from "@hyperjump/json-schema/experimental"; -import { jsonSchemaErrors } from "./index.js"; - -/** - * @import { Json, ErrorObject } from "./index.js" - * @import { SchemaObject } from "@hyperjump/json-schema" - */ - -const dialect30 = "https://spec.openapis.org/oas/3.0/dialect"; -const dialect31 = "https://spec.openapis.org/oas/3.1/dialect/base"; -const dialect32 = "https://spec.openapis.org/oas/3.2/dialect"; - -/** @type (errors: ErrorObject[]) => string[] */ -const messages = (errors) => errors.map(({ message }) => message); - -/** @type (errors: ErrorObject[], schemaUri: string) => string[] */ -const relativeLocations = (errors, schemaUri) => errors - .flatMap(({ schemaLocations }) => schemaLocations) - .map((schemaLocation) => schemaLocation.replace(schemaUri, "")); - -/** @type (register: (schema: any, uri: string, dialect: string) => void, validate: (uri: string, instance: Json, format: typeof BASIC) => Promise, dialectUri: string, schema: SchemaObject, instance: Json) => Promise<{ schemaUri: string; errors: ErrorObject[] }> */ -const getErrors = async (register, validate, dialectUri, schema, instance) => { - const schemaUri = `https://example.com/openapi/${crypto.randomUUID()}`; - register(schema, schemaUri, dialectUri); - const output = await validate(schemaUri, instance, BASIC); - return { schemaUri, errors: await jsonSchemaErrors(output, schemaUri, instance) }; -}; - -const annotationKeywords = { - example: "an example", - externalDocs: { url: "https://example.com" }, - xml: { name: "value" }, - discriminator: { propertyName: "value" } -}; - -describe("OpenAPI 3.0", () => { - test("type is asserted", async () => { - const { schemaUri, errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { - type: "string" - }, 5); - - expect(messages(errors)).to.eql(["Expected a string"]); - expect(relativeLocations(errors, schemaUri)).to.eql(["#/type"]); - }); - - test("null is allowed when nullable", async () => { - const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { - type: "string", - nullable: true - }, null); - - expect(errors).to.eql([]); - }); - - test("nullable expands the allowed types", async () => { - const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { - type: "string", - nullable: true - }, 5); - - expect(messages(errors)).to.eql(["Expected a value of type null or string"]); - }); - - test("annotation keywords are ignored", async () => { - const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { - type: "string", - ...annotationKeywords - }, 5); - - expect(messages(errors)).to.eql(["Expected a string"]); - }); - - test("annotations allow any value when used alone", async () => { - const { errors } = await getErrors(registerOpenApi30, validateOpenApi30, dialect30, { - example: "an example", - externalDocs: { url: "https://example.com" }, - xml: { name: "value" }, - discriminator: { propertyName: "value" } - }, 5); - - expect(errors).to.eql([]); - }); -}); - -describe("OpenAPI 3.1", () => { - test("type is asserted and annotation keywords are ignored", async () => { - const { schemaUri, errors } = await getErrors(registerOpenApi31, validateOpenApi31, dialect31, { - type: "string", - ...annotationKeywords - }, 5); - - expect(messages(errors)).to.eql(["Expected a string"]); - expect(relativeLocations(errors, schemaUri)).to.eql(["#/type"]); - }); -}); - -describe("OpenAPI 3.2", () => { - test("type is asserted and annotation keywords are ignored", async () => { - const { schemaUri, errors } = await getErrors(registerOpenApi32, validateOpenApi32, dialect32, { - type: "string", - ...annotationKeywords - }, 5); - - expect(messages(errors)).to.eql(["Expected a string"]); - expect(relativeLocations(errors, schemaUri)).to.eql(["#/type"]); - }); -}); 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": [] + } + ] +} From 9af899aa0aae74c02d15cdc5a56fe18bf6c5f1bb Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Sat, 31 Oct 2026 19:30:41 -0700 Subject: [PATCH 4/6] fix: treat the OpenAPI 3.0 type keyword as an alias of type Error handlers that look up the type keyword by URI, such as anyOf and oneOf, missed the OpenAPI 3.0 type keyword, so they didn't drop alternatives with the wrong type. Add an alias option to keyword definitions so the normalizer reports a keyword's results under another keyword's URI, and make the OpenAPI 3.0 type keyword an alias of type. This replaces the special case in the type/const/enum error handler. Co-Authored-By: Claude Opus 5.5 --- src/error-handlers/typeConstEnum.js | 35 ++++++++++--------------- src/index.d.ts | 9 +++++++ src/index.js | 3 ++- src/json-schema-errors.js | 16 ++++++++++-- src/output-plugin.js | 6 +++-- src/test-suite/openapi/type.json | 40 +++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/error-handlers/typeConstEnum.js b/src/error-handlers/typeConstEnum.js index 9db89e0..d20aa56 100644 --- a/src/error-handlers/typeConstEnum.js +++ b/src/error-handlers/typeConstEnum.js @@ -7,11 +7,6 @@ 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) => { @@ -19,21 +14,19 @@ const typeConstEnumErrorHandler = { /** @type {string[]} */ const failedTypeLocations = []; - 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); + 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"); } + allowedTypes = allowedTypes.intersection(keywordTypes); } } if (allowedTypes.has("number")) { @@ -121,9 +114,9 @@ const typeConstEnumErrorHandler = { success: (normalizedOutput, instance, context) => { return [ - ...TYPE_KEYWORDS.flatMap((typeKeyword) => describeKeyword(normalizedOutput, typeKeyword, instance, (/** @type string | string[] */ type) => { + ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/type", 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/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/openapi/type.json b/src/test-suite/openapi/type.json index 5dd9e21..ce12cc1 100644 --- a/src/test-suite/openapi/type.json +++ b/src/test-suite/openapi/type.json @@ -29,6 +29,46 @@ }, "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"] + } + ] } ] } From 7749dda9bd783c053f9cff96dab9d09c9cf63a8d Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Sat, 31 Oct 2026 19:42:55 -0700 Subject: [PATCH 5/6] test: run the main test suite against OpenAPI 3.0 OpenAPI 3.0 only ran the openapi tests, so its type keyword was never tested inside keywords like anyOf, oneOf, and enum. Run the main tests against it as draft-04 and skip the tests with schemas that aren't valid OpenAPI 3.0. Co-Authored-By: Claude Opus 5.5 --- src/hyperjump-json-schema.test.js | 95 ++++++++++++++++++++++++++++++- src/test-suite/README.md | 6 +- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/hyperjump-json-schema.test.js b/src/hyperjump-json-schema.test.js index 4c01e7b..94e4e68 100644 --- a/src/hyperjump-json-schema.test.js +++ b/src/hyperjump-json-schema.test.js @@ -58,8 +58,8 @@ import { translations } from "./translations/index.js"; * }} TestSuite */ -/** @type (dialectUri: string, dialect: number, suite?: string) => void */ -const runTests = (dialectUri, dialect, suite = "tests") => { +/** @type (dialectUri: string, dialect: number, suite?: string, skip?: Record) => void */ +const runTests = (dialectUri, dialect, suite = "tests", skip = {}) => { describe(`JSON Schema Errors - ${dialectUri}`, async () => { setShouldValidateFormat(true); @@ -81,7 +81,8 @@ const runTests = (dialectUri, dialect, suite = "tests") => { if (!isCompatible(testCase.compatibility, dialect)) { continue; } - test(testCase.description, async () => { + const testOrSkip = skip[entry.name]?.includes(testCase.description) ? test.skip : test; + testOrSkip(testCase.description, async () => { registerSchema(testCase.schema, schemaUri, dialectUri); const instance = testCase.instance; @@ -213,6 +214,93 @@ 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 = { + "additionalProperties.json": [ + "additionalProperties with false schema", + "additionalProperties with object schema", + "additionalProperties on a non-object", + "additionalProperties pass" + ], + "anyOf.json": [ + "anyOf with $ref alternatives filters by type" + ], + "comment.json": [ + "format" + ], + "contains.json": [ + "contains passing" + ], + "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" + ], + "enum.json": [ + "enum with integer and string types filters fractions", + "enum with integer and string types passes for strings" + ], + "items.json": [ + "items/additionalItems with false schema", + "items/additionalItems with object schema", + "additionalItems on a non-array", + "additionalItems pass" + ], + "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" + ], + "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" + ], + "patternProperties.json": [ + "patternProperties", + "patternProperties on a non-object", + "patternProperties pass" + ], + "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" + ], + "propertyNames.json": [ + "propertyNames pass" + ], + "ref.json": [ + "$ref", + "$ref pass" + ], + "type.json": [ + "multiple types", + "collapsed type errors" + ], + "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); @@ -220,6 +308,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.0/dialect", 4, "tests", oas30Skip); runTests("https://spec.openapis.org/oas/3.2/dialect", 32, "openapi"); runTests("https://spec.openapis.org/oas/3.1/dialect/base", 31, "openapi"); diff --git a/src/test-suite/README.md b/src/test-suite/README.md index 4d34c46..09e7e90 100644 --- a/src/test-suite/README.md +++ b/src/test-suite/README.md @@ -66,8 +66,10 @@ 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 +The tests in the `tests` folder are also run against the OpenAPI dialects, with +`compatibility` treating OpenAPI 3.0 as `4` and OpenAPI 3.1 and 3.2 as `2020`. +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. 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. From a8d0c6660972ee5f3c6bab68dadddc82fa1fb5dd Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Sat, 31 Oct 2026 20:39:47 -0700 Subject: [PATCH 6/6] test: run each OpenAPI dialect once over both test folders Each OpenAPI dialect was run twice, once for the tests folder and once for the openapi folder, with different dialect numbers in each and two describe blocks with the same name. Run each dialect once over both folders, numbered by the JSON Schema release it's based on: 4 for OpenAPI 3.0 and 2020 for OpenAPI 3.1 and 3.2. Also rename the parsed test suite so it doesn't shadow the folder name. Co-Authored-By: Claude Opus 5.5 --- src/hyperjump-json-schema.test.js | 92 ++++++++++++++-------------- src/test-suite/README.md | 17 ++--- src/test-suite/openapi/nullable.json | 4 +- 3 files changed, 56 insertions(+), 57 deletions(-) diff --git a/src/hyperjump-json-schema.test.js b/src/hyperjump-json-schema.test.js index 94e4e68..de50e4b 100644 --- a/src/hyperjump-json-schema.test.js +++ b/src/hyperjump-json-schema.test.js @@ -58,8 +58,8 @@ import { translations } from "./translations/index.js"; * }} TestSuite */ -/** @type (dialectUri: string, dialect: number, suite?: string, skip?: Record) => void */ -const runTests = (dialectUri, dialect, suite = "tests", skip = {}) => { +/** @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); @@ -69,34 +69,36 @@ const runTests = (dialectUri, dialect, suite = "tests", skip = {}) => { unregisterSchema(schemaUri); }); - for (const entry of await readdir(`${import.meta.dirname}/test-suite/${suite}`, { 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; } - const testOrSkip = skip[entry.name]?.includes(testCase.description) ? test.skip : test; - testOrSkip(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"))); + + 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); - expect(errors).to.eql(buildErrors(testCase.errors, schemaUri)); - expectNestedLocations(errors); + const instance = testCase.instance; + const output = await validate(schemaUri, instance, BASIC); + const errors = await jsonSchemaErrors(output, schemaUri, instance, { isFormatAsserted: true }); - 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); - }); + 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); + }); + } } } }); @@ -223,22 +225,22 @@ const getMessage = (function () { * @type Record */ const oas30Skip = { - "additionalProperties.json": [ + "tests/additionalProperties.json": [ "additionalProperties with false schema", "additionalProperties with object schema", "additionalProperties on a non-object", "additionalProperties pass" ], - "anyOf.json": [ + "tests/anyOf.json": [ "anyOf with $ref alternatives filters by type" ], - "comment.json": [ + "tests/comment.json": [ "format" ], - "contains.json": [ + "tests/contains.json": [ "contains passing" ], - "dependencies.json": [ + "tests/dependencies.json": [ "array-form dependencies", "array-form dependencies pass", "schema-form dependencies", @@ -247,17 +249,17 @@ const oas30Skip = { "schem-form dependencies pass", "schema-form and array-form dependencies together" ], - "enum.json": [ + "tests/enum.json": [ "enum with integer and string types filters fractions", "enum with integer and string types passes for strings" ], - "items.json": [ + "tests/items.json": [ "items/additionalItems with false schema", "items/additionalItems with object schema", "additionalItems on a non-array", "additionalItems pass" ], - "not.json": [ + "tests/not.json": [ "not with multiple types", "not with dependencies", "not with schema dependencies", @@ -268,35 +270,35 @@ const oas30Skip = { "not with patternProperties that aren't present", "not with descriptions nested too deeply to show" ], - "oneOf.json": [ + "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" ], - "patternProperties.json": [ + "tests/patternProperties.json": [ "patternProperties", "patternProperties on a non-object", "patternProperties pass" ], - "prefixItems.json": [ + "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" ], - "propertyNames.json": [ + "tests/propertyNames.json": [ "propertyNames pass" ], - "ref.json": [ + "tests/ref.json": [ "$ref", "$ref pass" ], - "type.json": [ + "tests/type.json": [ "multiple types", "collapsed type errors" ], - "unknown.json": [ + "tests/unknown.json": [ "unknown keyword" ] }; @@ -306,10 +308,6 @@ 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.0/dialect", 4, "tests", oas30Skip); - -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"); +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/test-suite/README.md b/src/test-suite/README.md index 09e7e90..6882c08 100644 --- a/src/test-suite/README.md +++ b/src/test-suite/README.md @@ -66,11 +66,12 @@ test. ## OpenAPI -The tests in the `tests` folder are also run against the OpenAPI dialects, with -`compatibility` treating OpenAPI 3.0 as `4` and OpenAPI 3.1 and 3.2 as `2020`. -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. 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"` +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/nullable.json b/src/test-suite/openapi/nullable.json index 273bc5c..65f20eb 100644 --- a/src/test-suite/openapi/nullable.json +++ b/src/test-suite/openapi/nullable.json @@ -5,7 +5,7 @@ "tests": [ { "description": "nullable expands the allowed types", - "compatibility": "=30", + "compatibility": "=4", "schema": { "type": "string", "nullable": true @@ -26,7 +26,7 @@ }, { "description": "nullable allows null", - "compatibility": "=30", + "compatibility": "=4", "schema": { "type": "string", "nullable": true