import { expect, test } from "vitest";
import { fromJSONSchema } from "../from-json-schema.js";
import * as z from "../index.js";
test("basic string schema", () => {
const schema = fromJSONSchema({ type: "string" });
expect(schema.parse("hello")).toBe("hello");
expect(() => schema.parse(123)).toThrow();
});
test("string with constraints", () => {
const schema = fromJSONSchema({
type: "string",
minLength: 3,
maxLength: 10,
pattern: "^[a-z]+$",
});
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse("helloworld")).toBe("helloworld"); // exactly 10 chars - valid
expect(() => schema.parse("hi")).toThrow(); // too short
expect(() => schema.parse("helloworld1")).toThrow(); // too long (11 chars)
expect(() => schema.parse("Hello")).toThrow(); // pattern mismatch
});
test("pattern is not implicitly anchored", () => {
// JSON Schema patterns match anywhere in the string, not just the full string
const schema = fromJSONSchema({
type: "string",
pattern: "foo",
});
expect(schema.parse("foo")).toBe("foo");
expect(schema.parse("foobar")).toBe("foobar"); // matches at start
expect(schema.parse("barfoo")).toBe("barfoo"); // matches at end
expect(schema.parse("barfoobar")).toBe("barfoobar"); // matches in middle
expect(() => schema.parse("bar")).toThrow(); // no match
});
test("number schema", () => {
const schema = fromJSONSchema({ type: "number" });
expect(schema.parse(42)).toBe(42);
expect(() => schema.parse("42")).toThrow();
});
test("number with constraints", () => {
const schema = fromJSONSchema({
type: "number",
minimum: 0,
maximum: 100,
multipleOf: 5,
});
expect(schema.parse(50)).toBe(50);
expect(() => schema.parse(-1)).toThrow();
expect(() => schema.parse(101)).toThrow();
expect(() => schema.parse(47)).toThrow(); // not multiple of 5
});
test("integer schema", () => {
const schema = fromJSONSchema({ type: "integer" });
expect(schema.parse(42)).toBe(42);
expect(() => schema.parse(42.5)).toThrow();
});
test("boolean schema", () => {
const schema = fromJSONSchema({ type: "boolean" });
expect(schema.parse(true)).toBe(true);
expect(schema.parse(false)).toBe(false);
expect(() => schema.parse("true")).toThrow();
});
test("null schema", () => {
const schema = fromJSONSchema({ type: "null" });
expect(schema.parse(null)).toBe(null);
expect(() => schema.parse(undefined)).toThrow();
});
test("object schema", () => {
const schema = fromJSONSchema({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
});
expect(schema.parse({ name: "John", age: 30 })).toEqual({ name: "John", age: 30 });
expect(schema.parse({ name: "John" })).toEqual({ name: "John" });
expect(() => schema.parse({ age: 30 })).toThrow(); // missing required
});
test("object with additionalProperties false", () => {
const schema = fromJSONSchema({
type: "object",
properties: {
name: { type: "string" },
},
additionalProperties: false,
});
expect(schema.parse({ name: "John" })).toEqual({ name: "John" });
expect(() => schema.parse({ name: "John", extra: "field" })).toThrow();
});
test("array schema", () => {
const schema = fromJSONSchema({
type: "array",
items: { type: "string" },
});
expect(schema.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]);
expect(() => schema.parse([1, 2, 3])).toThrow();
});
test("array with constraints", () => {
const schema = fromJSONSchema({
type: "array",
items: { type: "number" },
minItems: 2,
maxItems: 4,
});
expect(schema.parse([1, 2])).toEqual([1, 2]);
expect(schema.parse([1, 2, 3, 4])).toEqual([1, 2, 3, 4]);
expect(() => schema.parse([1])).toThrow();
expect(() => schema.parse([1, 2, 3, 4, 5])).toThrow();
});
test("tuple with prefixItems (draft-2020-12)", () => {
const schema = fromJSONSchema({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "array",
prefixItems: [{ type: "string" }, { type: "number" }],
});
expect(schema.parse(["hello", 42])).toEqual(["hello", 42]);
expect(() => schema.parse(["hello"])).toThrow();
expect(() => schema.parse(["hello", "world"])).toThrow();
});
test("tuple with items array (draft-7)", () => {
const schema = fromJSONSchema({
$schema: "http://json-schema.org/draft-07/schema#",
type: "array",
items: [{ type: "string" }, { type: "number" }],
additionalItems: false,
});
expect(schema.parse(["hello", 42])).toEqual(["hello", 42]);
expect(() => schema.parse(["hello", 42, "extra"])).toThrow();
});
test("enum schema", () => {
const schema = fromJSONSchema({
enum: ["red", "green", "blue"],
});
expect(schema.parse("red")).toBe("red");
expect(() => schema.parse("yellow")).toThrow();
});
test("const schema", () => {
const schema = fromJSONSchema({
const: "hello",
});
expect(schema.parse("hello")).toBe("hello");
expect(() => schema.parse("world")).toThrow();
});
test("anyOf schema", () => {
const schema = fromJSONSchema({
anyOf: [{ type: "string" }, { type: "number" }],
});
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse(42)).toBe(42);
expect(() => schema.parse(true)).toThrow();
});
test("allOf schema", () => {
const schema = fromJSONSchema({
allOf: [
{ type: "object", properties: { name: { type: "string" } }, required: ["name"] },
{ type: "object", properties: { age: { type: "number" } }, required: ["age"] },
],
});
const result = schema.parse({ name: "John", age: 30 }) as { name: string; age: number };
expect(result.name).toBe("John");
expect(result.age).toBe(30);
});
test("allOf with empty array", () => {
// Empty allOf without explicit type returns any
const schema1 = fromJSONSchema({
allOf: [],
});
expect(schema1.parse("hello")).toBe("hello");
expect(schema1.parse(123)).toBe(123);
expect(schema1.parse({})).toEqual({});
// Empty allOf with explicit type returns base schema
const schema2 = fromJSONSchema({
type: "string",
allOf: [],
});
expect(schema2.parse("hello")).toBe("hello");
expect(() => schema2.parse(123)).toThrow();
});
test("oneOf schema (exclusive union)", () => {
const schema = fromJSONSchema({
oneOf: [{ type: "string" }, { type: "number" }],
});
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse(42)).toBe(42);
expect(() => schema.parse(true)).toThrow();
});
test("type with anyOf creates intersection", () => {
// type: string AND (type:string,minLength:5 OR type:string,pattern:^a)
const schema = fromJSONSchema({
type: "string",
anyOf: [
{ type: "string", minLength: 5 },
{ type: "string", pattern: "^a" },
],
});
// Should pass: string AND (minLength:5 OR pattern:^a) - matches minLength
expect(schema.parse("hello")).toBe("hello");
// Should pass: string AND (minLength:5 OR pattern:^a) - matches pattern
expect(schema.parse("abc")).toBe("abc");
// Should fail: string but neither minLength nor pattern match
expect(() => schema.parse("hi")).toThrow();
// Should fail: not a string
expect(() => schema.parse(123)).toThrow();
});
test("type with oneOf creates intersection", () => {
// type: string AND (exactly one of: type:string,minLength:5 OR type:string,pattern:^a)
const schema = fromJSONSchema({
type: "string",
oneOf: [
{ type: "string", minLength: 5 },
{ type: "string", pattern: "^a" },
],
});
// Should pass: string AND minLength:5 (exactly one match - "hello" length 5 >= 5, doesn't start with 'a')
expect(schema.parse("hello")).toBe("hello");
// Should pass: string AND pattern:^a (exactly one match - "abc" starts with 'a', length 3 < 5)
expect(schema.parse("abc")).toBe("abc");
// Should fail: string but neither match
expect(() => schema.parse("hi")).toThrow();
// Should fail: not a string
expect(() => schema.parse(123)).toThrow();
// Should fail: matches both (length >= 5 AND starts with 'a') - exclusive union fails
expect(() => schema.parse("apple")).toThrow();
});
test("unevaluatedItems throws error", () => {
expect(() => {
fromJSONSchema({
type: "array",
unevaluatedItems: false,
});
}).toThrow("unevaluatedItems is not supported");
});
test("unevaluatedProperties throws error", () => {
expect(() => {
fromJSONSchema({
type: "object",
unevaluatedProperties: false,
});
}).toThrow("unevaluatedProperties is not supported");
});
test("if/then/else throws error", () => {
expect(() => {
fromJSONSchema({
if: { type: "string" },
then: { type: "number" },
});
}).toThrow("Conditional schemas");
});
test("external $ref throws error", () => {
expect(() => {
fromJSONSchema({
$ref: "https://example.com/schema#/definitions/User",
});
}).toThrow("External $ref is not supported");
});
test("local $ref resolution", () => {
const schema = fromJSONSchema({
$defs: {
User: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
$ref: "#/$defs/User",
});
expect(schema.parse({ name: "John" })).toEqual({ name: "John" });
expect(() => schema.parse({})).toThrow();
});
test("circular $ref with lazy", () => {
const schema = fromJSONSchema({
$defs: {
Node: {
type: "object",
properties: {
value: { type: "string" },
children: {
type: "array",
items: { $ref: "#/$defs/Node" },
},
},
},
},
$ref: "#/$defs/Node",
});
type Node = { value: string; children: Node[] };
const result = schema.parse({
value: "root",
children: [{ value: "child", children: [] }],
}) as Node;
expect(result.value).toBe("root");
expect(result.children[0]?.value).toBe("child");
});
test("patternProperties", () => {
const schema = fromJSONSchema({
type: "object",
patternProperties: {
"^S_": { type: "string" },
},
});
const result = schema.parse({ S_name: "John", S_age: "30" }) as Record<string, string>;
expect(result.S_name).toBe("John");
expect(result.S_age).toBe("30");
});
test("patternProperties with regular properties", () => {
// Note: When patternProperties is combined with properties, the intersection
// validates all keys against the pattern. This test uses a pattern that
// matches the regular property name as well.
const schema = fromJSONSchema({
type: "object",
properties: {
S_name: { type: "string" },
},
patternProperties: {
"^S_": { type: "string" },
},
required: ["S_name"],
});
const result = schema.parse({ S_name: "John", S_extra: "value" }) as Record<string, string>;
expect(result.S_name).toBe("John");
expect(result.S_extra).toBe("value");
});
test("multiple patternProperties", () => {
const schema = fromJSONSchema({
type: "object",
patternProperties: {
"^S_": { type: "string" },
"^N_": { type: "number" },
},
});
const result = schema.parse({ S_name: "John", N_count: 123 }) as Record<string, string | number>;
expect(result.S_name).toBe("John");
expect(result.N_count).toBe(123);
// Keys not matching any pattern should pass through
const result2 = schema.parse({ S_name: "John", N_count: 123, other: "value" }) as Record<string, string | number>;
expect(result2.other).toBe("value");
});
test("multiple overlapping patternProperties", () => {
// If a key matches multiple patterns, value must satisfy all schemas
const schema = fromJSONSchema({
type: "object",
patternProperties: {
"^S_": { type: "string" },
"^S_N": { type: "string", minLength: 3 },
},
});
// S_name matches ^S_ but not ^S_N
expect(schema.parse({ S_name: "John" })).toEqual({ S_name: "John" });
// S_N matches both patterns - must satisfy both (string with minLength 3)
expect(schema.parse({ S_N: "abc" })).toEqual({ S_N: "abc" });
expect(() => schema.parse({ S_N: "ab" })).toThrow(); // too short for ^S_N pattern
});
test("default value", () => {
const schema = fromJSONSchema({
type: "string",
default: "hello",
});
// Default is applied during parsing if value is missing/undefined
// This depends on Zod's default behavior
expect(schema.parse("world")).toBe("world");
});
test("description metadata", () => {
const schema = fromJSONSchema({
type: "string",
description: "A string value",
});
expect(schema.parse("hello")).toBe("hello");
});
test("version detection - draft-2020-12", () => {
const schema = fromJSONSchema({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "array",
prefixItems: [{ type: "string" }],
});
expect(schema.parse(["hello"])).toEqual(["hello"]);
});
test("version detection - draft-7", () => {
const schema = fromJSONSchema({
$schema: "http://json-schema.org/draft-07/schema#",
type: "array",
items: [{ type: "string" }],
});
expect(schema.parse(["hello"])).toEqual(["hello"]);
});
test("version detection - draft-4", () => {
const schema = fromJSONSchema({
$schema: "http://json-schema.org/draft-04/schema#",
type: "array",
items: [{ type: "string" }],
});
expect(schema.parse(["hello"])).toEqual(["hello"]);
});
test("default version (draft-2020-12)", () => {
const schema = fromJSONSchema({
type: "array",
prefixItems: [{ type: "string" }],
});
expect(schema.parse(["hello"])).toEqual(["hello"]);
});
test("string format - email", () => {
const schema = fromJSONSchema({
type: "string",
format: "email",
});
expect(schema.parse("test@example.com")).toBe("test@example.com");
});
test("string format - uuid", () => {
const schema = fromJSONSchema({
type: "string",
format: "uuid",
});
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(schema.parse(uuid)).toBe(uuid);
});
test("exclusiveMinimum and exclusiveMaximum", () => {
const schema = fromJSONSchema({
type: "number",
exclusiveMinimum: 0,
exclusiveMaximum: 100,
});
expect(schema.parse(50)).toBe(50);
expect(() => schema.parse(0)).toThrow();
expect(() => schema.parse(100)).toThrow();
});
test("boolean schema (true/false)", () => {
const trueSchema = fromJSONSchema(true);
expect(trueSchema.parse("anything")).toBe("anything");
const falseSchema = fromJSONSchema(false);
expect(() => falseSchema.parse("anything")).toThrow();
});
test("empty object schema", () => {
const schema = fromJSONSchema({
type: "object",
});
expect(schema.parse({})).toEqual({});
expect(schema.parse({ extra: "field" })).toEqual({ extra: "field" });
});
test("array without items", () => {
const schema = fromJSONSchema({
type: "array",
});
expect(schema.parse([1, "string", true])).toEqual([1, "string", true]);
});
test("mixed enum types", () => {
const schema = fromJSONSchema({
enum: ["string", 42, true, null],
});
expect(schema.parse("string")).toBe("string");
expect(schema.parse(42)).toBe(42);
expect(schema.parse(true)).toBe(true);
expect(schema.parse(null)).toBe(null);
});
test("nullable in OpenAPI 3.0", () => {
// General nullable case (not just enum: [null])
const stringSchema = fromJSONSchema(
{
type: "string",
nullable: true,
},
{ defaultTarget: "openapi-3.0" }
);
expect(stringSchema.parse("hello")).toBe("hello");
expect(stringSchema.parse(null)).toBe(null);
expect(() => stringSchema.parse(123)).toThrow();
const numberSchema = fromJSONSchema(
{
type: "number",
nullable: true,
},
{ defaultTarget: "openapi-3.0" }
);
expect(numberSchema.parse(42)).toBe(42);
expect(numberSchema.parse(null)).toBe(null);
expect(() => numberSchema.parse("string")).toThrow();
const objectSchema = fromJSONSchema(
{
type: "object",
properties: { name: { type: "string" } },
nullable: true,
},
{ defaultTarget: "openapi-3.0" }
);
expect(objectSchema.parse({ name: "John" })).toEqual({ name: "John" });
expect(objectSchema.parse(null)).toBe(null);
});
// Metadata extraction tests
test("unrecognized keys stored in globalRegistry by default", () => {
const schema = fromJSONSchema({
type: "string",
title: "My String",
deprecated: true,
examples: ["hello", "world"],
"x-custom": "custom value",
});
const meta = z.globalRegistry.get(schema);
expect(meta).toBeDefined();
expect(meta?.title).toBe("My String");
expect(meta?.deprecated).toBe(true);
expect(meta?.examples).toEqual(["hello", "world"]);
expect((meta as any)?.["x-custom"]).toBe("custom value");
// Clean up
z.globalRegistry.remove(schema);
});
test("unrecognized keys stored in custom registry", () => {
const customRegistry = z.registry<{ title?: string; deprecated?: boolean }>();
const schema = fromJSONSchema(
{
type: "number",
title: "Age",
deprecated: true,
},
{ registry: customRegistry }
);
// Should be in custom registry
const meta = customRegistry.get(schema);
expect(meta).toBeDefined();
expect(meta?.title).toBe("Age");
expect(meta?.deprecated).toBe(true);
// Should NOT be in globalRegistry
expect(z.globalRegistry.get(schema)).toBeUndefined();
});
test("$id and id are captured as metadata", () => {
const customRegistry = z.registry<{ $id?: string; id?: string }>();
const schema1 = fromJSONSchema(
{
$id: "https://example.com/schemas/user",
type: "object",
},
{ registry: customRegistry }
);
expect(customRegistry.get(schema1)?.$id).toBe("https://example.com/schemas/user");
const schema2 = fromJSONSchema(
{
id: "legacy-id",
type: "string",
},
{ registry: customRegistry }
);
expect(customRegistry.get(schema2)?.id).toBe("legacy-id");
});
test("x-* extension keys are captured as metadata", () => {
const customRegistry = z.registry<Record<string, unknown>>();
const schema = fromJSONSchema(
{
type: "string",
"x-openapi-example": "example value",
"x-internal": true,
"x-tags": ["api", "public"],
},
{ registry: customRegistry }
);
const meta = customRegistry.get(schema);
expect(meta?.["x-openapi-example"]).toBe("example value");
expect(meta?.["x-internal"]).toBe(true);
expect(meta?.["x-tags"]).toEqual(["api", "public"]);
});
test("metadata on nested schemas", () => {
const customRegistry = z.registry<Record<string, unknown>>();
const parentSchema = fromJSONSchema(
{
type: "object",
title: "User",
properties: {
name: {
type: "string",
title: "Name",
"x-field-order": 1,
},
age: {
type: "number",
title: "Age",
deprecated: true,
},
},
},
{ registry: customRegistry }
);
// Verify parent schema has its metadata
expect(customRegistry.get(parentSchema)?.title).toBe("User");
// We can't easily access nested schemas directly, but we can verify
// the registry is being used correctly by checking a separate schema
const simpleSchema = fromJSONSchema(
{
type: "string",
title: "Simple",
},
{ registry: customRegistry }
);
expect(customRegistry.get(simpleSchema)?.title).toBe("Simple");
});
test("no metadata added when no unrecognized keys", () => {
const customRegistry = z.registry<Record<string, unknown>>();
const schema = fromJSONSchema(
{
type: "string",
minLength: 1,
maxLength: 100,
description: "A regular string",
},
{ registry: customRegistry }
);
// description is handled via .describe(), so it shouldn't be in metadata
// All other keys are recognized, so no metadata should be added
expect(customRegistry.get(schema)).toBeUndefined();
});
test("writeOnly and examples are captured as metadata", () => {
const customRegistry = z.registry<{ writeOnly?: boolean; examples?: unknown[] }>();
const schema = fromJSONSchema(
{
type: "string",
writeOnly: true,
examples: ["password123", "secret"],
},
{ registry: customRegistry }
);
const meta = customRegistry.get(schema);
expect(meta?.writeOnly).toBe(true);
expect(meta?.examples).toEqual(["password123", "secret"]);
});
test("$comment and $anchor are captured as metadata", () => {
const customRegistry = z.registry<{ $comment?: string; $anchor?: string }>();
const schema = fromJSONSchema(
{
type: "string",
$comment: "This is a developer note",
$anchor: "my-anchor",
},
{ registry: customRegistry }
);
const meta = customRegistry.get(schema);
expect(meta?.$comment).toBe("This is a developer note");
expect(meta?.$anchor).toBe("my-anchor");
});
test("contentEncoding and contentMediaType are stored as metadata", () => {
const customRegistry = z.registry<{ contentEncoding?: string; contentMediaType?: string }>();
const schema = fromJSONSchema(
{
type: "string",
contentEncoding: "base64",
contentMediaType: "image/png",
},
{ registry: customRegistry }
);
// Should just be a string schema
expect(schema.parse("aGVsbG8gd29ybGQ=")).toBe("aGVsbG8gd29ybGQ=");
// Content keywords should be in metadata
const meta = customRegistry.get(schema);
expect(meta?.contentEncoding).toBe("base64");
expect(meta?.contentMediaType).toBe("image/png");
});