Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions src/error-handlers/typeConstEnum.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,33 @@ 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) => {
let allowedTypes = new Set(ALL_TYPES);
/** @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<string>} */
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<string>} */
const keywordTypes = new Set(types);
if (keywordTypes.has("number")) {
keywordTypes.add("integer");
}
allowedTypes = allowedTypes.intersection(keywordTypes);
}
allowedTypes = allowedTypes.intersection(keywordTypes);
}
}
if (allowedTypes.has("number")) {
Expand Down Expand Up @@ -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)]);
}),
Expand Down
11 changes: 11 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions src/openapi.test.js
Original file line number Diff line number Diff line change
@@ -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<any>, 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"]);
});
});
61 changes: 61 additions & 0 deletions src/test-suite/tests/content.json
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}