first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,4 @@
node_modules
dist
coverage
test/output
@@ -0,0 +1,3 @@
src
tsconfig*
test
+16
View File
@@ -0,0 +1,16 @@
ISC License
Copyright (c) 2024, n8n
Copyright (c) 2021, Stefan Terdell
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,34 @@
# Json-Schema-to-Zod
A package to convert JSON schema (draft 4+) objects into Zod schemas in the form of Zod objects at runtime.
## Installation
```sh
npm install @n8n/json-schema-to-zod
```
### Simple example
```typescript
import { jsonSchemaToZod } from "json-schema-to-zod";
const jsonSchema = {
type: "object",
properties: {
hello: {
type: "string",
},
},
};
const zodSchema = jsonSchemaToZod(myObject);
```
### Overriding a parser
You can pass a function to the `overrideParser` option, which represents a function that receives the current schema node and the reference object, and should return a zod object when it wants to replace a default output. If the default output should be used for the node just return undefined.
## Acknowledgements
This is a fork of [`json-schema-to-zod`](https://github.com/StefanTerdell/json-schema-to-zod).
@@ -0,0 +1,26 @@
import { defineConfig } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(
nodeConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'@typescript-eslint/no-duplicate-imports': 'off',
'import-x/no-cycle': 'off',
complexity: 'error',
// TODO: Remove this
'no-constant-condition': 'warn',
},
},
{
files: ['**/*.test.ts'],
rules: {
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }],
},
},
);
@@ -0,0 +1,5 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
setupFilesAfterEnv: ['<rootDir>/test/extend-expect.ts'],
};
@@ -0,0 +1,70 @@
{
"name": "@n8n/json-schema-to-zod",
"version": "1.6.0",
"description": "Converts JSON schema objects into Zod schemas",
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"exports": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
}
},
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"format": "biome format --write src",
"format:check": "biome ci src",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"build:types": "tsc -p tsconfig.types.json",
"build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.cjs",
"build:esm": "tsc -p tsconfig.esm.json && node postesm.cjs",
"build": "rimraf ./dist && pnpm run build:types && pnpm run build:cjs && pnpm run build:esm",
"dry": "pnpm run build && pnpm pub --dry-run",
"test": "jest",
"test:unit": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"zod",
"json",
"schema",
"converter",
"cli"
],
"author": "Stefan Terdell",
"contributors": [
"Chen (https://github.com/werifu)",
"Nuno Carduso (https://github.com/ncardoso-barracuda)",
"Lars Strojny (https://github.com/lstrojny)",
"Navtoj Chahal (https://github.com/navtoj)",
"Ben McCann (https://github.com/benmccann)",
"Dmitry Zakharov (https://github.com/DZakh)",
"Michel Turpin (https://github.com/grimly)",
"David Barratt (https://github.com/davidbarratt)",
"pevisscher (https://github.com/pevisscher)",
"Aidin Abedi (https://github.com/aidinabedi)",
"Brett Zamir (https://github.com/brettz9)",
"n8n (https://github.com/n8n-io)"
],
"license": "ISC",
"repository": {
"type": "git",
"url": "https://github.com/n8n-io/n8n"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/json-schema": "^7.0.15",
"zod": "catalog:"
}
}
@@ -0,0 +1 @@
require('fs').writeFileSync('./dist/cjs/package.json', '{"type":"commonjs"}', 'utf-8');
@@ -0,0 +1 @@
require('fs').writeFileSync('./dist/esm/package.json', '{"type":"module"}', 'utf-8');
@@ -0,0 +1,2 @@
export type * from './types';
export { jsonSchemaToZod } from './json-schema-to-zod';
@@ -0,0 +1,15 @@
import type { z } from 'zod';
import { parseSchema } from './parsers/parse-schema';
import type { JsonSchemaToZodOptions, JsonSchema } from './types';
export const jsonSchemaToZod = <T extends z.ZodTypeAny = z.ZodTypeAny>(
schema: JsonSchema,
options: JsonSchemaToZodOptions = {},
): T => {
return parseSchema(schema, {
path: [],
seen: new Map(),
...options,
}) as T;
};
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
import { half } from '../utils/half';
const originalIndex = Symbol('Original index');
const ensureOriginalIndex = (arr: JsonSchema[]) => {
const newArr = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (typeof item === 'boolean') {
newArr.push(item ? { [originalIndex]: i } : { [originalIndex]: i, not: {} });
} else if (originalIndex in item) {
return arr;
} else {
newArr.push({ ...item, [originalIndex]: i });
}
}
return newArr;
};
export function parseAllOf(
jsonSchema: JsonSchemaObject & { allOf: JsonSchema[] },
refs: Refs,
): z.ZodTypeAny {
if (jsonSchema.allOf.length === 0) {
return z.never();
}
if (jsonSchema.allOf.length === 1) {
const item = jsonSchema.allOf[0];
return parseSchema(item, {
...refs,
path: [...refs.path, 'allOf', (item as never)[originalIndex]],
});
}
const [left, right] = half(ensureOriginalIndex(jsonSchema.allOf));
return z.intersection(parseAllOf({ allOf: left }, refs), parseAllOf({ allOf: right }, refs));
}
@@ -0,0 +1,19 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseAnyOf = (jsonSchema: JsonSchemaObject & { anyOf: JsonSchema[] }, refs: Refs) => {
return jsonSchema.anyOf.length
? jsonSchema.anyOf.length === 1
? parseSchema(jsonSchema.anyOf[0], {
...refs,
path: [...refs.path, 'anyOf', 0],
})
: z.union(
jsonSchema.anyOf.map((schema, i) =>
parseSchema(schema, { ...refs, path: [...refs.path, 'anyOf', i] }),
) as [z.ZodTypeAny, z.ZodTypeAny],
)
: z.any();
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseArray = (jsonSchema: JsonSchemaObject & { type: 'array' }, refs: Refs) => {
if (Array.isArray(jsonSchema.items)) {
return z.tuple(
jsonSchema.items.map((v, i) =>
parseSchema(v, { ...refs, path: [...refs.path, 'items', i] }),
) as [z.ZodTypeAny],
);
}
let zodSchema = !jsonSchema.items
? z.array(z.any())
: z.array(parseSchema(jsonSchema.items, { ...refs, path: [...refs.path, 'items'] }));
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minItems',
(zs, minItems, errorMessage) => zs.min(minItems, errorMessage),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maxItems',
(zs, maxItems, errorMessage) => zs.max(maxItems, errorMessage),
);
return zodSchema;
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseBoolean = (_jsonSchema: JsonSchemaObject & { type: 'boolean' }) => {
return z.boolean();
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject, Serializable } from '../types';
export const parseConst = (jsonSchema: JsonSchemaObject & { const: Serializable }) => {
return z.literal(jsonSchema.const as z.Primitive);
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseDefault = (_jsonSchema: JsonSchemaObject) => {
return z.any();
};
@@ -0,0 +1,25 @@
import { z } from 'zod';
import type { JsonSchemaObject, Serializable } from '../types';
export const parseEnum = (jsonSchema: JsonSchemaObject & { enum: Serializable[] }) => {
if (jsonSchema.enum.length === 0) {
return z.never();
}
if (jsonSchema.enum.length === 1) {
// union does not work when there is only one element
return z.literal(jsonSchema.enum[0] as z.Primitive);
}
if (jsonSchema.enum.every((x) => typeof x === 'string')) {
return z.enum(jsonSchema.enum as [string]);
}
return z.union(
jsonSchema.enum.map((x) => z.literal(x as z.Primitive)) as unknown as [
z.ZodTypeAny,
z.ZodTypeAny,
],
);
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseIfThenElse = (
jsonSchema: JsonSchemaObject & {
if: JsonSchema;
then: JsonSchema;
else: JsonSchema;
},
refs: Refs,
) => {
const $if = parseSchema(jsonSchema.if, { ...refs, path: [...refs.path, 'if'] });
const $then = parseSchema(jsonSchema.then, {
...refs,
path: [...refs.path, 'then'],
});
const $else = parseSchema(jsonSchema.else, {
...refs,
path: [...refs.path, 'else'],
});
return z.union([$then, $else]).superRefine((value, ctx) => {
const result = $if.safeParse(value).success ? $then.safeParse(value) : $else.safeParse(value);
if (!result.success) {
result.error.errors.forEach((error) => ctx.addIssue(error));
}
});
};
@@ -0,0 +1,16 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchema, JsonSchemaObject, Refs } from '../types';
export const parseMultipleType = (
jsonSchema: JsonSchemaObject & { type: string[] },
refs: Refs,
) => {
return z.union(
jsonSchema.type.map((type) => parseSchema({ ...jsonSchema, type } as JsonSchema, refs)) as [
z.ZodTypeAny,
z.ZodTypeAny,
],
);
};
@@ -0,0 +1,15 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseNot = (jsonSchema: JsonSchemaObject & { not: JsonSchema }, refs: Refs) => {
return z.any().refine(
(value) =>
!parseSchema(jsonSchema.not, {
...refs,
path: [...refs.path, 'not'],
}).safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
);
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseNull = (_jsonSchema: JsonSchemaObject & { type: 'null' }) => {
return z.null();
};
@@ -0,0 +1,10 @@
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { omit } from '../utils/omit';
/**
* For compatibility with open api 3.0 nullable
*/
export const parseNullable = (jsonSchema: JsonSchemaObject & { nullable: true }, refs: Refs) => {
return parseSchema(omit(jsonSchema, 'nullable'), refs, true).nullable();
};
@@ -0,0 +1,88 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseNumber = (jsonSchema: JsonSchemaObject & { type: 'number' | 'integer' }) => {
let zodSchema = z.number();
let isInteger = false;
if (jsonSchema.type === 'integer') {
isInteger = true;
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'type', (zs, _, errorMsg) =>
zs.int(errorMsg),
);
} else if (jsonSchema.format === 'int64') {
isInteger = true;
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'format', (zs, _, errorMsg) =>
zs.int(errorMsg),
);
}
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'multipleOf',
(zs, multipleOf, errorMsg) => {
if (multipleOf === 1) {
if (isInteger) return zs;
return zs.int(errorMsg);
}
return zs.multipleOf(multipleOf, errorMsg);
},
);
if (typeof jsonSchema.minimum === 'number') {
if (jsonSchema.exclusiveMinimum === true) {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minimum',
(zs, minimum, errorMsg) => zs.gt(minimum, errorMsg),
);
} else {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minimum',
(zs, minimum, errorMsg) => zs.gte(minimum, errorMsg),
);
}
} else if (typeof jsonSchema.exclusiveMinimum === 'number') {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'exclusiveMinimum',
(zs, exclusiveMinimum, errorMsg) => zs.gt(exclusiveMinimum as number, errorMsg),
);
}
if (typeof jsonSchema.maximum === 'number') {
if (jsonSchema.exclusiveMaximum === true) {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maximum',
(zs, maximum, errorMsg) => zs.lt(maximum, errorMsg),
);
} else {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maximum',
(zs, maximum, errorMsg) => zs.lte(maximum, errorMsg),
);
}
} else if (typeof jsonSchema.exclusiveMaximum === 'number') {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'exclusiveMaximum',
(zs, exclusiveMaximum, errorMsg) => zs.lt(exclusiveMaximum as number, errorMsg),
);
}
return zodSchema;
};
@@ -0,0 +1,219 @@
import * as z from 'zod';
import { parseAllOf } from './parse-all-of';
import { parseAnyOf } from './parse-any-of';
import { parseOneOf } from './parse-one-of';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { its } from '../utils/its';
function parseObjectProperties(objectSchema: JsonSchemaObject & { type: 'object' }, refs: Refs) {
if (!objectSchema.properties) {
return undefined;
}
const propertyKeys = Object.keys(objectSchema.properties);
if (propertyKeys.length === 0) {
return z.object({});
}
const properties: Record<string, z.ZodTypeAny> = {};
for (const key of propertyKeys) {
const propJsonSchema = objectSchema.properties[key];
const propZodSchema = parseSchema(propJsonSchema, {
...refs,
path: [...refs.path, 'properties', key],
});
const hasDefault = typeof propJsonSchema === 'object' && propJsonSchema.default !== undefined;
const required = Array.isArray(objectSchema.required)
? objectSchema.required.includes(key)
: typeof propJsonSchema === 'object' && propJsonSchema.required === true;
const isOptional = !hasDefault && !required;
properties[key] = isOptional ? propZodSchema.optional() : propZodSchema;
}
return z.object(properties);
}
export function parseObject(
objectSchema: JsonSchemaObject & { type: 'object' },
refs: Refs,
): z.ZodTypeAny {
const hasPatternProperties = Object.keys(objectSchema.patternProperties ?? {}).length > 0;
const propertiesSchema:
| z.ZodObject<Record<string, z.ZodTypeAny>, 'strip', z.ZodTypeAny>
| undefined = parseObjectProperties(objectSchema, refs);
let zodSchema: z.ZodTypeAny | undefined = propertiesSchema;
const additionalProperties =
objectSchema.additionalProperties !== undefined
? parseSchema(objectSchema.additionalProperties, {
...refs,
path: [...refs.path, 'additionalProperties'],
})
: undefined;
if (objectSchema.patternProperties) {
const parsedPatternProperties = Object.fromEntries(
Object.entries(objectSchema.patternProperties).map(([key, value]) => {
return [
key,
parseSchema(value, {
...refs,
path: [...refs.path, 'patternProperties', key],
}),
];
}),
);
const patternPropertyValues = Object.values(parsedPatternProperties);
if (propertiesSchema) {
if (additionalProperties) {
zodSchema = propertiesSchema.catchall(
z.union([...patternPropertyValues, additionalProperties] as [z.ZodTypeAny, z.ZodTypeAny]),
);
} else if (Object.keys(parsedPatternProperties).length > 1) {
zodSchema = propertiesSchema.catchall(
z.union(patternPropertyValues as [z.ZodTypeAny, z.ZodTypeAny]),
);
} else {
zodSchema = propertiesSchema.catchall(patternPropertyValues[0]);
}
} else {
if (additionalProperties) {
zodSchema = z.record(
z.union([...patternPropertyValues, additionalProperties] as [z.ZodTypeAny, z.ZodTypeAny]),
);
} else if (patternPropertyValues.length > 1) {
zodSchema = z.record(z.union(patternPropertyValues as [z.ZodTypeAny, z.ZodTypeAny]));
} else {
zodSchema = z.record(patternPropertyValues[0]);
}
}
const objectPropertyKeys = new Set(Object.keys(objectSchema.properties ?? {}));
zodSchema = zodSchema.superRefine((value: Record<string, unknown>, ctx) => {
for (const key in value) {
let wasMatched = objectPropertyKeys.has(key);
for (const patternPropertyKey in objectSchema.patternProperties) {
const regex = new RegExp(patternPropertyKey);
if (key.match(regex)) {
wasMatched = true;
const result = parsedPatternProperties[patternPropertyKey].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 (!wasMatched && additionalProperties) {
const result = additionalProperties.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,
},
});
}
}
}
});
}
let output: z.ZodTypeAny;
if (propertiesSchema) {
if (hasPatternProperties) {
output = zodSchema!;
} else if (additionalProperties) {
if (additionalProperties instanceof z.ZodNever) {
output = propertiesSchema.strict();
} else {
output = propertiesSchema.catchall(additionalProperties);
}
} else {
output = zodSchema!;
}
} else {
if (hasPatternProperties) {
output = zodSchema!;
} else if (additionalProperties) {
output = z.record(additionalProperties);
} else {
output = z.record(z.any());
}
}
if (its.an.anyOf(objectSchema)) {
output = output.and(
parseAnyOf(
{
...objectSchema,
anyOf: objectSchema.anyOf.map((x) =>
typeof x === 'object' &&
!x.type &&
(x.properties ?? x.additionalProperties ?? x.patternProperties)
? { ...x, type: 'object' }
: x,
),
},
refs,
),
);
}
if (its.a.oneOf(objectSchema)) {
output = output.and(
parseOneOf(
{
...objectSchema,
oneOf: objectSchema.oneOf.map((x) =>
typeof x === 'object' &&
!x.type &&
(x.properties ?? x.additionalProperties ?? x.patternProperties)
? { ...x, type: 'object' }
: x,
),
},
refs,
),
);
}
if (its.an.allOf(objectSchema)) {
output = output.and(
parseAllOf(
{
...objectSchema,
allOf: objectSchema.allOf.map((x) =>
typeof x === 'object' &&
!x.type &&
(x.properties ?? x.additionalProperties ?? x.patternProperties)
? { ...x, type: 'object' }
: x,
),
},
refs,
),
);
}
return output;
}
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseOneOf = (jsonSchema: JsonSchemaObject & { oneOf: JsonSchema[] }, refs: Refs) => {
if (!jsonSchema.oneOf.length) {
return z.any();
}
if (jsonSchema.oneOf.length === 1) {
return parseSchema(jsonSchema.oneOf[0], {
...refs,
path: [...refs.path, 'oneOf', 0],
});
}
return z.any().superRefine((x, ctx) => {
const schemas = jsonSchema.oneOf.map((schema, i) =>
parseSchema(schema, {
...refs,
path: [...refs.path, 'oneOf', i],
}),
);
const unionErrors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
[],
);
if (schemas.length - unionErrors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors,
message: 'Invalid input: Should pass single schema',
});
}
});
};
@@ -0,0 +1,130 @@
import * as z from 'zod';
import { parseAllOf } from './parse-all-of';
import { parseAnyOf } from './parse-any-of';
import { parseArray } from './parse-array';
import { parseBoolean } from './parse-boolean';
import { parseConst } from './parse-const';
import { parseDefault } from './parse-default';
import { parseEnum } from './parse-enum';
import { parseIfThenElse } from './parse-if-then-else';
import { parseMultipleType } from './parse-multiple-type';
import { parseNot } from './parse-not';
import { parseNull } from './parse-null';
import { parseNullable } from './parse-nullable';
import { parseNumber } from './parse-number';
import { parseObject } from './parse-object';
import { parseOneOf } from './parse-one-of';
import { parseString } from './parse-string';
import type { ParserSelector, Refs, JsonSchemaObject, JsonSchema } from '../types';
import { its } from '../utils/its';
const addDescribes = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
if (jsonSchema.description) {
zodSchema = zodSchema.describe(jsonSchema.description);
}
return zodSchema;
};
const addDefaults = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
if (jsonSchema.default !== undefined) {
zodSchema = zodSchema.default(jsonSchema.default);
}
return zodSchema;
};
const addAnnotations = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
if (jsonSchema.readOnly) {
zodSchema = zodSchema.readonly();
}
return zodSchema;
};
const selectParser: ParserSelector = (schema, refs) => {
if (its.a.nullable(schema)) {
return parseNullable(schema, refs);
} else if (its.an.object(schema)) {
return parseObject(schema, refs);
} else if (its.an.array(schema)) {
return parseArray(schema, refs);
} else if (its.an.anyOf(schema)) {
return parseAnyOf(schema, refs);
} else if (its.an.allOf(schema)) {
return parseAllOf(schema, refs);
} else if (its.a.oneOf(schema)) {
return parseOneOf(schema, refs);
} else if (its.a.not(schema)) {
return parseNot(schema, refs);
} else if (its.an.enum(schema)) {
return parseEnum(schema); //<-- needs to come before primitives
} else if (its.a.const(schema)) {
return parseConst(schema);
} else if (its.a.multipleType(schema)) {
return parseMultipleType(schema, refs);
} else if (its.a.primitive(schema, 'string')) {
return parseString(schema);
} else if (its.a.primitive(schema, 'number') || its.a.primitive(schema, 'integer')) {
return parseNumber(schema);
} else if (its.a.primitive(schema, 'boolean')) {
return parseBoolean(schema);
} else if (its.a.primitive(schema, 'null')) {
return parseNull(schema);
} else if (its.a.conditional(schema)) {
return parseIfThenElse(schema, refs);
} else {
return parseDefault(schema);
}
};
export const parseSchema = (
jsonSchema: JsonSchema,
refs: Refs = { seen: new Map(), path: [] },
blockMeta?: boolean,
): z.ZodTypeAny => {
if (typeof jsonSchema !== 'object') return jsonSchema ? z.any() : z.never();
if (refs.parserOverride) {
const custom = refs.parserOverride(jsonSchema, refs);
if (custom instanceof z.ZodType) {
return custom;
}
}
let seen = refs.seen.get(jsonSchema);
if (seen) {
if (seen.r !== undefined) {
return seen.r;
}
if (refs.depth === undefined || seen.n >= refs.depth) {
return z.any();
}
seen.n += 1;
} else {
seen = { r: undefined, n: 0 };
refs.seen.set(jsonSchema, seen);
}
let parsedZodSchema = selectParser(jsonSchema, refs);
if (!blockMeta) {
if (!refs.withoutDescribes) {
parsedZodSchema = addDescribes(jsonSchema, parsedZodSchema);
}
if (!refs.withoutDefaults) {
parsedZodSchema = addDefaults(jsonSchema, parsedZodSchema);
}
parsedZodSchema = addAnnotations(jsonSchema, parsedZodSchema);
}
seen.r = parsedZodSchema;
return parsedZodSchema;
};
@@ -0,0 +1,58 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseString = (jsonSchema: JsonSchemaObject & { type: 'string' }) => {
let zodSchema = z.string();
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'format', (zs, format, errorMsg) => {
switch (format) {
case 'email':
return zs.email(errorMsg);
case 'ip':
return zs.ip(errorMsg);
case 'ipv4':
return zs.ip({ version: 'v4', message: errorMsg });
case 'ipv6':
return zs.ip({ version: 'v6', message: errorMsg });
case 'uri':
return zs.url(errorMsg);
case 'uuid':
return zs.uuid(errorMsg);
case 'date-time':
return zs.datetime({ offset: true, message: errorMsg });
case 'time':
return zs.time(errorMsg);
case 'date':
return zs.date(errorMsg);
case 'binary':
return zs.base64(errorMsg);
case 'duration':
return zs.duration(errorMsg);
default:
return zs;
}
});
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'contentEncoding', (zs, _, errorMsg) =>
zs.base64(errorMsg),
);
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'pattern', (zs, pattern, errorMsg) =>
zs.regex(new RegExp(pattern), errorMsg),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minLength',
(zs, minLength, errorMsg) => zs.min(minLength, errorMsg),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maxLength',
(zs, maxLength, errorMsg) => zs.max(maxLength, errorMsg),
);
return zodSchema;
};
@@ -0,0 +1,82 @@
import type { ZodTypeAny } from 'zod';
export type Serializable =
| { [key: string]: Serializable }
| Serializable[]
| string
| number
| boolean
| null;
export type JsonSchema = JsonSchemaObject | boolean;
export type JsonSchemaObject = {
// left permissive by design
type?: string | string[];
// object
properties?: { [key: string]: JsonSchema };
additionalProperties?: JsonSchema;
unevaluatedProperties?: JsonSchema;
patternProperties?: { [key: string]: JsonSchema };
minProperties?: number;
maxProperties?: number;
required?: string[] | boolean;
propertyNames?: JsonSchema;
// array
items?: JsonSchema | JsonSchema[];
additionalItems?: JsonSchema;
minItems?: number;
maxItems?: number;
uniqueItems?: boolean;
// string
minLength?: number;
maxLength?: number;
pattern?: string;
format?: string;
// number
minimum?: number;
maximum?: number;
exclusiveMinimum?: number | boolean;
exclusiveMaximum?: number | boolean;
multipleOf?: number;
// unions
anyOf?: JsonSchema[];
allOf?: JsonSchema[];
oneOf?: JsonSchema[];
if?: JsonSchema;
then?: JsonSchema;
else?: JsonSchema;
// shared
const?: Serializable;
enum?: Serializable[];
errorMessage?: { [key: string]: string | undefined };
description?: string;
default?: Serializable;
readOnly?: boolean;
not?: JsonSchema;
contentEncoding?: string;
nullable?: boolean;
};
export type ParserSelector = (schema: JsonSchemaObject, refs: Refs) => ZodTypeAny;
export type ParserOverride = (schema: JsonSchemaObject, refs: Refs) => ZodTypeAny | undefined;
export type JsonSchemaToZodOptions = {
withoutDefaults?: boolean;
withoutDescribes?: boolean;
parserOverride?: ParserOverride;
depth?: number;
};
export type Refs = JsonSchemaToZodOptions & {
path: Array<string | number>;
seen: Map<object | boolean, { n: number; r: ZodTypeAny | undefined }>;
};
@@ -0,0 +1,23 @@
import type { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export function extendSchemaWithMessage<
TZod extends z.ZodTypeAny,
TJson extends JsonSchemaObject,
TKey extends keyof TJson,
>(
zodSchema: TZod,
jsonSchema: TJson,
key: TKey,
extend: (zodSchema: TZod, value: NonNullable<TJson[TKey]>, errorMessage?: string) => TZod,
) {
const value = jsonSchema[key];
if (value !== undefined) {
const errorMessage = jsonSchema.errorMessage?.[key as string];
return extend(zodSchema, value as NonNullable<TJson[TKey]>, errorMessage);
}
return zodSchema;
}
@@ -0,0 +1,3 @@
export const half = <T>(arr: T[]): [T[], T[]] => {
return [arr.slice(0, arr.length / 2), arr.slice(arr.length / 2)];
};
@@ -0,0 +1,57 @@
import type { JsonSchema, JsonSchemaObject, Serializable } from '../types';
export const its = {
an: {
object: (x: JsonSchemaObject): x is JsonSchemaObject & { type: 'object' } =>
x.type === 'object',
array: (x: JsonSchemaObject): x is JsonSchemaObject & { type: 'array' } => x.type === 'array',
anyOf: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
anyOf: JsonSchema[];
} => x.anyOf !== undefined,
allOf: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
allOf: JsonSchema[];
} => x.allOf !== undefined,
enum: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
enum: Serializable | Serializable[];
} => x.enum !== undefined,
},
a: {
nullable: (x: JsonSchemaObject): x is JsonSchemaObject & { nullable: true } =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(x as any).nullable === true,
multipleType: (x: JsonSchemaObject): x is JsonSchemaObject & { type: string[] } =>
Array.isArray(x.type),
not: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
not: JsonSchema;
} => x.not !== undefined,
const: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
const: Serializable;
} => x.const !== undefined,
primitive: <T extends 'string' | 'number' | 'integer' | 'boolean' | 'null'>(
x: JsonSchemaObject,
p: T,
): x is JsonSchemaObject & { type: T } => x.type === p,
conditional: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
if: JsonSchema;
then: JsonSchema;
else: JsonSchema;
} => Boolean('if' in x && x.if && 'then' in x && 'else' in x && x.then && x.else),
oneOf: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
oneOf: JsonSchema[];
} => x.oneOf !== undefined,
},
};
@@ -0,0 +1,8 @@
export const omit = <T extends object, K extends keyof T>(obj: T, ...keys: K[]): Omit<T, K> =>
Object.keys(obj).reduce((acc: Record<string, unknown>, key) => {
if (!keys.includes(key as K)) {
acc[key] = obj[key as K];
}
return acc;
}, {}) as Omit<T, K>;
@@ -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
}
}
}
@@ -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}`,
};
},
});
+5
View File
@@ -0,0 +1,5 @@
namespace jest {
interface Matchers<R, T> {
toMatchZod(expected: unknown): T;
}
}
@@ -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())));
});
});
@@ -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',
),
),
);
});
});
@@ -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());
});
});
@@ -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));
});
});
@@ -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));
});
});
@@ -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)]));
});
});
@@ -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',
),
);
});
});
@@ -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));
});
});
@@ -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'));
});
});
@@ -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);
});
});
@@ -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());
});
});
@@ -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',
});
}
}),
);
});
});
@@ -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'),
);
});
});
@@ -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']);
});
});
@@ -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();
});
});
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "dist/cjs",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "es2020",
"moduleResolution": "node",
"outDir": "dist/esm",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
@@ -0,0 +1,12 @@
{
"extends": ["@n8n/typescript-config/tsconfig.common.json"],
"compilerOptions": {
"rootDir": ".",
"baseUrl": "src",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}