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 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/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"]); + }); +}); 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": [] + } + ] +}