From 6b0b40f764892c69854f2c9305630325f35e9851 Mon Sep 17 00:00:00 2001 From: MohitSAGAR11 Date: Mon, 19 Oct 2026 06:57:18 +0530 Subject: [PATCH 1/3] 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/3] 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/3] 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": [] + } + ] +}