From 63372a5c95243937038a45014f6ee1f667da69fe Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 15:22:05 +0100 Subject: [PATCH 01/20] feat: support more input parsers --- packages/uploadthing/package.json | 2 + packages/uploadthing/src/internal/handler.ts | 2 +- packages/uploadthing/src/internal/parser.ts | 43 +++++-- packages/uploadthing/src/internal/types.ts | 6 +- .../src/internal/upload-builder.ts | 2 +- .../uploadthing/test/upload-builder.test.ts | 63 +++++++++- pnpm-lock.yaml | 119 +++++++++++------- 7 files changed, 172 insertions(+), 65 deletions(-) diff --git a/packages/uploadthing/package.json b/packages/uploadthing/package.json index 46b9b771d5..a8b4b8f296 100644 --- a/packages/uploadthing/package.json +++ b/packages/uploadthing/package.json @@ -151,6 +151,7 @@ }, "dependencies": { "@effect/platform": "0.69.24", + "@standard-schema/spec": "1.0.0-beta.3", "@uploadthing/mime-types": "workspace:*", "@uploadthing/shared": "workspace:*", "effect": "3.10.15" @@ -176,6 +177,7 @@ "type-fest": "^4.10.3", "typescript": "^5.5.2", "undici": "^6.6.2", + "valibot": "1.0.0-beta.7", "vue": "^3.4.21", "wait-on": "^7.2.0", "zod": "^3.23.8" diff --git a/packages/uploadthing/src/internal/handler.ts b/packages/uploadthing/src/internal/handler.ts index 23e4ff1114..727fdbaaa5 100644 --- a/packages/uploadthing/src/internal/handler.ts +++ b/packages/uploadthing/src/internal/handler.ts @@ -460,7 +460,7 @@ const handleUploadAction = (opts: { // validate the input yield* Effect.logDebug("Parsing user input"); const parsedInput = yield* Effect.tryPromise({ - try: async () => getParseFn(uploadable.inputParser)(json.input), + try: () => getParseFn(uploadable.inputParser)(json.input), catch: (error) => new UploadThingError({ code: "BAD_REQUEST", diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index ab01f22b78..ff189d3121 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -1,25 +1,48 @@ -import type { Json, MaybePromise } from "@uploadthing/shared"; +import type { v1 } from "@standard-schema/spec"; + +import type { Json } from "@uploadthing/shared"; /** * TODO: Do we wanna support effect/schema parsers now?? */ -// Don't want to use Zod cause it's an optional dependency -export type ParseFn = (input: unknown) => MaybePromise; +export type ParseFn = (input: unknown) => Promise; + export type ParserZodEsque = { _input: TInput; _output: TParsedInput; // if using .transform etc - parse: ParseFn; + parseAsync: ParseFn; }; +export type ParserStandardSchemaEsque = v1.StandardSchema< + TInput, + TParsedInput +>; + // In case we add support for more parsers later -export type JsonParser = ParserZodEsque; +export type JsonParser = + | ParserZodEsque + | ParserStandardSchemaEsque; + +export function getParseFn< + TOut extends Json, + TParser extends JsonParser, +>(parser: TParser): ParseFn { + if ("~standard" in parser) { + return async (value) => { + const result = await parser["~standard"].validate(value); + if (result.issues) { + throw new Error( + "Input validation failed. See validation issues in the error cause.", + { cause: result.issues }, + ); + } + return result.value; + }; + } -export function getParseFn( - parser: TParser, -): ParseFn { - if (typeof parser.parse === "function") { - return parser.parse; + if (typeof parser.parseAsync === "function") { + return parser.parseAsync; } throw new Error("Invalid parser"); diff --git a/packages/uploadthing/src/internal/types.ts b/packages/uploadthing/src/internal/types.ts index 2a3c410611..40527fcb60 100644 --- a/packages/uploadthing/src/internal/types.ts +++ b/packages/uploadthing/src/internal/types.ts @@ -97,13 +97,13 @@ type UploadErrorFn> = ( ) => MaybePromise; export interface UploadBuilder { - input: ( + input: ( parser: TParams["_input"]["in"] extends UnsetMarker - ? TParser + ? JsonParser : ErrorMessage<"input is already set">, ) => UploadBuilder<{ _routeOptions: TParams["_routeOptions"]; - _input: { in: TParser["_input"]; out: TParser["_output"] }; + _input: { in: TIn; out: TOut }; _metadata: TParams["_metadata"]; _adapterFnArgs: TParams["_adapterFnArgs"]; _errorShape: TParams["_errorShape"]; diff --git a/packages/uploadthing/src/internal/upload-builder.ts b/packages/uploadthing/src/internal/upload-builder.ts index fb85c90c49..c041e879a3 100644 --- a/packages/uploadthing/src/internal/upload-builder.ts +++ b/packages/uploadthing/src/internal/upload-builder.ts @@ -42,7 +42,7 @@ function internalCreateBuilder< }, inputParser: { - parse: () => undefined, + parseAsync: () => Promise.resolve(undefined), _input: undefined, _output: undefined, }, diff --git a/packages/uploadthing/test/upload-builder.test.ts b/packages/uploadthing/test/upload-builder.test.ts index ed9d477f06..7b75f8df64 100644 --- a/packages/uploadthing/test/upload-builder.test.ts +++ b/packages/uploadthing/test/upload-builder.test.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import * as v from "valibot"; import { expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; +import { getParseFn } from "../src/internal/parser"; import { UTFiles } from "../src/internal/types"; import { createBuilder } from "../src/internal/upload-builder"; @@ -115,6 +117,13 @@ it("with input", () => { expectTypeOf<{ foo: string }>(opts.input); return {}; }); + + f(["image"]) + .input(v.object({ foo: v.string() })) + .middleware((opts) => { + expectTypeOf<{ foo: string }>(opts.input); + return {}; + }); }); it("with optional input", () => { @@ -125,6 +134,13 @@ it("with optional input", () => { expectTypeOf<{ foo: string } | undefined>(opts.input); return {}; }); + + f(["image"]) + .input(v.optional(v.object({ foo: v.string() }))) + .middleware((opts) => { + expectTypeOf<{ foo: string } | undefined>(opts.input); + return {}; + }); }); it("can append a customId", () => { @@ -140,7 +156,7 @@ it("can append a customId", () => { }); }); -it("smoke", async () => { +it("smoke (zod)", async () => { const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); const uploadable = f(["image", "video"]) @@ -167,11 +183,54 @@ it("smoke", async () => { expect(uploadable.routerConfig).toEqual(["image", "video"]); + const parsedInput = await getParseFn(uploadable.inputParser)({ foo: "bar" }); + + const metadata = await uploadable.middleware({ + req: new Request("http://localhost", { + headers: { header1: "woohoo" }, + }), + input: parsedInput, + res: undefined, + event: undefined, + files: [{ name: "test.txt", size: 123456, type: "text/plain" }], + }); + expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); +}); + +it("smoke (valibot)", async () => { + const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); + + const uploadable = f(["image", "video"]) + .input(v.object({ foo: v.string() })) + .middleware((opts) => { + expect(opts.input).toEqual({ foo: "bar" }); + expectTypeOf<{ foo: string }>(opts.input); + expectTypeOf(opts.files); + + const header1 = opts.req.headers.get("header1"); + + return { header1, userId: "123" as const }; + }) + .onUploadComplete(({ file, metadata }) => { + // expect(file).toEqual({ name: "file", url: "http://localhost" }) + expectTypeOf<{ name: string; url: string }>(file); + + expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); + expectTypeOf<{ + header1: string | null; + userId: "123"; + }>(metadata); + }); + + expect(uploadable.routerConfig).toEqual(["image", "video"]); + + const parsedInput = await getParseFn(uploadable.inputParser)({ foo: "bar" }); + const metadata = await uploadable.middleware({ req: new Request("http://localhost", { headers: { header1: "woohoo" }, }), - input: { foo: "bar" }, + input: parsedInput, res: undefined, event: undefined, files: [{ name: "test.txt", size: 123456, type: "text/plain" }], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61883067a7..88c1bf682c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1622,6 +1622,9 @@ importers: '@effect/platform': specifier: 0.69.24 version: 0.69.24(effect@3.10.15) + '@standard-schema/spec': + specifier: 1.0.0-beta.3 + version: 1.0.0-beta.3 '@uploadthing/mime-types': specifier: workspace:* version: link:../mime-types @@ -1692,6 +1695,9 @@ importers: undici: specifier: ^6.6.2 version: 6.19.8 + valibot: + specifier: 1.0.0-beta.7 + version: 1.0.0-beta.7(typescript@5.6.2) vue: specifier: ^3.4.21 version: 3.4.25(typescript@5.6.2) @@ -1755,7 +1761,7 @@ importers: version: 3.10.15 next: specifier: canary - version: 15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -1792,7 +1798,7 @@ importers: dependencies: '@uploadthing/react': specifier: npm:@uploadthing/react@6 - version: 6.8.0(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(solid-js@1.8.23)(svelte@4.2.15)(uploadthing@6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14))(vue@3.4.25(typescript@5.6.3)) + version: 6.8.0(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(solid-js@1.8.23)(svelte@4.2.15)(uploadthing@6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14))(vue@3.4.25(typescript@5.6.3)) clsx: specifier: 2.1.1 version: 2.1.1 @@ -1801,7 +1807,7 @@ importers: version: 3.10.15 next: specifier: canary - version: 15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -1810,7 +1816,7 @@ importers: version: 18.3.1(react@18.3.1) uploadthing: specifier: npm:uploadthing@6 - version: 6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14) + version: 6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14) zod: specifier: 3.23.8 version: 3.23.8 @@ -4726,8 +4732,8 @@ packages: '@next/env@14.2.11': resolution: {integrity: sha512-HYsQRSIXwiNqvzzYThrBwq6RhXo3E0n8j8nQnAs8i4fCEo2Zf/3eS0IiRA8XnRg9Ha0YnpkyJZIZg1qEwemrHw==} - '@next/env@15.0.4-canary.17': - resolution: {integrity: sha512-WF84xjqTv+XagkwS7T5MCHgXr8TkpPGuKJjInfOcubAATfoOJ5L2liK7s9coDa09VLsnfMq4X01xT0DUv7wLyw==} + '@next/env@15.0.4-canary.19': + resolution: {integrity: sha512-OpioeuVzT9OsXVjefodLQ1B/US5lUlio5dwoJH7NLRXgYqOUBybFDDZOQ+PXzF0f55bFYjsoRFq6YnZzVkj1lA==} '@next/eslint-plugin-next@14.2.2': resolution: {integrity: sha512-q+Ec2648JtBpKiu/FSJm8HAsFXlNvioHeBCbTP12T1SGcHYwhqHULSfQgFkPgHDu3kzNp2Kem4J54bK4rPQ5SQ==} @@ -4752,8 +4758,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@15.0.4-canary.17': - resolution: {integrity: sha512-zyH2kwDEvuKKmL0zHGbFy7DCDc3Bd+MZC8RRaR4eAxs8j9NWWzMyg7Di8equTwHja3nk5sMLahRk7XGVkOchoA==} + '@next/swc-darwin-arm64@15.0.4-canary.19': + resolution: {integrity: sha512-n8BF+fPreS7uBUQO/6+F13FVtV/namLoS+9nqIYXL4ZyYVYWfIJKPDbjqoUK8rOnvAlBDoBSXbN9VsmMdW5sQQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -4764,8 +4770,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@15.0.4-canary.17': - resolution: {integrity: sha512-pHUT9kNoYIUdu4ciTIThWd2J0FLIrnSov7OZ+GUVfgxmHBSoFFvHio3eRZnD4hj4ifJ5dU4BR0QZYe1CPafYaw==} + '@next/swc-darwin-x64@15.0.4-canary.19': + resolution: {integrity: sha512-iDyFGSUuxPeCCOwuo5nj8usUiZXwmtQOM0RpTSO1GAGIeDH49BWWqEYIiRFcS8dhEyDGZNl3SlRS4xmHaeT8cg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -4776,8 +4782,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@15.0.4-canary.17': - resolution: {integrity: sha512-X5KlH327Dn3crLCd+2VuH57FoIgg3XOr/7+MxBH3wpDyihT2QopU/C5tAOe2kcY3d0DBpytuTKtOoV3oQ2T57w==} + '@next/swc-linux-arm64-gnu@15.0.4-canary.19': + resolution: {integrity: sha512-qV2xF7V1yLuNnDZQ6bkzDuZbUGV7ofYv0Q9IgogPTx2pwChpwSXVK2OQAoU9BqVcIMBThVdVa5ILMWvoG5TfEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -4788,8 +4794,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.0.4-canary.17': - resolution: {integrity: sha512-yWQGdGTH7SYE9RT0z+Dls8UxqxpdjiNJglWz1kL819Zovh1LNDUPWSk1Vb1w6z+bxX+Fo54ttxqeh2FIbjl9Iw==} + '@next/swc-linux-arm64-musl@15.0.4-canary.19': + resolution: {integrity: sha512-nykBD1+5RrtqC5fT87wt3CPx04q8tUKKCixHzdmXIcycEYP+RTEucq04gvEITkHuC6DYg9IOGKeqlQqSugRYWA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -4800,8 +4806,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@15.0.4-canary.17': - resolution: {integrity: sha512-195PFWM+Ah+TigBZG2qBZyo7pciB4BY/QarizMcr9rUDAXejZT7AYM2f4rkAQMbz0jftgZEuFjqOfCP0cbGcMg==} + '@next/swc-linux-x64-gnu@15.0.4-canary.19': + resolution: {integrity: sha512-aJFVPeFSdQwQmhBll8XW+e8yrYF0M6AKLUsXOUkAd6XvP1m/8Waps+DNkdMmjI1eD89b+C9SufjP8S1HO+LWgQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -4812,8 +4818,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.0.4-canary.17': - resolution: {integrity: sha512-f+eQPl5h+kMZkCwi90B4bmx3zCDjl9Adw66hE92djSibWMMlNGsxRVOXAvZglJWaZr8RffpCHUGyjyL003Uiqg==} + '@next/swc-linux-x64-musl@15.0.4-canary.19': + resolution: {integrity: sha512-owcSYpr0sLz94LJPgQ1z/FgoFsY40uHMG1nZBD0ar2Cj5/zbeHGzATpLoeg6UWG2M441KBRpeTPvNGIfTEQ4xQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -4824,8 +4830,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@15.0.4-canary.17': - resolution: {integrity: sha512-mNLMrLBPG8TKkymQqJH6ZLAj61oGUK8GnXMsqmZR/+OrsIYlzjHjJbYpLPStyxezChhmmBIwBnf9Olm4++8irQ==} + '@next/swc-win32-arm64-msvc@15.0.4-canary.19': + resolution: {integrity: sha512-dyeltaQ40350j+FExwrlV4YXZnYXQE/SQ17JEQHucOX9lrlUJ/XtwIEJSLSeu+fVjWq5gu6uV6wQejquwECngg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -4842,8 +4848,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@15.0.4-canary.17': - resolution: {integrity: sha512-E1OVH5mnfQprRcRXTVvPWro/ywyqj+ojRNzhkZzLyoy7cODQdyD6uIq+jeMltQg8BQIxfG8EeHjgTimwoixDPA==} + '@next/swc-win32-x64-msvc@15.0.4-canary.19': + resolution: {integrity: sha512-dbhxrOJXrE5Z1QTb9iArnc9MZRTinP76vKrxu1yvKHJJ1tXIe8wwpXAmPP4tK4wZ9mnlJIMc8fpuoNla38cFNA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -6180,6 +6186,9 @@ packages: '@solidjs/start@1.0.6': resolution: {integrity: sha512-O5knaeqDBx+nKLJRm5ZJurnXZtIYBOwOreQ10APaVtVjKIKKRC5HxJ1Kwqg7atOQNNDgsF0pzhW218KseaZ1UA==} + '@standard-schema/spec@1.0.0-beta.3': + resolution: {integrity: sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw==} + '@storybook/codemod@8.2.1': resolution: {integrity: sha512-LYvVLOKj5mDbbAPLrxd3BWQaemTqp2y5RV5glNqsPq3FoFX4rn4VnWb5X/YBWsMqqCK+skimH/f7HQ5fDvWubg==} @@ -12601,8 +12610,8 @@ packages: sass: optional: true - next@15.0.4-canary.17: - resolution: {integrity: sha512-2lNBfL42lCqpc2R6//sndFwCB8nUC0VlpLDQUPiOCh5Y/oFQlB36so7IaXSboyle7enwsZz631TvewYnYsN1yQ==} + next@15.0.4-canary.19: + resolution: {integrity: sha512-TFYV2o4v8gqti5JNKaUujI1rsPQyNkZ2NZCpcOhme+LlP99yD+IUOibCRvveySNNZuH2pvUzpzJsXMjpZOc9PA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -16097,6 +16106,14 @@ packages: engines: {node: '>=8'} hasBin: true + valibot@1.0.0-beta.7: + resolution: {integrity: sha512-8CsDu3tqyg7quEHMzCOYdQ/d9NlmVQKtd4AlFje6oJpvqo70EIZjSakKIeWltJyNAiUtdtLe0LAk4625gavoeQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valid-url@1.0.9: resolution: {integrity: sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==} @@ -20035,7 +20052,7 @@ snapshots: '@next/env@14.2.11': {} - '@next/env@15.0.4-canary.17': {} + '@next/env@15.0.4-canary.19': {} '@next/eslint-plugin-next@14.2.2': dependencies: @@ -20055,43 +20072,43 @@ snapshots: '@next/swc-darwin-arm64@14.2.11': optional: true - '@next/swc-darwin-arm64@15.0.4-canary.17': + '@next/swc-darwin-arm64@15.0.4-canary.19': optional: true '@next/swc-darwin-x64@14.2.11': optional: true - '@next/swc-darwin-x64@15.0.4-canary.17': + '@next/swc-darwin-x64@15.0.4-canary.19': optional: true '@next/swc-linux-arm64-gnu@14.2.11': optional: true - '@next/swc-linux-arm64-gnu@15.0.4-canary.17': + '@next/swc-linux-arm64-gnu@15.0.4-canary.19': optional: true '@next/swc-linux-arm64-musl@14.2.11': optional: true - '@next/swc-linux-arm64-musl@15.0.4-canary.17': + '@next/swc-linux-arm64-musl@15.0.4-canary.19': optional: true '@next/swc-linux-x64-gnu@14.2.11': optional: true - '@next/swc-linux-x64-gnu@15.0.4-canary.17': + '@next/swc-linux-x64-gnu@15.0.4-canary.19': optional: true '@next/swc-linux-x64-musl@14.2.11': optional: true - '@next/swc-linux-x64-musl@15.0.4-canary.17': + '@next/swc-linux-x64-musl@15.0.4-canary.19': optional: true '@next/swc-win32-arm64-msvc@14.2.11': optional: true - '@next/swc-win32-arm64-msvc@15.0.4-canary.17': + '@next/swc-win32-arm64-msvc@15.0.4-canary.19': optional: true '@next/swc-win32-ia32-msvc@14.2.11': @@ -20100,7 +20117,7 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.11': optional: true - '@next/swc-win32-x64-msvc@15.0.4-canary.17': + '@next/swc-win32-x64-msvc@15.0.4-canary.19': optional: true '@nodelib/fs.scandir@2.1.5': @@ -22671,6 +22688,8 @@ snapshots: - vinxi - vite + '@standard-schema/spec@1.0.0-beta.3': {} + '@storybook/codemod@8.2.1': dependencies: '@babel/core': 7.25.8 @@ -24052,16 +24071,16 @@ snapshots: '@uploadthing/mime-types@0.2.10': {} - '@uploadthing/react@6.8.0(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(solid-js@1.8.23)(svelte@4.2.15)(uploadthing@6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14))(vue@3.4.25(typescript@5.6.3))': + '@uploadthing/react@6.8.0(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(solid-js@1.8.23)(svelte@4.2.15)(uploadthing@6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14))(vue@3.4.25(typescript@5.6.3))': dependencies: '@uploadthing/dropzone': 0.4.1(react@18.3.1)(solid-js@1.8.23)(svelte@4.2.15)(vue@3.4.25(typescript@5.6.3)) '@uploadthing/shared': 6.7.9 file-selector: 0.6.0 react: 18.3.1 tailwind-merge: 2.3.0 - uploadthing: 6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14) + uploadthing: 6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14) optionalDependencies: - next: 15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - solid-js - svelte @@ -31260,9 +31279,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.0.4-canary.17 + '@next/env': 15.0.4-canary.19 '@swc/counter': 0.1.3 '@swc/helpers': 0.5.13 busboy: 1.6.0 @@ -31272,14 +31291,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.0.4-canary.17 - '@next/swc-darwin-x64': 15.0.4-canary.17 - '@next/swc-linux-arm64-gnu': 15.0.4-canary.17 - '@next/swc-linux-arm64-musl': 15.0.4-canary.17 - '@next/swc-linux-x64-gnu': 15.0.4-canary.17 - '@next/swc-linux-x64-musl': 15.0.4-canary.17 - '@next/swc-win32-arm64-msvc': 15.0.4-canary.17 - '@next/swc-win32-x64-msvc': 15.0.4-canary.17 + '@next/swc-darwin-arm64': 15.0.4-canary.19 + '@next/swc-darwin-x64': 15.0.4-canary.19 + '@next/swc-linux-arm64-gnu': 15.0.4-canary.19 + '@next/swc-linux-arm64-musl': 15.0.4-canary.19 + '@next/swc-linux-x64-gnu': 15.0.4-canary.19 + '@next/swc-linux-x64-musl': 15.0.4-canary.19 + '@next/swc-win32-arm64-msvc': 15.0.4-canary.19 + '@next/swc-win32-x64-msvc': 15.0.4-canary.19 '@playwright/test': 1.45.0 sharp: 0.33.5 transitivePeerDependencies: @@ -35903,7 +35922,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.0 - uploadthing@6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14): + uploadthing@6.13.3(@effect/platform@0.69.24(effect@3.10.15))(express@4.21.1)(fastify@4.26.2)(h3@1.13.0)(next@15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.14): dependencies: '@effect/schema': 0.68.18(effect@3.4.8) '@uploadthing/mime-types': 0.2.10 @@ -35916,7 +35935,7 @@ snapshots: express: 4.21.1 fastify: 4.26.2 h3: 1.13.0 - next: 15.0.4-canary.17(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.0.4-canary.19(@playwright/test@1.45.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwindcss: 3.4.14 uqr@0.1.2: {} @@ -35988,6 +36007,10 @@ snapshots: kleur: 4.1.5 sade: 1.8.1 + valibot@1.0.0-beta.7(typescript@5.6.2): + optionalDependencies: + typescript: 5.6.2 + valid-url@1.0.9: {} validate-html-nesting@1.2.2: {} From 6b05be49bc58eda3210d587178811705c7d10439 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 15:34:18 +0100 Subject: [PATCH 02/20] support effect/Schema --- packages/uploadthing/src/internal/parser.ts | 19 ++++- .../uploadthing/test/upload-builder.test.ts | 71 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index ff189d3121..2697f1e86b 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -1,4 +1,5 @@ import type { v1 } from "@standard-schema/spec"; +import * as Schema from "effect/Schema"; import type { Json } from "@uploadthing/shared"; @@ -22,13 +23,17 @@ export type ParserStandardSchemaEsque = v1.StandardSchema< // In case we add support for more parsers later export type JsonParser = | ParserZodEsque - | ParserStandardSchemaEsque; + | ParserStandardSchemaEsque + | Schema.Schema; export function getParseFn< TOut extends Json, TParser extends JsonParser, >(parser: TParser): ParseFn { if ("~standard" in parser) { + /** + * Standard Schema + */ return async (value) => { const result = await parser["~standard"].validate(value); if (result.issues) { @@ -41,9 +46,19 @@ export function getParseFn< }; } - if (typeof parser.parseAsync === "function") { + if ("parseAsync" in parser && typeof parser.parseAsync === "function") { + /** + * Zod + */ return parser.parseAsync; } + if (Schema.isSchema(parser)) { + /** + * Effect Schema + */ + return Schema.decodeUnknownPromise(parser as Schema.Schema); + } + throw new Error("Invalid parser"); } diff --git a/packages/uploadthing/test/upload-builder.test.ts b/packages/uploadthing/test/upload-builder.test.ts index 7b75f8df64..21c1ae91c2 100644 --- a/packages/uploadthing/test/upload-builder.test.ts +++ b/packages/uploadthing/test/upload-builder.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import * as Schema from "effect/Schema"; import * as v from "valibot"; import { expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; @@ -58,6 +59,20 @@ it("typeerrors for invalid input", () => { return {}; }); + f(["image"]) + // @ts-expect-error - set is not allowed + .input(v.object({ foo: v.set() })) + .middleware(() => { + return {}; + }); + + f(["image"]) + // @ts-expect-error - set is not allowed + .input(Schema.Struct({ foo: Schema.Set(Schema.String) })) + .middleware(() => { + return {}; + }); + f(["image"]) .input(z.object({ foo: z.string() })) .middleware((opts) => { @@ -124,6 +139,13 @@ it("with input", () => { expectTypeOf<{ foo: string }>(opts.input); return {}; }); + + f(["image"]) + .input(Schema.Struct({ foo: Schema.String })) + .middleware((opts) => { + expectTypeOf<{ readonly foo: string }>(opts.input); + return {}; + }); }); it("with optional input", () => { @@ -141,6 +163,14 @@ it("with optional input", () => { expectTypeOf<{ foo: string } | undefined>(opts.input); return {}; }); + + // FIXME + // f(["image"]) + // .input(Schema.Struct({ foo: Schema.String }).pipe(Schema.optional)) + // .middleware((opts) => { + // expectTypeOf<{ readonly foo: string } | undefined>(opts.input); + // return {}; + // }); }); it("can append a customId", () => { @@ -237,3 +267,44 @@ it("smoke (valibot)", async () => { }); expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); }); + +it("smoke (effect/Schema)", async () => { + const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); + + const uploadable = f(["image", "video"]) + .input(Schema.Struct({ foo: Schema.String })) + .middleware((opts) => { + expect(opts.input).toEqual({ foo: "bar" }); + expectTypeOf<{ readonly foo: string }>(opts.input); + expectTypeOf(opts.files); + + const header1 = opts.req.headers.get("header1"); + + return { header1, userId: "123" as const }; + }) + .onUploadComplete(({ file, metadata }) => { + // expect(file).toEqual({ name: "file", url: "http://localhost" }) + expectTypeOf<{ name: string; url: string }>(file); + + expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); + expectTypeOf<{ + header1: string | null; + userId: "123"; + }>(metadata); + }); + + expect(uploadable.routerConfig).toEqual(["image", "video"]); + + const parsedInput = await getParseFn(uploadable.inputParser)({ foo: "bar" }); + + const metadata = await uploadable.middleware({ + req: new Request("http://localhost", { + headers: { header1: "woohoo" }, + }), + input: parsedInput, + res: undefined, + event: undefined, + files: [{ name: "test.txt", size: 123456, type: "text/plain" }], + }); + expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); +}); From c4f4a3402aebff415be8a3a8c412f9719090bb5f Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 15:41:16 +0100 Subject: [PATCH 03/20] docs --- docs/src/app/(docs)/file-routes/page.mdx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 4b0e19f1de..1bb309df12 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -171,10 +171,23 @@ f(["image"]) ## `input` {{ since: '5.0' }} -You can pass a [Zod](https://github.com/colinhacks/zod) schema to validate user -input from the client. This data comes from the client when the upload starts. -If validation here fails, an error will be thrown and none of your `middleware` -n'or `onUploadComplete` functions will be executed. +You can pass a schema validator to validate user input from the client. This +data comes from the client when the upload starts. If validation here fails, an +error will be thrown and none of your `middleware` n'or `onUploadComplete` +functions will be executed. + +The schema must only contain JSON serializable types as UploadThing does no +special data transforming to handle non-JSON types. + +Historically only [Zod](https://github.com/colinhacks/zod) was supported, but +since `7.4` the following validators are supported: + +- [Zod](https://github.com/colinhacks/zod) +- [Effect/Schema](https://effect.website/docs/datatypes/schema) +- Any validator that implements the + [Standard Schema specification](https://github.com/standard-schema/standard-schema), + for example [Valibot >=1](https://github.com/fabian-hiller/valibot) and + [ArkType](https://github.com/arktypeio/arktype) The input is validated on **your** server and only leaves your server if you pass it along from the `.middleware` to the `.onUploadComplete`. If you only use From 238a32a8dedf148e42e3f4257a3530d76181d95f Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 15:50:09 +0100 Subject: [PATCH 04/20] fix type --- packages/uploadthing/src/internal/upload-builder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/uploadthing/src/internal/upload-builder.ts b/packages/uploadthing/src/internal/upload-builder.ts index c041e879a3..f4bf75c678 100644 --- a/packages/uploadthing/src/internal/upload-builder.ts +++ b/packages/uploadthing/src/internal/upload-builder.ts @@ -6,6 +6,7 @@ import type { } from "@uploadthing/shared"; import { defaultErrorFormatter } from "./error-formatter"; +import type { JsonParser } from "./parser"; import type { AdapterFnArgs, AnyBuiltUploaderTypes, @@ -63,7 +64,7 @@ function internalCreateBuilder< input(userParser) { return internalCreateBuilder({ ..._def, - inputParser: userParser, + inputParser: userParser as JsonParser, }) as UploadBuilder; }, middleware(userMiddleware) { From b6417f9e6d40f217fb45ee1e87fb0f78e1a36127 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:03:56 +0100 Subject: [PATCH 05/20] tests --- docs/src/app/(docs)/file-routes/page.mdx | 7 +- packages/uploadthing/src/internal/parser.ts | 17 +- packages/uploadthing/src/internal/types.ts | 2 +- packages/uploadthing/test/input.test.ts | 208 ++++++++++++++++++ .../uploadthing/test/upload-builder.test.ts | 161 +------------- 5 files changed, 228 insertions(+), 167 deletions(-) create mode 100644 packages/uploadthing/test/input.test.ts diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 1bb309df12..821ca37764 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -176,8 +176,11 @@ data comes from the client when the upload starts. If validation here fails, an error will be thrown and none of your `middleware` n'or `onUploadComplete` functions will be executed. -The schema must only contain JSON serializable types as UploadThing does no -special data transforming to handle non-JSON types. + + The schema's **input type** must only contain JSON serializable types as + UploadThing does no special data transforming to handle non-JSON types. For + example, `z.date()` is invalid but `z.string().transform(Date)` is valid. + Historically only [Zod](https://github.com/colinhacks/zod) was supported, but since `7.4` the following validators are supported: diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index 2697f1e86b..91ec669ed8 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -1,4 +1,6 @@ import type { v1 } from "@standard-schema/spec"; +import * as Cause from "effect/Cause"; +import * as Runtime from "effect/Runtime"; import * as Schema from "effect/Schema"; import type { Json } from "@uploadthing/shared"; @@ -9,7 +11,7 @@ import type { Json } from "@uploadthing/shared"; export type ParseFn = (input: unknown) => Promise; -export type ParserZodEsque = { +export type ParserZodEsque = { _input: TInput; _output: TParsedInput; // if using .transform etc parseAsync: ParseFn; @@ -21,10 +23,10 @@ export type ParserStandardSchemaEsque = v1.StandardSchema< >; // In case we add support for more parsers later -export type JsonParser = +export type JsonParser = | ParserZodEsque | ParserStandardSchemaEsque - | Schema.Schema; + | Schema.Schema; export function getParseFn< TOut extends Json, @@ -57,7 +59,14 @@ export function getParseFn< /** * Effect Schema */ - return Schema.decodeUnknownPromise(parser as Schema.Schema); + return (value) => + Schema.decodeUnknownPromise(parser as Schema.Schema)( + value, + ).catch((error) => { + throw Cause.squash( + (error as Runtime.FiberFailure)[Runtime.FiberFailureCauseId], + ); + }); } throw new Error("Invalid parser"); diff --git a/packages/uploadthing/src/internal/types.ts b/packages/uploadthing/src/internal/types.ts index 40527fcb60..430981e36e 100644 --- a/packages/uploadthing/src/internal/types.ts +++ b/packages/uploadthing/src/internal/types.ts @@ -97,7 +97,7 @@ type UploadErrorFn> = ( ) => MaybePromise; export interface UploadBuilder { - input: ( + input: ( parser: TParams["_input"]["in"] extends UnsetMarker ? JsonParser : ErrorMessage<"input is already set">, diff --git a/packages/uploadthing/test/input.test.ts b/packages/uploadthing/test/input.test.ts new file mode 100644 index 0000000000..b508632dde --- /dev/null +++ b/packages/uploadthing/test/input.test.ts @@ -0,0 +1,208 @@ +import { ParseError } from "effect/ParseResult"; +import * as Schema from "effect/Schema"; +import * as v from "valibot"; +import { expect, expectTypeOf, it } from "vitest"; +import * as z from "zod"; + +import { noop } from "@uploadthing/shared"; + +import { getParseFn } from "../src/internal/parser"; +import { createBuilder } from "../src/internal/upload-builder"; +import type { inferEndpointInput } from "../src/types"; + +const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); + +it.each([ + ["zod", z.string()], + ["valibot", v.string()], + ["effect/schema", Schema.String], +])("primitive string schema (%s)", async (_, input) => { + const fileRoute = f(["image"]) + .input(input) + .middleware((opts) => { + expectTypeOf(opts.input); + return {}; + }) + .onUploadComplete(noop); + + type Input = inferEndpointInput; + expectTypeOf().toMatchTypeOf(); + + const parsedInput = await getParseFn(fileRoute.inputParser)("bar"); + expect(parsedInput).toEqual("bar"); +}); + +it.each([ + ["zod", z.object({ foo: z.string() })], + ["valibot", v.object({ foo: v.string() })], + ["effect/schema", Schema.Struct({ foo: Schema.String })], +])("object input (%s)", async (_, input) => { + const fileRoute = f(["image"]) + .input(input) + .middleware((opts) => { + expectTypeOf<{ foo: string }>(opts.input); + return {}; + }) + .onUploadComplete(noop); + + type Input = inferEndpointInput; + expectTypeOf().toMatchTypeOf<{ foo: string }>(); + + const parsedInput = await getParseFn(fileRoute.inputParser)({ foo: "bar" }); + expect(parsedInput).toEqual({ foo: "bar" }); +}); + +it.each([ + ["zod", z.object({ foo: z.string() }).optional()], + ["valibot", v.optional(v.object({ foo: v.string() }))], + // FIXME: Effect's optional schema wraps the entire type which makes it incompatible with the current approach + // [ + // "effect/schema", + // Schema.Struct({ foo: Schema.String }).pipe(Schema.optional), + // ], +])("optional input (%s)", async (_, input) => { + const fileRoute = f(["image"]) + .input(input) + .middleware((opts) => { + expectTypeOf<{ foo: string } | undefined>(opts.input); + return {}; + }) + .onUploadComplete(noop); + + type Input = inferEndpointInput; + expectTypeOf().toMatchTypeOf<{ foo: string } | undefined>(); + + const parsedInput = await getParseFn(fileRoute.inputParser)({ foo: "bar" }); + expect(parsedInput).toEqual({ foo: "bar" }); +}); + +it("validation fails when input is invalid (zod)", async () => { + const fileRoute = f(["image"]).input(z.string()).onUploadComplete(noop); + const err = await getParseFn(fileRoute.inputParser)(123).catch((e) => e); + expect(err).toBeInstanceOf(z.ZodError); +}); +it("validation fails when input is invalid (valibot)", async () => { + const fileRoute = f(["image"]).input(v.string()).onUploadComplete(noop); + const err = await getParseFn(fileRoute.inputParser)(123).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(Array.isArray(err.cause)).toBe(true); +}); +it("validation fails when input is invalid (effect/schema)", async () => { + const fileRoute = f(["image"]).input(Schema.String).onUploadComplete(noop); + const err = await getParseFn(fileRoute.inputParser)(123).catch((e) => e); + expect(err).toBeInstanceOf(ParseError); +}); + +it("with data transforming (zod)", async () => { + f(["image"]).input( + // @ts-expect-error - Date -> string is not JSON serializable + z.object({ + date: z.date().transform((s) => s.toISOString()), + }), + ); + + // string -> Date should work + const fileRoute = f(["image"]) + .input( + z.object({ + date: z.string().transform((s) => new Date(s)), + }), + ) + .middleware((opts) => { + expectTypeOf<{ date: Date }>(opts.input); + return {}; + }) + .onUploadComplete(noop); + + type Input = inferEndpointInput; + expectTypeOf().toMatchTypeOf<{ date: string }>(); + + const parsedInput = await getParseFn(fileRoute.inputParser)({ + date: "2024-01-01", + }); + expect(parsedInput).toEqual({ date: new Date("2024-01-01") }); +}); +it("with data transforming (valibot)", async () => { + f(["image"]).input( + // @ts-expect-error - Date -> string is not JSON serializable + v.object({ + date: v.pipe( + v.date(), + v.transform((d) => d.toISOString()), + ), + }), + ); + + // string -> Date should work + const fileRoute = f(["image"]) + .input( + v.object({ + date: v.pipe( + v.string(), + v.transform((s) => new Date(s)), + ), + }), + ) + .middleware((opts) => { + expectTypeOf<{ date: Date }>(opts.input); + return {}; + }) + .onUploadComplete(noop); + + type Input = inferEndpointInput; + expectTypeOf().toMatchTypeOf<{ date: string }>(); + + const parsedInput = await getParseFn(fileRoute.inputParser)({ + date: "2024-01-01", + }); + expect(parsedInput).toEqual({ date: new Date("2024-01-01") }); +}); +it("with data transforming (effect/schema)", async () => { + const fileRoute = f(["image"]) + .input( + Schema.Struct({ + date: Schema.Date, + }), + ) + .middleware((opts) => { + expectTypeOf<{ date: Date }>(opts.input); + return {}; + }) + .onUploadComplete(noop); + + type Input = inferEndpointInput; + expectTypeOf().toMatchTypeOf<{ date: string }>(); + + const parsedInput = await getParseFn(fileRoute.inputParser)({ + date: "2024-01-01", + }); + expect(parsedInput).toEqual({ date: new Date("2024-01-01") }); +}); + +it("type errors for non-JSON data types (zod)", () => { + f(["image"]) + // @ts-expect-error - Set is not a valid JSON type + .input(z.object({ foo: z.set(z.string()) })) + .middleware((opts) => { + return {}; + }) + .onUploadComplete(noop); +}); +it("type errors for non-JSON data types (valibot)", () => { + f(["image"]) + // @ts-expect-error - Set is not a valid JSON type + .input(v.object({ foo: v.set(v.string()) })) + .middleware((opts) => { + return {}; + }) + .onUploadComplete(noop); +}); +it("type errors for non-JSON data types (effect/schema)", () => { + f(["image"]) + // @ts-expect-error - Set is not a valid JSON type + .input(Schema.Struct({ foo: Schema.Set(Schema.String) })) + .middleware((opts) => { + return {}; + }) + .onUploadComplete(noop); +}); diff --git a/packages/uploadthing/test/upload-builder.test.ts b/packages/uploadthing/test/upload-builder.test.ts index 21c1ae91c2..5164e3bc86 100644 --- a/packages/uploadthing/test/upload-builder.test.ts +++ b/packages/uploadthing/test/upload-builder.test.ts @@ -45,34 +45,6 @@ it("typeerrors for invalid input", () => { return {}; }); - f(["image"]) - // @ts-expect-error - date is not allowed - .input(z.object({ foo: z.date() })) - .middleware(() => { - return {}; - }); - - f(["image"]) - // @ts-expect-error - set is not allowed - .input(z.object({ foo: z.set() })) - .middleware(() => { - return {}; - }); - - f(["image"]) - // @ts-expect-error - set is not allowed - .input(v.object({ foo: v.set() })) - .middleware(() => { - return {}; - }); - - f(["image"]) - // @ts-expect-error - set is not allowed - .input(Schema.Struct({ foo: Schema.Set(Schema.String) })) - .middleware(() => { - return {}; - }); - f(["image"]) .input(z.object({ foo: z.string() })) .middleware((opts) => { @@ -124,55 +96,6 @@ it("allows async middleware", () => { }); }); -it("with input", () => { - const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); - f(["image"]) - .input(z.object({ foo: z.string() })) - .middleware((opts) => { - expectTypeOf<{ foo: string }>(opts.input); - return {}; - }); - - f(["image"]) - .input(v.object({ foo: v.string() })) - .middleware((opts) => { - expectTypeOf<{ foo: string }>(opts.input); - return {}; - }); - - f(["image"]) - .input(Schema.Struct({ foo: Schema.String })) - .middleware((opts) => { - expectTypeOf<{ readonly foo: string }>(opts.input); - return {}; - }); -}); - -it("with optional input", () => { - const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); - f(["image"]) - .input(z.object({ foo: z.string() }).optional()) - .middleware((opts) => { - expectTypeOf<{ foo: string } | undefined>(opts.input); - return {}; - }); - - f(["image"]) - .input(v.optional(v.object({ foo: v.string() }))) - .middleware((opts) => { - expectTypeOf<{ foo: string } | undefined>(opts.input); - return {}; - }); - - // FIXME - // f(["image"]) - // .input(Schema.Struct({ foo: Schema.String }).pipe(Schema.optional)) - // .middleware((opts) => { - // expectTypeOf<{ readonly foo: string } | undefined>(opts.input); - // return {}; - // }); -}); - it("can append a customId", () => { const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); f(["image"]) @@ -186,7 +109,7 @@ it("can append a customId", () => { }); }); -it("smoke (zod)", async () => { +it("smoke", async () => { const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); const uploadable = f(["image", "video"]) @@ -226,85 +149,3 @@ it("smoke (zod)", async () => { }); expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); }); - -it("smoke (valibot)", async () => { - const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); - - const uploadable = f(["image", "video"]) - .input(v.object({ foo: v.string() })) - .middleware((opts) => { - expect(opts.input).toEqual({ foo: "bar" }); - expectTypeOf<{ foo: string }>(opts.input); - expectTypeOf(opts.files); - - const header1 = opts.req.headers.get("header1"); - - return { header1, userId: "123" as const }; - }) - .onUploadComplete(({ file, metadata }) => { - // expect(file).toEqual({ name: "file", url: "http://localhost" }) - expectTypeOf<{ name: string; url: string }>(file); - - expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); - expectTypeOf<{ - header1: string | null; - userId: "123"; - }>(metadata); - }); - - expect(uploadable.routerConfig).toEqual(["image", "video"]); - - const parsedInput = await getParseFn(uploadable.inputParser)({ foo: "bar" }); - - const metadata = await uploadable.middleware({ - req: new Request("http://localhost", { - headers: { header1: "woohoo" }, - }), - input: parsedInput, - res: undefined, - event: undefined, - files: [{ name: "test.txt", size: 123456, type: "text/plain" }], - }); - expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); -}); - -it("smoke (effect/Schema)", async () => { - const f = createBuilder<{ req: Request; res: undefined; event: undefined }>(); - - const uploadable = f(["image", "video"]) - .input(Schema.Struct({ foo: Schema.String })) - .middleware((opts) => { - expect(opts.input).toEqual({ foo: "bar" }); - expectTypeOf<{ readonly foo: string }>(opts.input); - expectTypeOf(opts.files); - - const header1 = opts.req.headers.get("header1"); - - return { header1, userId: "123" as const }; - }) - .onUploadComplete(({ file, metadata }) => { - // expect(file).toEqual({ name: "file", url: "http://localhost" }) - expectTypeOf<{ name: string; url: string }>(file); - - expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); - expectTypeOf<{ - header1: string | null; - userId: "123"; - }>(metadata); - }); - - expect(uploadable.routerConfig).toEqual(["image", "video"]); - - const parsedInput = await getParseFn(uploadable.inputParser)({ foo: "bar" }); - - const metadata = await uploadable.middleware({ - req: new Request("http://localhost", { - headers: { header1: "woohoo" }, - }), - input: parsedInput, - res: undefined, - event: undefined, - files: [{ name: "test.txt", size: 123456, type: "text/plain" }], - }); - expect(metadata).toEqual({ header1: "woohoo", userId: "123" }); -}); From 8fe3b719c2e38189e1f797ea8c7cf0677afb20f2 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:08:17 +0100 Subject: [PATCH 06/20] nits --- packages/uploadthing/src/internal/parser.ts | 6 +----- packages/uploadthing/src/internal/types.ts | 2 +- packages/uploadthing/src/internal/upload-builder.ts | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index 91ec669ed8..bf95f74305 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -5,10 +5,6 @@ import * as Schema from "effect/Schema"; import type { Json } from "@uploadthing/shared"; -/** - * TODO: Do we wanna support effect/schema parsers now?? - */ - export type ParseFn = (input: unknown) => Promise; export type ParserZodEsque = { @@ -23,7 +19,7 @@ export type ParserStandardSchemaEsque = v1.StandardSchema< >; // In case we add support for more parsers later -export type JsonParser = +export type JsonParser = | ParserZodEsque | ParserStandardSchemaEsque | Schema.Schema; diff --git a/packages/uploadthing/src/internal/types.ts b/packages/uploadthing/src/internal/types.ts index 430981e36e..bbbfdc0d2f 100644 --- a/packages/uploadthing/src/internal/types.ts +++ b/packages/uploadthing/src/internal/types.ts @@ -173,7 +173,7 @@ export interface FileRoute { $types: TTypes; routerConfig: FileRouterInputConfig; routeOptions: RouteOptions; - inputParser: JsonParser; + inputParser: JsonParser; middleware: MiddlewareFn; onUploadError: UploadErrorFn; errorFormatter: (err: UploadThingError) => any; diff --git a/packages/uploadthing/src/internal/upload-builder.ts b/packages/uploadthing/src/internal/upload-builder.ts index f4bf75c678..c041e879a3 100644 --- a/packages/uploadthing/src/internal/upload-builder.ts +++ b/packages/uploadthing/src/internal/upload-builder.ts @@ -6,7 +6,6 @@ import type { } from "@uploadthing/shared"; import { defaultErrorFormatter } from "./error-formatter"; -import type { JsonParser } from "./parser"; import type { AdapterFnArgs, AnyBuiltUploaderTypes, @@ -64,7 +63,7 @@ function internalCreateBuilder< input(userParser) { return internalCreateBuilder({ ..._def, - inputParser: userParser as JsonParser, + inputParser: userParser, }) as UploadBuilder; }, middleware(userMiddleware) { From 55f83524abd6ac898437d4bce298e18befba0c89 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:10:46 +0100 Subject: [PATCH 07/20] nit --- packages/uploadthing/src/internal/parser.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index bf95f74305..8152b9c6e0 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -13,15 +13,10 @@ export type ParserZodEsque = { parseAsync: ParseFn; }; -export type ParserStandardSchemaEsque = v1.StandardSchema< - TInput, - TParsedInput ->; - // In case we add support for more parsers later export type JsonParser = | ParserZodEsque - | ParserStandardSchemaEsque + | v1.StandardSchema | Schema.Schema; export function getParseFn< From 0bf97a1908c584c02800e4a71077aa509c41c179 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:13:45 +0100 Subject: [PATCH 08/20] move --- docs/src/app/(docs)/file-routes/page.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 821ca37764..7d23eb1377 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -176,21 +176,21 @@ data comes from the client when the upload starts. If validation here fails, an error will be thrown and none of your `middleware` n'or `onUploadComplete` functions will be executed. - - The schema's **input type** must only contain JSON serializable types as - UploadThing does no special data transforming to handle non-JSON types. For - example, `z.date()` is invalid but `z.string().transform(Date)` is valid. - - Historically only [Zod](https://github.com/colinhacks/zod) was supported, but since `7.4` the following validators are supported: -- [Zod](https://github.com/colinhacks/zod) +- [Zod >=3](https://github.com/colinhacks/zod) - [Effect/Schema](https://effect.website/docs/datatypes/schema) - Any validator that implements the [Standard Schema specification](https://github.com/standard-schema/standard-schema), - for example [Valibot >=1](https://github.com/fabian-hiller/valibot) and - [ArkType](https://github.com/arktypeio/arktype) + for example [Valibot >=1.0](https://github.com/fabian-hiller/valibot) and + [ArkType >=2.0](https://github.com/arktypeio/arktype) + + + The schema's **input type** must only contain JSON serializable types as + UploadThing does no special data transforming to handle non-JSON types. For + example, `z.date()` is invalid but `z.string().transform(Date)` is valid. + The input is validated on **your** server and only leaves your server if you pass it along from the `.middleware` to the `.onUploadComplete`. If you only use From e7d0b49661f5933e315323c62786c03787e64711 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:14:38 +0100 Subject: [PATCH 09/20] nit --- docs/src/app/(docs)/file-routes/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 7d23eb1377..49ca299609 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -177,7 +177,7 @@ error will be thrown and none of your `middleware` n'or `onUploadComplete` functions will be executed. Historically only [Zod](https://github.com/colinhacks/zod) was supported, but -since `7.4` the following validators are supported: +since `uploadthing@7.4` the following validators are supported: - [Zod >=3](https://github.com/colinhacks/zod) - [Effect/Schema](https://effect.website/docs/datatypes/schema) From 41a4ef1f965aff9accf86c700d9a819be083dbbb Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:15:04 +0100 Subject: [PATCH 10/20] fix broken link --- docs/src/app/(docs)/file-routes/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 49ca299609..d7dfdfb82a 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -180,7 +180,7 @@ Historically only [Zod](https://github.com/colinhacks/zod) was supported, but since `uploadthing@7.4` the following validators are supported: - [Zod >=3](https://github.com/colinhacks/zod) -- [Effect/Schema](https://effect.website/docs/datatypes/schema) +- [Effect/Schema](https://effect.website/docs/schema/introduction) - Any validator that implements the [Standard Schema specification](https://github.com/standard-schema/standard-schema), for example [Valibot >=1.0](https://github.com/fabian-hiller/valibot) and From 3c58112e43933b93ce8ef0d81fd8e5fd98b36e1d Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:18:07 +0100 Subject: [PATCH 11/20] version effect --- docs/src/app/(docs)/file-routes/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index d7dfdfb82a..ff0486cf71 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -180,7 +180,7 @@ Historically only [Zod](https://github.com/colinhacks/zod) was supported, but since `uploadthing@7.4` the following validators are supported: - [Zod >=3](https://github.com/colinhacks/zod) -- [Effect/Schema](https://effect.website/docs/schema/introduction) +- [Effect/Schema >=3.10](https://effect.website/docs/schema/introduction) - Any validator that implements the [Standard Schema specification](https://github.com/standard-schema/standard-schema), for example [Valibot >=1.0](https://github.com/fabian-hiller/valibot) and From 752bbc5800e468ecfcb53df4c87b817e64867baf Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:18:45 +0100 Subject: [PATCH 12/20] changeset --- .changeset/odd-icons-pretend.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/odd-icons-pretend.md diff --git a/.changeset/odd-icons-pretend.md b/.changeset/odd-icons-pretend.md new file mode 100644 index 0000000000..bc5e221e4b --- /dev/null +++ b/.changeset/odd-icons-pretend.md @@ -0,0 +1,5 @@ +--- +"uploadthing": minor +--- + +feat: support `effect/Schema` and `@standard-schema/spec` input validators From 19af73f748f6d72d2d589c31d7480075c6582935 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Tue, 19 Nov 2024 17:20:16 +0100 Subject: [PATCH 13/20] * as --- packages/uploadthing/src/internal/parser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index 8152b9c6e0..7a36ab5033 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -1,4 +1,4 @@ -import type { v1 } from "@standard-schema/spec"; +import type * as Standard from "@standard-schema/spec"; import * as Cause from "effect/Cause"; import * as Runtime from "effect/Runtime"; import * as Schema from "effect/Schema"; @@ -16,7 +16,7 @@ export type ParserZodEsque = { // In case we add support for more parsers later export type JsonParser = | ParserZodEsque - | v1.StandardSchema + | Standard.v1.StandardSchema | Schema.Schema; export function getParseFn< From 300a503ceab63b996320858a49ef7c6f10c2c778 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 20 Nov 2024 16:32:11 +0100 Subject: [PATCH 14/20] Update page.mdx --- docs/src/app/(docs)/file-routes/page.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index ff0486cf71..5a22876f5b 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -180,7 +180,9 @@ Historically only [Zod](https://github.com/colinhacks/zod) was supported, but since `uploadthing@7.4` the following validators are supported: - [Zod >=3](https://github.com/colinhacks/zod) -- [Effect/Schema >=3.10](https://effect.website/docs/schema/introduction) +- [Effect/Schema >=3.10](https://effect.website/docs/schema/introduction) is partially + supported as long as the top-level schema is of type `Schema.Schema` and not wrapped + like `optional`/`optionalWith` - Any validator that implements the [Standard Schema specification](https://github.com/standard-schema/standard-schema), for example [Valibot >=1.0](https://github.com/fabian-hiller/valibot) and From 434097c85fe8b4d9c6fb2b0bf1f097b634533e83 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Wed, 20 Nov 2024 16:34:57 +0100 Subject: [PATCH 15/20] format --- docs/src/app/(docs)/file-routes/page.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 5a22876f5b..9e7d5c1cf0 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -180,9 +180,9 @@ Historically only [Zod](https://github.com/colinhacks/zod) was supported, but since `uploadthing@7.4` the following validators are supported: - [Zod >=3](https://github.com/colinhacks/zod) -- [Effect/Schema >=3.10](https://effect.website/docs/schema/introduction) is partially - supported as long as the top-level schema is of type `Schema.Schema` and not wrapped - like `optional`/`optionalWith` +- [Effect/Schema >=3.10](https://effect.website/docs/schema/introduction) is + partially supported as long as the top-level schema is of type `Schema.Schema` + and not wrapped like `optional`/`optionalWith` - Any validator that implements the [Standard Schema specification](https://github.com/standard-schema/standard-schema), for example [Valibot >=1.0](https://github.com/fabian-hiller/valibot) and From 2dbadf479820683fdd58b8b1b6f47878fc162250 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Fri, 22 Nov 2024 10:07:48 +0100 Subject: [PATCH 16/20] update docs --- docs/src/app/(docs)/file-routes/page.mdx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 9e7d5c1cf0..15352382d6 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -176,15 +176,16 @@ data comes from the client when the upload starts. If validation here fails, an error will be thrown and none of your `middleware` n'or `onUploadComplete` functions will be executed. -Historically only [Zod](https://github.com/colinhacks/zod) was supported, but +Historically, only [Zod](https://github.com/colinhacks/zod) was supported, but since `uploadthing@7.4` the following validators are supported: - [Zod >=3](https://github.com/colinhacks/zod) - [Effect/Schema >=3.10](https://effect.website/docs/schema/introduction) is - partially supported as long as the top-level schema is of type `Schema.Schema` - and not wrapped like `optional`/`optionalWith` -- Any validator that implements the - [Standard Schema specification](https://github.com/standard-schema/standard-schema), + partially supported with the following limitations: + - Top-level schema must be of type `Schema.Schema` + - Must not be wrapped (e.g., no `optional` or + `optionalWith`) +- [Standard Schema specification](https://github.com/standard-schema/standard-schema), for example [Valibot >=1.0](https://github.com/fabian-hiller/valibot) and [ArkType >=2.0](https://github.com/arktypeio/arktype) From b179776bd3c1f34f08efa1f7c2541151512e7927 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Fri, 22 Nov 2024 10:08:37 +0100 Subject: [PATCH 17/20] nit --- docs/src/app/(docs)/file-routes/page.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 15352382d6..6c3afc608d 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -191,8 +191,9 @@ since `uploadthing@7.4` the following validators are supported: The schema's **input type** must only contain JSON serializable types as - UploadThing does no special data transforming to handle non-JSON types. For - example, `z.date()` is invalid but `z.string().transform(Date)` is valid. + UploadThing does no special data transforming to handle non-JSON types. You + may do transformations yourself, for example `z.date()` is invalid but + `z.string().transform(Date)` is valid. The input is validated on **your** server and only leaves your server if you From fe22f96f56a0fc5f8ba2588d41f476ae21228471 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Fri, 22 Nov 2024 10:08:56 +0100 Subject: [PATCH 18/20] nit --- docs/src/app/(docs)/file-routes/page.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 6c3afc608d..1debe5746c 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -192,8 +192,8 @@ since `uploadthing@7.4` the following validators are supported: The schema's **input type** must only contain JSON serializable types as UploadThing does no special data transforming to handle non-JSON types. You - may do transformations yourself, for example `z.date()` is invalid but - `z.string().transform(Date)` is valid. + may do transformations yourself, for example z.string().transform(Date) is + valid where as `z.date()` is not. The input is validated on **your** server and only leaves your server if you From 36acdf3448d8f3bdad35ecb5da3cc1525574af56 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Fri, 22 Nov 2024 10:09:06 +0100 Subject: [PATCH 19/20] nit --- docs/src/app/(docs)/file-routes/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/(docs)/file-routes/page.mdx b/docs/src/app/(docs)/file-routes/page.mdx index 1debe5746c..7e27fae6fa 100644 --- a/docs/src/app/(docs)/file-routes/page.mdx +++ b/docs/src/app/(docs)/file-routes/page.mdx @@ -192,7 +192,7 @@ since `uploadthing@7.4` the following validators are supported: The schema's **input type** must only contain JSON serializable types as UploadThing does no special data transforming to handle non-JSON types. You - may do transformations yourself, for example z.string().transform(Date) is + may do transformations yourself, for example `z.string().transform(Date)` is valid where as `z.date()` is not. From 8b92ab01234e922a35c25c17e2cdcd37172df857 Mon Sep 17 00:00:00 2001 From: juliusmarminge Date: Fri, 22 Nov 2024 10:18:08 +0100 Subject: [PATCH 20/20] nit --- packages/uploadthing/src/internal/parser.ts | 22 ++++++++++++++------- packages/uploadthing/test/input.test.ts | 7 ++++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/uploadthing/src/internal/parser.ts b/packages/uploadthing/src/internal/parser.ts index 7a36ab5033..caa1453c73 100644 --- a/packages/uploadthing/src/internal/parser.ts +++ b/packages/uploadthing/src/internal/parser.ts @@ -1,5 +1,6 @@ import type * as Standard from "@standard-schema/spec"; import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; import * as Runtime from "effect/Runtime"; import * as Schema from "effect/Schema"; @@ -19,6 +20,13 @@ export type JsonParser = | Standard.v1.StandardSchema | Schema.Schema; +export class ParserError extends Data.TaggedError("ParserError")<{ + cause: unknown; +}> { + message = + "Input validation failed. The original error with it's validation issues is in the error cause."; +} + export function getParseFn< TOut extends Json, TParser extends JsonParser, @@ -30,10 +38,7 @@ export function getParseFn< return async (value) => { const result = await parser["~standard"].validate(value); if (result.issues) { - throw new Error( - "Input validation failed. See validation issues in the error cause.", - { cause: result.issues }, - ); + throw new ParserError({ cause: result.issues }); } return result.value; }; @@ -42,6 +47,7 @@ export function getParseFn< if ("parseAsync" in parser && typeof parser.parseAsync === "function") { /** * Zod + * TODO (next major): Consider wrapping ZodError in ParserError */ return parser.parseAsync; } @@ -54,9 +60,11 @@ export function getParseFn< Schema.decodeUnknownPromise(parser as Schema.Schema)( value, ).catch((error) => { - throw Cause.squash( - (error as Runtime.FiberFailure)[Runtime.FiberFailureCauseId], - ); + throw new ParserError({ + cause: Cause.squash( + (error as Runtime.FiberFailure)[Runtime.FiberFailureCauseId], + ), + }); }); } diff --git a/packages/uploadthing/test/input.test.ts b/packages/uploadthing/test/input.test.ts index b508632dde..d4d5f9e2d5 100644 --- a/packages/uploadthing/test/input.test.ts +++ b/packages/uploadthing/test/input.test.ts @@ -6,7 +6,7 @@ import * as z from "zod"; import { noop } from "@uploadthing/shared"; -import { getParseFn } from "../src/internal/parser"; +import { getParseFn, ParserError } from "../src/internal/parser"; import { createBuilder } from "../src/internal/upload-builder"; import type { inferEndpointInput } from "../src/types"; @@ -84,13 +84,14 @@ it("validation fails when input is invalid (zod)", async () => { it("validation fails when input is invalid (valibot)", async () => { const fileRoute = f(["image"]).input(v.string()).onUploadComplete(noop); const err = await getParseFn(fileRoute.inputParser)(123).catch((e) => e); - expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ParserError); expect(Array.isArray(err.cause)).toBe(true); }); it("validation fails when input is invalid (effect/schema)", async () => { const fileRoute = f(["image"]).input(Schema.String).onUploadComplete(noop); const err = await getParseFn(fileRoute.inputParser)(123).catch((e) => e); - expect(err).toBeInstanceOf(ParseError); + expect(err).toBeInstanceOf(ParserError); + expect(err.cause).toBeInstanceOf(ParseError); }); it("with data transforming (zod)", async () => {