diff --git a/README.md b/README.md index a3c888695..ac94bd8e4 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ - [Cyclical objects](#cyclical-objects) - [Promises](#promises) - [Instanceof](#instanceof) +- [Files](#files) - [Functions](#functions) - [Preprocess](#preprocess) - [Custom schemas](#custom-schemas) @@ -1925,6 +1926,34 @@ TestSchema.parse(new Test()); // passes TestSchema.parse(blob); // throws ``` +## Files + +This basic implementation validates the input type. As usual, you can set custom error messages. + +```ts +const imageFile = z.file({ + required_error: "file is required", + invalid_type_error: "This object must be a file", +}); +``` + +You can validate the file's minimum and maximum size. + +```ts +const imageFile = z.file().size({ min: 100000, max: 200000 }); +``` + +You check again min and max size of the file. + +```ts +// Validate MIME type for any image +const imageFileAny = z.file().mimeType("image/*"); + +// Validate specific MIME types +const imageFileJpegPng = z.file().mimeType("image/jpeg,image/png"); +const imageFileJpegPngArray = z.file().mimeType(["image/jpeg", "image/png"]); +``` + ## Functions Zod also lets you define "function schemas". This makes it easy to validate the inputs and outputs of a function without intermixing your validation code and "business logic". diff --git a/deno/lib/README.md b/deno/lib/README.md index a3c888695..ac94bd8e4 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -116,6 +116,7 @@ - [Cyclical objects](#cyclical-objects) - [Promises](#promises) - [Instanceof](#instanceof) +- [Files](#files) - [Functions](#functions) - [Preprocess](#preprocess) - [Custom schemas](#custom-schemas) @@ -1925,6 +1926,34 @@ TestSchema.parse(new Test()); // passes TestSchema.parse(blob); // throws ``` +## Files + +This basic implementation validates the input type. As usual, you can set custom error messages. + +```ts +const imageFile = z.file({ + required_error: "file is required", + invalid_type_error: "This object must be a file", +}); +``` + +You can validate the file's minimum and maximum size. + +```ts +const imageFile = z.file().size({ min: 100000, max: 200000 }); +``` + +You check again min and max size of the file. + +```ts +// Validate MIME type for any image +const imageFileAny = z.file().mimeType("image/*"); + +// Validate specific MIME types +const imageFileJpegPng = z.file().mimeType("image/jpeg,image/png"); +const imageFileJpegPngArray = z.file().mimeType(["image/jpeg", "image/png"]); +``` + ## Functions Zod also lets you define "function schemas". This makes it easy to validate the inputs and outputs of a function without intermixing your validation code and "business logic". diff --git a/deno/lib/__tests__/file.test.ts b/deno/lib/__tests__/file.test.ts new file mode 100644 index 000000000..77fecf721 --- /dev/null +++ b/deno/lib/__tests__/file.test.ts @@ -0,0 +1,49 @@ +// @ts-ignore TS6133 +import { expect } from "https://deno.land/x/expect@v0.2.6/mod.ts"; +const test = Deno.test; + +import * as z from "../index.ts"; + +class MockFile { + static create(size?: number, name?: string, mimeType?: string) { + name = name || "mock.txt"; + size = size || 1024; + mimeType = mimeType || "plain/txt"; + + function range(count: number) { + let output = ""; + for (let i = 0; i < count; i++) { + output += "a"; + } + return output; + } + + return new File([range(size)], name, { type: mimeType }); + } +} + +const basicSchema = z.file().size({ max: 50000, min: 1024 }); +const imageSchema = z + .file() + .size({ min: 20000 }) + .mimeType("image/*,image/jpeg,image/png", "Invalide mime type"); + +test("passing validations", () => { + expect(basicSchema.parse(MockFile.create(1024))).toBeInstanceOf(File); + expect(basicSchema.parse(MockFile.create(50000))).toBeInstanceOf(File); + expect(basicSchema.parse(MockFile.create(2014))).toBeInstanceOf(File); + expect( + imageSchema.parse(MockFile.create(20000, "mock.jpeg", "image/jpeg")) + ).toBeInstanceOf(File); +}); + +test("failing validations", () => { + expect(() => basicSchema.parse(MockFile.create(1023))).toThrow(); + expect(() => basicSchema.parse(MockFile.create(50001))).toThrow(); + expect(() => + imageSchema.parse(MockFile.create(20000, "mock.jpeg", "video/mp4")) + ).toThrow(); + expect(() => basicSchema.parse(undefined)).toThrow(); + expect(() => basicSchema.parse({})).toThrow(); + expect(() => basicSchema.parse([])).toThrow(); +}); diff --git a/deno/lib/__tests__/firstparty.test.ts b/deno/lib/__tests__/firstparty.test.ts index 5df8a71bd..bb7b2a1a2 100644 --- a/deno/lib/__tests__/firstparty.test.ts +++ b/deno/lib/__tests__/firstparty.test.ts @@ -52,6 +52,8 @@ test("first party switch", () => { break; case z.ZodFirstPartyTypeKind.ZodSet: break; + case z.ZodFirstPartyTypeKind.ZodFile: + break; case z.ZodFirstPartyTypeKind.ZodFunction: break; case z.ZodFirstPartyTypeKind.ZodLazy: diff --git a/deno/lib/helpers/util.ts b/deno/lib/helpers/util.ts index 058da47fa..de9b8b931 100644 --- a/deno/lib/helpers/util.ts +++ b/deno/lib/helpers/util.ts @@ -148,6 +148,7 @@ export const ZodParsedType = util.arrayToEnum([ "date", "bigint", "symbol", + "file", "function", "undefined", "null", @@ -189,6 +190,9 @@ export const getParsedType = (data: any): ZodParsedType => { return ZodParsedType.symbol; case "object": + if (data instanceof File) { + return ZodParsedType.file; + } if (Array.isArray(data)) { return ZodParsedType.array; } diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 5d020d278..c819cdb2a 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -3847,6 +3847,134 @@ export class ZodSet extends ZodType< }; } +/////////////////////////////////////////// +/////////////////////////////////////////// +////////// ////////// +////////// ZodFile ////////// +////////// ////////// +/////////////////////////////////////////// +/////////////////////////////////////////// +type SizeRange = { min?: number; max?: number }; + +export type ZodFileCheck = + | { kind: "size"; value: SizeRange; message?: string } + | { kind: "mime_type"; value: string | string[]; message?: string }; + +export interface ZodFileDef extends ZodTypeDef { + checks: ZodFileCheck[]; + typeName: ZodFirstPartyTypeKind.ZodFile; +} + +export class ZodFile extends ZodType { + size(range: SizeRange, message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "size", + value: range, + message: errorUtil.toString(message), + }); + } + + mimeType(type: string | string[], message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "mime_type", + value: type, + message: errorUtil.toString(message), + }); + } + + _parse(input: ParseInput): ParseReturnType { + const parsedType = this._getType(input); + + if (parsedType !== ZodParsedType.file) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.file, + received: ctx.parsedType, + }); + return INVALID; + } + + const file = input.data as File; + + const status = new ParseStatus(); + + for (const check of this._def.checks) { + const ctx = this._getOrReturnCtx(input); + + if ( + check.kind === "size" && + check.value.min && + check.value.min > file.size + ) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check.value.min, + type: ZodParsedType.number, + inclusive: true, + exact: false, + message: check.message, + }); + + status.dirty(); + } else if ( + check.kind === "size" && + check.value.max && + check.value.max < file.size + ) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check.value.max, + type: ZodParsedType.number, + inclusive: true, + exact: false, + message: check.message, + }); + + status.dirty(); + } else if (check.kind === "mime_type") { + const mimeTypes = Array.isArray(check.value) + ? check.value + : check.value.split(",").map((s) => s.trim()); + + const state = mimeTypes.reduce((state, currentMimeType) => { + const regex = new RegExp(currentMimeType, "gm"); + + return state || regex.test(file.type); + }, false); + + if (!state) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.object, + received: ctx.parsedType, + message: check.message, + }); + + status.dirty(); + } + } + } + + return { status: status.value, value: input.data }; + } + + _addCheck(check: ZodFileCheck) { + return new ZodFile({ + ...this._def, + checks: [...this._def.checks, check], + }); + } + + static create = (params?: RawCreateParams): ZodFile => { + return new ZodFile({ + typeName: ZodFirstPartyTypeKind.ZodFile, + checks: [], + ...processCreateParams(params), + }); + }; +} + /////////////////////////////////////////// /////////////////////////////////////////// ////////// ////////// @@ -5117,6 +5245,7 @@ export const late = { }; export enum ZodFirstPartyTypeKind { + ZodFile = "ZodFile", ZodString = "ZodString", ZodNumber = "ZodNumber", ZodNaN = "ZodNaN", @@ -5176,6 +5305,7 @@ export type ZodFirstPartySchemaTypes = | ZodRecord | ZodMap | ZodSet + | ZodFile | ZodFunction | ZodLazy | ZodLiteral @@ -5227,6 +5357,7 @@ const tupleType = ZodTuple.create; const recordType = ZodRecord.create; const mapType = ZodMap.create; const setType = ZodSet.create; +const fileType = ZodFile.create; const functionType = ZodFunction.create; const lazyType = ZodLazy.create; const literalType = ZodLiteral.create; @@ -5267,6 +5398,7 @@ export { discriminatedUnionType as discriminatedUnion, effectsType as effect, enumType as enum, + fileType as file, functionType as function, instanceOfType as instanceof, intersectionType as intersection, diff --git a/src/__tests__/file.test.ts b/src/__tests__/file.test.ts new file mode 100644 index 000000000..8645a984f --- /dev/null +++ b/src/__tests__/file.test.ts @@ -0,0 +1,48 @@ +// @ts-ignore TS6133 +import { expect, test } from "@jest/globals"; + +import * as z from "../index"; + +class MockFile { + static create(size?: number, name?: string, mimeType?: string) { + name = name || "mock.txt"; + size = size || 1024; + mimeType = mimeType || "plain/txt"; + + function range(count: number) { + let output = ""; + for (let i = 0; i < count; i++) { + output += "a"; + } + return output; + } + + return new File([range(size)], name, { type: mimeType }); + } +} + +const basicSchema = z.file().size({ max: 50000, min: 1024 }); +const imageSchema = z + .file() + .size({ min: 20000 }) + .mimeType("image/*,image/jpeg,image/png", "Invalide mime type"); + +test("passing validations", () => { + expect(basicSchema.parse(MockFile.create(1024))).toBeInstanceOf(File); + expect(basicSchema.parse(MockFile.create(50000))).toBeInstanceOf(File); + expect(basicSchema.parse(MockFile.create(2014))).toBeInstanceOf(File); + expect( + imageSchema.parse(MockFile.create(20000, "mock.jpeg", "image/jpeg")) + ).toBeInstanceOf(File); +}); + +test("failing validations", () => { + expect(() => basicSchema.parse(MockFile.create(1023))).toThrow(); + expect(() => basicSchema.parse(MockFile.create(50001))).toThrow(); + expect(() => + imageSchema.parse(MockFile.create(20000, "mock.jpeg", "video/mp4")) + ).toThrow(); + expect(() => basicSchema.parse(undefined)).toThrow(); + expect(() => basicSchema.parse({})).toThrow(); + expect(() => basicSchema.parse([])).toThrow(); +}); diff --git a/src/__tests__/firstparty.test.ts b/src/__tests__/firstparty.test.ts index 2ad9cf88f..e85269747 100644 --- a/src/__tests__/firstparty.test.ts +++ b/src/__tests__/firstparty.test.ts @@ -51,6 +51,8 @@ test("first party switch", () => { break; case z.ZodFirstPartyTypeKind.ZodSet: break; + case z.ZodFirstPartyTypeKind.ZodFile: + break; case z.ZodFirstPartyTypeKind.ZodFunction: break; case z.ZodFirstPartyTypeKind.ZodLazy: diff --git a/src/helpers/util.ts b/src/helpers/util.ts index 058da47fa..de9b8b931 100644 --- a/src/helpers/util.ts +++ b/src/helpers/util.ts @@ -148,6 +148,7 @@ export const ZodParsedType = util.arrayToEnum([ "date", "bigint", "symbol", + "file", "function", "undefined", "null", @@ -189,6 +190,9 @@ export const getParsedType = (data: any): ZodParsedType => { return ZodParsedType.symbol; case "object": + if (data instanceof File) { + return ZodParsedType.file; + } if (Array.isArray(data)) { return ZodParsedType.array; } diff --git a/src/types.ts b/src/types.ts index f3730ae14..458e16d28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3847,6 +3847,134 @@ export class ZodSet extends ZodType< }; } +/////////////////////////////////////////// +/////////////////////////////////////////// +////////// ////////// +////////// ZodFile ////////// +////////// ////////// +/////////////////////////////////////////// +/////////////////////////////////////////// +type SizeRange = { min?: number; max?: number }; + +export type ZodFileCheck = + | { kind: "size"; value: SizeRange; message?: string } + | { kind: "mime_type"; value: string | string[]; message?: string }; + +export interface ZodFileDef extends ZodTypeDef { + checks: ZodFileCheck[]; + typeName: ZodFirstPartyTypeKind.ZodFile; +} + +export class ZodFile extends ZodType { + size(range: SizeRange, message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "size", + value: range, + message: errorUtil.toString(message), + }); + } + + mimeType(type: string | string[], message?: errorUtil.ErrMessage) { + return this._addCheck({ + kind: "mime_type", + value: type, + message: errorUtil.toString(message), + }); + } + + _parse(input: ParseInput): ParseReturnType { + const parsedType = this._getType(input); + + if (parsedType !== ZodParsedType.file) { + const ctx = this._getOrReturnCtx(input); + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.file, + received: ctx.parsedType, + }); + return INVALID; + } + + const file = input.data as File; + + const status = new ParseStatus(); + + for (const check of this._def.checks) { + const ctx = this._getOrReturnCtx(input); + + if ( + check.kind === "size" && + check.value.min && + check.value.min > file.size + ) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_small, + minimum: check.value.min, + type: ZodParsedType.number, + inclusive: true, + exact: false, + message: check.message, + }); + + status.dirty(); + } else if ( + check.kind === "size" && + check.value.max && + check.value.max < file.size + ) { + addIssueToContext(ctx, { + code: ZodIssueCode.too_big, + maximum: check.value.max, + type: ZodParsedType.number, + inclusive: true, + exact: false, + message: check.message, + }); + + status.dirty(); + } else if (check.kind === "mime_type") { + const mimeTypes = Array.isArray(check.value) + ? check.value + : check.value.split(",").map((s) => s.trim()); + + const state = mimeTypes.reduce((state, currentMimeType) => { + const regex = new RegExp(currentMimeType, "gm"); + + return state || regex.test(file.type); + }, false); + + if (!state) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_type, + expected: ZodParsedType.object, + received: ctx.parsedType, + message: check.message, + }); + + status.dirty(); + } + } + } + + return { status: status.value, value: input.data }; + } + + _addCheck(check: ZodFileCheck) { + return new ZodFile({ + ...this._def, + checks: [...this._def.checks, check], + }); + } + + static create = (params?: RawCreateParams): ZodFile => { + return new ZodFile({ + typeName: ZodFirstPartyTypeKind.ZodFile, + checks: [], + ...processCreateParams(params), + }); + }; +} + /////////////////////////////////////////// /////////////////////////////////////////// ////////// ////////// @@ -5117,6 +5245,7 @@ export const late = { }; export enum ZodFirstPartyTypeKind { + ZodFile = "ZodFile", ZodString = "ZodString", ZodNumber = "ZodNumber", ZodNaN = "ZodNaN", @@ -5176,6 +5305,7 @@ export type ZodFirstPartySchemaTypes = | ZodRecord | ZodMap | ZodSet + | ZodFile | ZodFunction | ZodLazy | ZodLiteral @@ -5227,6 +5357,7 @@ const tupleType = ZodTuple.create; const recordType = ZodRecord.create; const mapType = ZodMap.create; const setType = ZodSet.create; +const fileType = ZodFile.create; const functionType = ZodFunction.create; const lazyType = ZodLazy.create; const literalType = ZodLiteral.create; @@ -5267,6 +5398,7 @@ export { discriminatedUnionType as discriminatedUnion, effectsType as effect, enumType as enum, + fileType as file, functionType as function, instanceOfType as instanceof, intersectionType as intersection,