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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 3013 additions and 0 deletions

View file

@ -0,0 +1,21 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
...sharedOptions(__dirname),
ignorePatterns: ['jest.config.js'],
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'@typescript-eslint/no-duplicate-imports': 'off',
'import/no-cycle': 'off',
'n8n-local-rules/no-plain-errors': 'off',
complexity: 'error',
},
};

View file

@ -0,0 +1,4 @@
node_modules
dist
coverage
test/output

View file

@ -0,0 +1,3 @@
src
tsconfig*
test

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.

View file

@ -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).

View file

@ -0,0 +1,5 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
setupFilesAfterEnv: ['<rootDir>/test/extend-expect.ts'],
};

View file

@ -0,0 +1,69 @@
{
"name": "@n8n/json-schema-to-zod",
"version": "1.0.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 . --quiet",
"lintfix": "eslint . --fix",
"build:types": "tsc -p tsconfig.types.json",
"build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.js",
"build:esm": "tsc -p tsconfig.esm.json && node postesm.js",
"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: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": {
"@types/json-schema": "^7.0.15",
"@types/node": "^20.9.0",
"zod": "catalog:"
}
}

View file

@ -0,0 +1 @@
require('fs').writeFileSync('./dist/cjs/package.json', '{"type":"commonjs"}', 'utf-8');

View file

@ -0,0 +1 @@
require('fs').writeFileSync('./dist/esm/package.json', '{"type":"module"}', 'utf-8');

View file

@ -0,0 +1,2 @@
export type * from './types';
export { jsonSchemaToZod } from './json-schema-to-zod.js';

View file

@ -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;
};

View file

@ -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));
}

View file

@ -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();
};

View file

@ -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;
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseBoolean = (_jsonSchema: JsonSchemaObject & { type: 'boolean' }) => {
return z.boolean();
};

View file

@ -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);
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseDefault = (_jsonSchema: JsonSchemaObject) => {
return z.any();
};

View file

@ -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,
],
);
};

View file

@ -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));
}
});
};

View file

@ -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,
],
);
};

View file

@ -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',
);
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseNull = (_jsonSchema: JsonSchemaObject & { type: 'null' }) => {
return z.null();
};

View file

@ -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();
};

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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',
});
}
});
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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 }>;
};

View file

@ -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;
}

View file

@ -0,0 +1,3 @@
export const half = <T>(arr: T[]): [T[], T[]] => {
return [arr.slice(0, arr.length / 2), arr.slice(arr.length / 2)];
};

View file

@ -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,
},
};

View file

@ -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>;

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();
});
});

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "dist/cjs",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "es2020",
"moduleResolution": "node",
"outDir": "dist/esm",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

View file

@ -0,0 +1,12 @@
{
"extends": ["../../../tsconfig.json"],
"compilerOptions": {
"rootDir": ".",
"baseUrl": "src",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

View file

@ -394,6 +394,18 @@ importers:
specifier: ^0.0.3
version: 0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4)
packages/@n8n/json-schema-to-zod:
devDependencies:
'@types/json-schema':
specifier: ^7.0.15
version: 7.0.15
'@types/node':
specifier: ^18.16.16
version: 18.16.16
zod:
specifier: 'catalog:'
version: 3.23.8
packages/@n8n/nodes-langchain:
dependencies:
'@aws-sdk/client-sso-oidc':