feat: Add fork of json-schema-to-zod (no-changelog) (#11228)

This commit is contained in:
Tomi Turtiainen
2024-10-17 14:57:44 +02:00
committed by GitHub
parent c728a2ffe0
commit 86a94b5523
56 changed files with 3013 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"properties": {
"allOf": {
"allOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"anyOf": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"oneOf": {
"oneOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"array": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 2,
"maxItems": 3
},
"tuple": {
"type": "array",
"items": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
],
"minItems": 2,
"maxItems": 3
},
"const": {
"const": "xbox"
},
"enum": {
"enum": ["ps4", "ps5"]
},
"ifThenElse": {
"if": {
"type": "string"
},
"then": {
"const": "x"
},
"else": {
"enum": [1, 2, 3]
}
},
"null": {
"type": "null"
},
"multiple": {
"type": ["array", "boolean"]
},
"objAdditionalTrue": {
"type": "object",
"properties": {
"x": {
"type": "string"
}
},
"additionalProperties": true
},
"objAdditionalFalse": {
"type": "object",
"properties": {
"x": {
"type": "string"
}
},
"additionalProperties": false
},
"objAdditionalNumber": {
"type": "object",
"properties": {
"x": {
"type": "string"
}
},
"additionalProperties": {
"type": "number"
}
},
"objAdditionalOnly": {
"type": "object",
"additionalProperties": {
"type": "number"
}
},
"patternProps": {
"type": "object",
"patternProperties": {
"^x": {
"type": "string"
},
"^y": {
"type": "number"
}
},
"properties": {
"z": {
"type": "string"
}
},
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,16 @@
import type { z } from 'zod';
expect.extend({
toMatchZod(this: jest.MatcherContext, actual: z.ZodTypeAny, expected: z.ZodTypeAny) {
const actualSerialized = JSON.stringify(actual._def, null, 2);
const expectedSerialized = JSON.stringify(expected._def, null, 2);
const pass = this.equals(actualSerialized, expectedSerialized);
return {
pass,
message: pass
? () => `Expected ${actualSerialized} not to match ${expectedSerialized}`
: () => `Expected ${actualSerialized} to match ${expectedSerialized}`,
};
},
});

View File

@@ -0,0 +1,5 @@
namespace jest {
interface Matchers<R, T> {
toMatchZod(expected: unknown): T;
}
}

View File

@@ -0,0 +1,106 @@
import type { JSONSchema4, JSONSchema6Definition, JSONSchema7Definition } from 'json-schema';
import { z } from 'zod';
import { jsonSchemaToZod } from '../src';
describe('jsonSchemaToZod', () => {
test('should accept json schema 7 and 4', () => {
const schema = { type: 'string' } as unknown;
expect(jsonSchemaToZod(schema as JSONSchema4));
expect(jsonSchemaToZod(schema as JSONSchema6Definition));
expect(jsonSchemaToZod(schema as JSONSchema7Definition));
});
test('can exclude defaults', () => {
expect(
jsonSchemaToZod(
{
type: 'string',
default: 'foo',
},
{ withoutDefaults: true },
),
).toMatchZod(z.string());
});
test('should include describes', () => {
expect(
jsonSchemaToZod({
type: 'string',
description: 'foo',
}),
).toMatchZod(z.string().describe('foo'));
});
test('can exclude describes', () => {
expect(
jsonSchemaToZod(
{
type: 'string',
description: 'foo',
},
{
withoutDescribes: true,
},
),
).toMatchZod(z.string());
});
test('will remove optionality if default is present', () => {
expect(
jsonSchemaToZod({
type: 'object',
properties: {
prop: {
type: 'string',
default: 'def',
},
},
}),
).toMatchZod(z.object({ prop: z.string().default('def') }));
});
test('will handle falsy defaults', () => {
expect(
jsonSchemaToZod({
type: 'boolean',
default: false,
}),
).toMatchZod(z.boolean().default(false));
});
test('will ignore undefined as default', () => {
expect(
jsonSchemaToZod({
type: 'null',
default: undefined,
}),
).toMatchZod(z.null());
});
test('should be possible to define a custom parser', () => {
expect(
jsonSchemaToZod(
{
allOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean', description: 'foo' }],
},
{
parserOverride: (schema, refs) => {
if (
refs.path.length === 2 &&
refs.path[0] === 'allOf' &&
refs.path[1] === 2 &&
schema.type === 'boolean' &&
schema.description === 'foo'
) {
return z.null();
}
return undefined;
},
},
),
).toMatchZod(z.intersection(z.string(), z.intersection(z.number(), z.null())));
});
});

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { parseAllOf } from '../../src/parsers/parse-all-of';
describe('parseAllOf', () => {
test('should create never if empty', () => {
expect(
parseAllOf(
{
allOf: [],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.never());
});
test('should handle true values', () => {
expect(
parseAllOf(
{
allOf: [{ type: 'string' }, true],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.intersection(z.string(), z.any()));
});
test('should handle false values', () => {
expect(
parseAllOf(
{
allOf: [{ type: 'string' }, false],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.intersection(
z.string(),
z
.any()
.refine(
(value) => !z.any().safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
),
),
);
});
});

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { parseAnyOf } from '../../src/parsers/parse-any-of';
describe('parseAnyOf', () => {
test('should create a union from two or more schemas', () => {
expect(
parseAnyOf(
{
anyOf: [
{
type: 'string',
},
{ type: 'number' },
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.union([z.string(), z.number()]));
});
test('should extract a single schema', () => {
expect(parseAnyOf({ anyOf: [{ type: 'string' }] }, { path: [], seen: new Map() })).toMatchZod(
z.string(),
);
});
test('should return z.any() if array is empty', () => {
expect(parseAnyOf({ anyOf: [] }, { path: [], seen: new Map() })).toMatchZod(z.any());
});
});

View File

@@ -0,0 +1,68 @@
import { z } from 'zod';
import { parseArray } from '../../src/parsers/parse-array';
describe('parseArray', () => {
test('should create tuple with items array', () => {
expect(
parseArray(
{
type: 'array',
items: [
{
type: 'string',
},
{
type: 'number',
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.tuple([z.string(), z.number()]));
});
test('should create array with items object', () => {
expect(
parseArray(
{
type: 'array',
items: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.array(z.string()));
});
test('should create min for minItems', () => {
expect(
parseArray(
{
type: 'array',
minItems: 2,
items: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.array(z.string()).min(2));
});
test('should create max for maxItems', () => {
expect(
parseArray(
{
type: 'array',
maxItems: 2,
items: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.array(z.string()).max(2));
});
});

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
import { parseConst } from '../../src/parsers/parse-const';
describe('parseConst', () => {
test('should handle falsy constants', () => {
expect(
parseConst({
const: false,
}),
).toMatchZod(z.literal(false));
});
});

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
import { parseEnum } from '../../src/parsers/parse-enum';
describe('parseEnum', () => {
test('should create never with empty enum', () => {
expect(
parseEnum({
enum: [],
}),
).toMatchZod(z.never());
});
test('should create literal with single item enum', () => {
expect(
parseEnum({
enum: ['someValue'],
}),
).toMatchZod(z.literal('someValue'));
});
test('should create enum array with string enums', () => {
expect(
parseEnum({
enum: ['someValue', 'anotherValue'],
}),
).toMatchZod(z.enum(['someValue', 'anotherValue']));
});
test('should create union with mixed enums', () => {
expect(
parseEnum({
enum: ['someValue', 57],
}),
).toMatchZod(z.union([z.literal('someValue'), z.literal(57)]));
});
});

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
import { parseNot } from '../../src/parsers/parse-not';
describe('parseNot', () => {
test('parseNot', () => {
expect(
parseNot(
{
not: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.any()
.refine(
(value) => !z.string().safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
),
);
});
});

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
import { parseSchema } from '../../src/parsers/parse-schema';
describe('parseNullable', () => {
test('parseSchema should not add default twice', () => {
expect(
parseSchema(
{
type: 'string',
nullable: true,
default: null,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.string().nullable().default(null));
});
});

View File

@@ -0,0 +1,83 @@
import { z } from 'zod';
import { parseNumber } from '../../src/parsers/parse-number';
describe('parseNumber', () => {
test('should handle integer', () => {
expect(
parseNumber({
type: 'integer',
}),
).toMatchZod(z.number().int());
expect(
parseNumber({
type: 'integer',
multipleOf: 1,
}),
).toMatchZod(z.number().int());
expect(
parseNumber({
type: 'number',
multipleOf: 1,
}),
).toMatchZod(z.number().int());
});
test('should handle maximum with exclusiveMinimum', () => {
expect(
parseNumber({
type: 'number',
exclusiveMinimum: true,
minimum: 2,
}),
).toMatchZod(z.number().gt(2));
});
test('should handle maximum with exclusiveMinimum', () => {
expect(
parseNumber({
type: 'number',
minimum: 2,
}),
).toMatchZod(z.number().gte(2));
});
test('should handle maximum with exclusiveMaximum', () => {
expect(
parseNumber({
type: 'number',
exclusiveMaximum: true,
maximum: 2,
}),
).toMatchZod(z.number().lt(2));
});
test('should handle numeric exclusiveMaximum', () => {
expect(
parseNumber({
type: 'number',
exclusiveMaximum: 2,
}),
).toMatchZod(z.number().lt(2));
});
test('should accept errorMessage', () => {
expect(
parseNumber({
type: 'number',
format: 'int64',
exclusiveMinimum: 0,
maximum: 2,
multipleOf: 2,
errorMessage: {
format: 'ayy',
multipleOf: 'lmao',
exclusiveMinimum: 'deez',
maximum: 'nuts',
},
}),
).toMatchZod(z.number().int('ayy').multipleOf(2, 'lmao').gt(0, 'deez').lte(2, 'nuts'));
});
});

View File

@@ -0,0 +1,904 @@
/* eslint-disable n8n-local-rules/no-skipped-tests */
import type { JSONSchema7 } from 'json-schema';
import { z, ZodError } from 'zod';
import { parseObject } from '../../src/parsers/parse-object';
describe('parseObject', () => {
test('should handle with missing properties', () => {
expect(
parseObject(
{
type: 'object',
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.any()));
});
test('should handle with empty properties', () => {
expect(
parseObject(
{
type: 'object',
properties: {},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({}));
});
test('With properties - should handle optional and required properties', () => {
expect(
parseObject(
{
type: 'object',
required: ['myRequiredString'],
properties: {
myOptionalString: {
type: 'string',
},
myRequiredString: {
type: 'string',
},
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ myOptionalString: z.string().optional(), myRequiredString: z.string() }),
);
});
test('With properties - should handle additionalProperties when set to false', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: false,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).strict());
});
test('With properties - should handle additionalProperties when set to true', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).catchall(z.any()));
});
test('With properties - should handle additionalProperties when provided a schema', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: { type: 'number' },
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).catchall(z.number()));
});
test('Without properties - should handle additionalProperties when set to false', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: false,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.never()));
});
test('Without properties - should handle additionalProperties when set to true', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.any()));
});
test('Without properties - should handle additionalProperties when provided a schema', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: { type: 'number' },
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.number()));
});
test('Without properties - should include falsy defaults', () => {
expect(
parseObject(
{
type: 'object',
properties: {
s: {
type: 'string',
default: '',
},
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ s: z.string().default('') }));
});
test('eh', () => {
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
anyOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.object({ a: z.string() })
.and(z.union([z.object({ b: z.string() }), z.object({ c: z.string() })])),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
anyOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ a: z.string() }).and(z.union([z.object({ b: z.string() }), z.any()])));
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
oneOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(
z.any().superRefine((x, ctx) => {
const schemas = [z.object({ b: z.string() }), z.object({ c: z.string() })];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(
schema.safeParse(x),
),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
oneOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(
z.any().superRefine((x, ctx) => {
const schemas = [z.object({ b: z.string() }), z.any()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(
schema.safeParse(x),
),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
allOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.object({ a: z.string() })
.and(z.intersection(z.object({ b: z.string() }), z.object({ c: z.string() }))),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
allOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(z.intersection(z.object({ b: z.string() }), z.any())),
);
});
const run = (zodSchema: z.ZodTypeAny, data: unknown) => zodSchema.safeParse(data);
test('Functional tests - run', () => {
expect(run(z.string(), 'hello')).toEqual({
success: true,
data: 'hello',
});
});
test('Functional tests - properties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
};
const expected = z.object({ a: z.string(), b: z.number().optional() });
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { a: 'hello' })).toEqual({
success: true,
data: {
a: 'hello',
},
});
expect(run(result, { a: 'hello', b: 123 })).toEqual({
success: true,
data: {
a: 'hello',
b: 123,
},
});
expect(run(result, { b: 'hello', x: true })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['a'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: ['b'],
message: 'Expected number, received string',
},
]),
});
});
test('Functional tests - properties and additionalProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
additionalProperties: { type: 'boolean' },
};
const expected = z.object({ a: z.string(), b: z.number().optional() }).catchall(z.boolean());
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { b: 'hello', x: 'true' })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['a'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: ['b'],
message: 'Expected number, received string',
},
{
code: 'invalid_type',
expected: 'boolean',
received: 'string',
path: ['x'],
message: 'Expected boolean, received string',
},
]),
});
});
test('Functional tests - properties and single-item patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
patternProperties: {
'\\.': { type: 'array' },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.array(z.any()))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { a: 'a', b: 2, '.': [] })).toEqual({
success: true,
data: { a: 'a', b: 2, '.': [] },
});
expect(run(result, { a: 'a', b: 2, '.': '[]' })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'array',
received: 'string',
path: ['.'],
message: 'Expected array, received string',
},
]),
});
});
test('Functional tests - properties, additionalProperties and patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
additionalProperties: { type: 'boolean' },
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.union([z.array(z.any()), z.array(z.any()).min(1), z.boolean()]))
.superRefine((value, ctx) => {
for (const key in value) {
let evaluated = ['a', 'b'].includes(key);
if (key.match(new RegExp('\\\\.'))) {
evaluated = true;
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\\\,'))) {
evaluated = true;
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (!evaluated) {
const result = z.boolean().safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - additionalProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
additionalProperties: { type: 'boolean' },
};
const expected = z.record(z.boolean());
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - additionalProperties and patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
additionalProperties: { type: 'boolean' },
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.record(z.union([z.array(z.any()), z.array(z.any()).min(1), z.boolean()]))
.superRefine((value, ctx) => {
for (const key in value) {
let evaluated = false;
if (key.match(new RegExp('\\\\.'))) {
evaluated = true;
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\\\,'))) {
evaluated = true;
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (!evaluated) {
const result = z.boolean().safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { x: true, '.': [], ',': [] })).toEqual({
success: false,
error: new ZodError([
{
path: [','],
code: 'custom',
message: 'Invalid input: Key matching regex /,/ must match schema',
params: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'array',
inclusive: true,
exact: false,
message: 'Array must contain at least 1 element(s)',
path: [],
},
],
},
},
]),
});
});
test('Functional tests - single-item patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
patternProperties: {
'\\.': { type: 'array' },
},
};
const expected = z.record(z.array(z.any())).superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.record(z.union([z.array(z.any()), z.array(z.any()).min(1)]))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\,'))) {
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(run(result, { '.': [] })).toEqual({
success: true,
data: { '.': [] },
});
expect(run(result, { ',': [] })).toEqual({
success: false,
error: new ZodError([
{
path: [','],
code: 'custom',
message: 'Invalid input: Key matching regex /,/ must match schema',
params: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'array',
inclusive: true,
exact: false,
message: 'Array must contain at least 1 element(s)',
path: [],
},
],
},
},
]),
});
expect(result).toMatchZod(expected);
});
test('Functional tests - patternProperties and properties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.union([z.array(z.any()), z.array(z.any()).min(1)]))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\,'))) {
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
});

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { parseOneOf } from '../../src/parsers/parse-one-of';
describe('parseOneOf', () => {
test('should create a union from two or more schemas', () => {
expect(
parseOneOf(
{
oneOf: [
{
type: 'string',
},
{ type: 'number' },
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.any().superRefine((x, ctx) => {
const schemas = [z.string(), z.number()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
);
});
test('should extract a single schema', () => {
expect(parseOneOf({ oneOf: [{ type: 'string' }] }, { path: [], seen: new Map() })).toMatchZod(
z.string(),
);
});
test('should return z.any() if array is empty', () => {
expect(parseOneOf({ oneOf: [] }, { path: [], seen: new Map() })).toMatchZod(z.any());
});
});

View File

@@ -0,0 +1,113 @@
import { z } from 'zod';
import { parseSchema } from '../../src/parsers/parse-schema';
describe('parseSchema', () => {
test('should be usable without providing refs', () => {
expect(parseSchema({ type: 'string' })).toMatchZod(z.string());
});
test('should return a seen and processed ref', () => {
const seen = new Map();
const schema = {
type: 'object',
properties: {
prop: {
type: 'string',
},
},
};
expect(parseSchema(schema, { seen, path: [] }));
expect(parseSchema(schema, { seen, path: [] }));
});
test('should be possible to describe a readonly schema', () => {
expect(parseSchema({ type: 'string', readOnly: true })).toMatchZod(z.string().readonly());
});
test('should handle nullable', () => {
expect(
parseSchema(
{
type: 'string',
nullable: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.string().nullable());
});
test('should handle enum', () => {
expect(parseSchema({ enum: ['someValue', 57] })).toMatchZod(
z.union([z.literal('someValue'), z.literal(57)]),
);
});
test('should handle multiple type', () => {
expect(parseSchema({ type: ['string', 'number'] })).toMatchZod(
z.union([z.string(), z.number()]),
);
});
test('should handle if-then-else type', () => {
expect(
parseSchema({
if: { type: 'string' },
then: { type: 'number' },
else: { type: 'boolean' },
}),
).toMatchZod(
z.union([z.number(), z.boolean()]).superRefine((value, ctx) => {
const result = z.string().safeParse(value).success
? z.number().safeParse(value)
: z.boolean().safeParse(value);
if (!result.success) {
result.error.errors.forEach((error) => ctx.addIssue(error));
}
}),
);
});
test('should handle anyOf', () => {
expect(
parseSchema({
anyOf: [
{
type: 'string',
},
{ type: 'number' },
],
}),
).toMatchZod(z.union([z.string(), z.number()]));
});
test('should handle oneOf', () => {
expect(
parseSchema({
oneOf: [
{
type: 'string',
},
{ type: 'number' },
],
}),
).toMatchZod(
z.any().superRefine((x, ctx) => {
const schemas = [z.string(), z.number()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
);
});
});

View File

@@ -0,0 +1,152 @@
import { z } from 'zod';
import { parseString } from '../../src/parsers/parse-string';
describe('parseString', () => {
const run = (schema: z.ZodString, data: unknown) => schema.safeParse(data);
test('DateTime format', () => {
const datetime = '2018-11-13T20:20:39Z';
const code = parseString({
type: 'string',
format: 'date-time',
errorMessage: { format: 'hello' },
});
expect(code).toMatchZod(z.string().datetime({ offset: true, message: 'hello' }));
expect(run(code, datetime)).toEqual({ success: true, data: datetime });
});
test('email', () => {
expect(
parseString({
type: 'string',
format: 'email',
}),
).toMatchZod(z.string().email());
});
test('ip', () => {
expect(
parseString({
type: 'string',
format: 'ip',
}),
).toMatchZod(z.string().ip());
expect(
parseString({
type: 'string',
format: 'ipv6',
}),
).toMatchZod(z.string().ip({ version: 'v6' }));
});
test('uri', () => {
expect(
parseString({
type: 'string',
format: 'uri',
}),
).toMatchZod(z.string().url());
});
test('uuid', () => {
expect(
parseString({
type: 'string',
format: 'uuid',
}),
).toMatchZod(z.string().uuid());
});
test('time', () => {
expect(
parseString({
type: 'string',
format: 'time',
}),
).toMatchZod(z.string().time());
});
test('date', () => {
expect(
parseString({
type: 'string',
format: 'date',
}),
).toMatchZod(z.string().date());
});
test('duration', () => {
expect(
parseString({
type: 'string',
format: 'duration',
}),
).toMatchZod(z.string().duration());
});
test('base64', () => {
expect(
parseString({
type: 'string',
contentEncoding: 'base64',
}),
).toMatchZod(z.string().base64());
expect(
parseString({
type: 'string',
contentEncoding: 'base64',
errorMessage: {
contentEncoding: 'x',
},
}),
).toMatchZod(z.string().base64('x'));
expect(
parseString({
type: 'string',
format: 'binary',
}),
).toMatchZod(z.string().base64());
expect(
parseString({
type: 'string',
format: 'binary',
errorMessage: {
format: 'x',
},
}),
).toMatchZod(z.string().base64('x'));
});
test('should accept errorMessage', () => {
expect(
parseString({
type: 'string',
format: 'ipv4',
pattern: 'x',
minLength: 1,
maxLength: 2,
errorMessage: {
format: 'ayy',
pattern: 'lmao',
minLength: 'deez',
maxLength: 'nuts',
},
}),
).toMatchZod(
z
.string()
.ip({ version: 'v4', message: 'ayy' })
.regex(new RegExp('x'), 'lmao')
.min(1, 'deez')
.max(2, 'nuts'),
);
});
});

View File

@@ -0,0 +1,15 @@
import { half } from '../../src/utils/half';
describe('half', () => {
test('half', () => {
const [a, b] = half(['A', 'B', 'C', 'D', 'E']);
if (1 < 0) {
// type should be string
a[0].endsWith('');
}
expect(a).toEqual(['A', 'B']);
expect(b).toEqual(['C', 'D', 'E']);
});
});

View File

@@ -0,0 +1,27 @@
import { omit } from '../../src/utils/omit';
describe('omit', () => {
test('omit', () => {
const input = {
a: true,
b: true,
};
omit(
input,
'b',
// @ts-expect-error
'c',
);
const output = omit(input, 'b');
// @ts-expect-error
output.b;
expect(output.a).toBe(true);
// @ts-expect-error
expect(output.b).toBeUndefined();
});
});