392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
import { Either, Schema } from "effect";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
translateJsonSchema,
|
|
type JsonSchemaTranslationError,
|
|
} from "./translator.js";
|
|
|
|
const translatedSchema = (
|
|
schema: unknown,
|
|
components: Readonly<Record<string, unknown>> = {},
|
|
): Schema.Schema.AnyNoContext => {
|
|
const result = translateJsonSchema(schema, { components });
|
|
if (Either.isLeft(result)) {
|
|
throw new Error(`${result.left.path}: ${result.left.message}`);
|
|
}
|
|
return result.right;
|
|
};
|
|
|
|
const accepts = (schema: Schema.Schema.AnyNoContext, value: unknown): boolean =>
|
|
Either.isRight(
|
|
Schema.decodeUnknownEither(schema)(value, { onExcessProperty: "error" }),
|
|
);
|
|
|
|
const rejected = (
|
|
schema: unknown,
|
|
components: Readonly<Record<string, unknown>> = {},
|
|
): JsonSchemaTranslationError => {
|
|
const result = translateJsonSchema(schema, { components });
|
|
if (Either.isRight(result)) throw new Error("expected translation to fail");
|
|
return result.left;
|
|
};
|
|
|
|
describe("translateJsonSchema", () => {
|
|
it("translates constrained primitives, arrays, and closed objects", () => {
|
|
const schema = translatedSchema({
|
|
additionalProperties: false,
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
id: { minLength: 2, pattern: "^w", type: "string" },
|
|
score: { maximum: 10, minimum: 0, type: "integer" },
|
|
tags: {
|
|
items: { enum: ["a", "b"], type: "string" },
|
|
maxItems: 2,
|
|
minItems: 1,
|
|
type: "array",
|
|
},
|
|
},
|
|
required: ["id", "score", "tags"],
|
|
type: "object",
|
|
});
|
|
|
|
expect(accepts(schema, { id: "wf", score: 4, tags: ["a"] })).toBe(true);
|
|
expect(accepts(schema, { id: "x", score: 4, tags: ["a"] })).toBe(false);
|
|
expect(accepts(schema, { id: "wf", score: 4.5, tags: ["a"] })).toBe(false);
|
|
expect(accepts(schema, { id: "wf", score: 4, tags: [], extra: true })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("supports open and schema-constrained additional properties", () => {
|
|
const open = translatedSchema({
|
|
additionalProperties: true,
|
|
properties: { id: { type: "string" } },
|
|
required: ["id"],
|
|
type: "object",
|
|
});
|
|
const numericRest = translatedSchema({
|
|
additionalProperties: { type: "number" },
|
|
properties: {},
|
|
type: "object",
|
|
});
|
|
const openAny = translatedSchema({
|
|
additionalProperties: true,
|
|
type: "object",
|
|
});
|
|
|
|
expect(accepts(open, { id: "x", metadata: { nested: true } })).toBe(true);
|
|
expect(accepts(open, { id: "x", invalid: undefined })).toBe(false);
|
|
expect(accepts(openAny, new Date())).toBe(false);
|
|
expect(accepts(openAny, new Map())).toBe(false);
|
|
expect(accepts(numericRest, { first: 1, second: 2.5 })).toBe(true);
|
|
expect(accepts(numericRest, { first: "1" })).toBe(false);
|
|
});
|
|
|
|
it("keeps empty closed objects closed", () => {
|
|
const emptyObject = translatedSchema({
|
|
additionalProperties: false,
|
|
type: "object",
|
|
});
|
|
|
|
expect(accepts(emptyObject, {})).toBe(true);
|
|
expect(accepts(emptyObject, [])).toBe(false);
|
|
expect(accepts(emptyObject, { extra: true })).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["properties", { properties: null, type: "object" }],
|
|
["required", { required: null, type: "object" }],
|
|
])("rejects explicit null object keyword: %s", (keyword, schema) => {
|
|
const error = rejected(schema);
|
|
|
|
expect(error.keyword).toBe(keyword);
|
|
});
|
|
|
|
it("fails closed on prototype-like JSON property names", () => {
|
|
const error = rejected({
|
|
additionalProperties: false,
|
|
properties: Object.fromEntries([
|
|
["__proto__", { type: "string" }],
|
|
["toString", { type: "string" }],
|
|
]),
|
|
required: ["__proto__", "toString"],
|
|
type: "object",
|
|
});
|
|
|
|
expect(error.keyword).toBe("properties");
|
|
expect(error.message).toMatch(/prototype/i);
|
|
});
|
|
|
|
it("counts Unicode code points for string length constraints", () => {
|
|
const exactlyOneCharacter = translatedSchema({
|
|
maxLength: 1,
|
|
minLength: 1,
|
|
type: "string",
|
|
});
|
|
|
|
expect(accepts(exactlyOneCharacter, "😀")).toBe(true);
|
|
expect(accepts(exactlyOneCharacter, "ab")).toBe(false);
|
|
});
|
|
|
|
it("resolves local references and recursive component schemas", () => {
|
|
const components = {
|
|
TreeNode: {
|
|
additionalProperties: false,
|
|
properties: {
|
|
children: {
|
|
items: { $ref: "#/components/schemas/TreeNode" },
|
|
type: "array",
|
|
},
|
|
label: { type: "string" },
|
|
},
|
|
required: ["label", "children"],
|
|
type: "object",
|
|
},
|
|
};
|
|
const schema = translatedSchema(
|
|
{
|
|
anyOf: [
|
|
{ $ref: "#/components/schemas/TreeNode" },
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
components,
|
|
);
|
|
|
|
expect(
|
|
accepts(schema, {
|
|
children: [{ children: [], label: "child" }],
|
|
label: "root",
|
|
}),
|
|
).toBe(true);
|
|
expect(accepts(schema, null)).toBe(true);
|
|
expect(accepts(schema, { children: [{ label: "child" }], label: "root" })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("translates a recursive discriminated oneOf contract", () => {
|
|
const components = {
|
|
InputExpression: {
|
|
discriminator: {
|
|
mapping: {
|
|
array: "#/components/schemas/ArrayExpression",
|
|
literal: "#/components/schemas/LiteralExpression",
|
|
object: "#/components/schemas/ObjectExpression",
|
|
path: "#/components/schemas/PathExpression",
|
|
},
|
|
propertyName: "kind",
|
|
},
|
|
oneOf: [
|
|
{ $ref: "#/components/schemas/LiteralExpression" },
|
|
{ $ref: "#/components/schemas/PathExpression" },
|
|
{ $ref: "#/components/schemas/ArrayExpression" },
|
|
{ $ref: "#/components/schemas/ObjectExpression" },
|
|
],
|
|
},
|
|
LiteralExpression: {
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { const: "literal", type: "string" },
|
|
value: { type: "string" },
|
|
},
|
|
required: ["kind", "value"],
|
|
type: "object",
|
|
},
|
|
PathExpression: {
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { const: "path", type: "string" },
|
|
path: { type: "string" },
|
|
},
|
|
required: ["kind", "path"],
|
|
type: "object",
|
|
},
|
|
ArrayExpression: {
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { const: "array", type: "string" },
|
|
items: {
|
|
items: { $ref: "#/components/schemas/InputExpression" },
|
|
type: "array",
|
|
},
|
|
},
|
|
required: ["kind", "items"],
|
|
type: "object",
|
|
},
|
|
ObjectExpression: {
|
|
additionalProperties: false,
|
|
properties: {
|
|
fields: {
|
|
additionalProperties: { $ref: "#/components/schemas/InputExpression" },
|
|
type: "object",
|
|
},
|
|
kind: { const: "object", type: "string" },
|
|
},
|
|
required: ["kind", "fields"],
|
|
type: "object",
|
|
},
|
|
};
|
|
const schema = translatedSchema(
|
|
{ $ref: "#/components/schemas/InputExpression" },
|
|
components,
|
|
);
|
|
|
|
expect(accepts(schema, { kind: "literal", value: "hello" })).toBe(true);
|
|
expect(
|
|
accepts(schema, {
|
|
kind: "object",
|
|
fields: {
|
|
nested: { kind: "array", items: [{ kind: "path", path: "state.foo" }] },
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
expect(accepts(schema, { kind: "literal", value: "hello", extra: true })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("rejects a decorative discriminator on inline overlapping oneOf branches", () => {
|
|
const error = rejected({
|
|
discriminator: {
|
|
mapping: { text: "#/components/schemas/Text", short: "#/components/schemas/Short" },
|
|
propertyName: "kind",
|
|
},
|
|
oneOf: [{ type: "string" }, { minLength: 1, type: "string" }],
|
|
}, {
|
|
Short: { minLength: 1, type: "string" },
|
|
Text: { type: "string" },
|
|
});
|
|
|
|
expect(error.keyword).toBe("oneOf");
|
|
expect(error.message).toMatch(/generated tagged object union/i);
|
|
});
|
|
|
|
it("rejects discriminated branches without distinct discriminator constants", () => {
|
|
const error = rejected({
|
|
discriminator: {
|
|
mapping: {
|
|
first: "#/components/schemas/First",
|
|
second: "#/components/schemas/Second",
|
|
},
|
|
propertyName: "kind",
|
|
},
|
|
oneOf: [
|
|
{ $ref: "#/components/schemas/First" },
|
|
{ $ref: "#/components/schemas/Second" },
|
|
],
|
|
}, {
|
|
First: {
|
|
additionalProperties: false,
|
|
properties: { kind: { type: "string" }, value: { type: "string" } },
|
|
required: ["kind", "value"],
|
|
type: "object",
|
|
},
|
|
Second: {
|
|
additionalProperties: false,
|
|
properties: { kind: { type: "string" }, value: { type: "string" } },
|
|
required: ["kind", "value"],
|
|
type: "object",
|
|
},
|
|
});
|
|
|
|
expect(error.keyword).toBe("oneOf");
|
|
expect(error.message).toMatch(/generated tagged object union/i);
|
|
});
|
|
|
|
it("rejects unproductive component reference cycles", () => {
|
|
const components = {
|
|
Loop: { $ref: "#/components/schemas/Loop" },
|
|
};
|
|
|
|
const error = rejected({ $ref: "#/components/schemas/Loop" }, components);
|
|
expect(error.keyword).toBe("$ref");
|
|
expect(error.message).toMatch(/recursive.*structural boundary/i);
|
|
});
|
|
|
|
it("supports productive recursion through a component alias", () => {
|
|
const components = {
|
|
Alias: { $ref: "#/components/schemas/Node" },
|
|
Node: {
|
|
additionalProperties: false,
|
|
properties: {
|
|
child: { $ref: "#/components/schemas/Alias" },
|
|
},
|
|
type: "object",
|
|
},
|
|
};
|
|
const schema = translatedSchema(
|
|
{ $ref: "#/components/schemas/Node" },
|
|
components,
|
|
);
|
|
|
|
expect(accepts(schema, { child: { child: {} } })).toBe(true);
|
|
});
|
|
|
|
it("treats boolean schemas as JSON-only accept-all or reject-all", () => {
|
|
const acceptJson = translatedSchema(true);
|
|
const rejectAll = translatedSchema(false);
|
|
const cyclic: Record<string, unknown> = {};
|
|
cyclic.self = cyclic;
|
|
|
|
expect(accepts(acceptJson, { nested: [1, true, null] })).toBe(true);
|
|
expect(accepts(acceptJson, undefined)).toBe(false);
|
|
expect(accepts(acceptJson, new Date())).toBe(false);
|
|
expect(accepts(acceptJson, new Map())).toBe(false);
|
|
expect(accepts(acceptJson, cyclic)).toBe(false);
|
|
expect(accepts(rejectAll, null)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
[{ oneOf: [{ type: "string" }, { minLength: 1, type: "string" }] }, "oneOf"],
|
|
[
|
|
{
|
|
if: { properties: { kind: { const: "x" } } },
|
|
then: { required: ["value"] },
|
|
type: "object",
|
|
},
|
|
"if",
|
|
],
|
|
[{ format: "date-time", type: "string" }, "format"],
|
|
])(
|
|
"rejects unsupported semantics instead of weakening them: %s",
|
|
(schema, keyword) => {
|
|
const error = rejected(schema);
|
|
expect(error.keyword).toBe(keyword);
|
|
},
|
|
);
|
|
|
|
it("rejects external and dangling component references", () => {
|
|
expect(rejected({ $ref: "https://example.com/schema.json" }).keyword).toBe(
|
|
"$ref",
|
|
);
|
|
expect(
|
|
rejected({ $ref: "#/components/schemas/Missing" }).message,
|
|
).toMatch(/missing component/i);
|
|
expect(
|
|
rejected({ $ref: "#/components/schemas/__proto__" }).message,
|
|
).toMatch(/missing component/i);
|
|
});
|
|
|
|
it("rejects const and enum values that contradict their declared type", () => {
|
|
expect(rejected({ const: 1, type: "string" }).keyword).toBe("type");
|
|
expect(rejected({ enum: ["ok", 1], type: "string" }).keyword).toBe("type");
|
|
});
|
|
|
|
it("rejects empty or duplicate enum declarations", () => {
|
|
expect(rejected({ enum: [] }).keyword).toBe("enum");
|
|
expect(rejected({ enum: ["same", "same"] }).keyword).toBe("enum");
|
|
});
|
|
|
|
it("fails closed on required names supplied only by additionalProperties", () => {
|
|
const error = rejected({
|
|
additionalProperties: { type: "string" },
|
|
required: ["dynamic"],
|
|
type: "object",
|
|
});
|
|
|
|
expect(error.keyword).toBe("required");
|
|
expect(error.message).toMatch(/not supported/i);
|
|
});
|
|
});
|