import { Either, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { translateJsonSchema, type JsonSchemaTranslationError, } from "./translator.js"; const translatedSchema = ( schema: unknown, components: Readonly> = {}, ): 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> = {}, ): 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 = {}; 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); }); });