Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support more input parsers #1061

Merged
merged 22 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odd-icons-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"uploadthing": minor
---

feat: support `effect/Schema` and `@standard-schema/spec` input validators
28 changes: 24 additions & 4 deletions docs/src/app/(docs)/file-routes/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,30 @@ 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.

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 with the following limitations:
- Top-level schema must be of type `Schema.Schema`
- Must not be wrapped (e.g., no `optional<Schema.Schema>` or
`optionalWith<Schema.Schema>`)
- [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)

<Note>
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
valid where as `z.date()` is not.
</Note>

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
Expand Down
2 changes: 2 additions & 0 deletions packages/uploadthing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
juliusmarminge marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
2 changes: 1 addition & 1 deletion packages/uploadthing/src/internal/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 60 additions & 14 deletions packages/uploadthing/src/internal/parser.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,71 @@
import type { Json, MaybePromise } from "@uploadthing/shared";
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";

/**
* TODO: Do we wanna support effect/schema parsers now??
*/
import type { Json } from "@uploadthing/shared";

// Don't want to use Zod cause it's an optional dependency
export type ParseFn<TType> = (input: unknown) => MaybePromise<TType>;
export type ParserZodEsque<TInput, TParsedInput extends Json> = {
export type ParseFn<TType> = (input: unknown) => Promise<TType>;

juliusmarminge marked this conversation as resolved.
Show resolved Hide resolved
export type ParserZodEsque<TInput extends Json, TParsedInput> = {
_input: TInput;
_output: TParsedInput; // if using .transform etc
parse: ParseFn<TParsedInput>;
parseAsync: ParseFn<TParsedInput>;
};

// In case we add support for more parsers later
export type JsonParser = ParserZodEsque<Json, Json>;
export type JsonParser<In extends Json, Out = In> =
| ParserZodEsque<In, Out>
| Standard.v1.StandardSchema<In, Out>
| Schema.Schema<Out, In>;

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,
juliusmarminge marked this conversation as resolved.
Show resolved Hide resolved
TParser extends JsonParser<any, TOut>,
>(parser: TParser): ParseFn<TOut> {
if ("~standard" in parser) {
/**
* Standard Schema
*/
return async (value) => {
const result = await parser["~standard"].validate(value);
if (result.issues) {
throw new ParserError({ cause: result.issues });
}
return result.value;
};
}

if ("parseAsync" in parser && typeof parser.parseAsync === "function") {
/**
* Zod
juliusmarminge marked this conversation as resolved.
Show resolved Hide resolved
* TODO (next major): Consider wrapping ZodError in ParserError
*/
return parser.parseAsync;
}

export function getParseFn<TParser extends JsonParser>(
parser: TParser,
): ParseFn<TParser["_output"]> {
if (typeof parser.parse === "function") {
return parser.parse;
if (Schema.isSchema(parser)) {
/**
* Effect Schema
*/
return (value) =>
Schema.decodeUnknownPromise(parser as Schema.Schema<any, TOut>)(
value,
).catch((error) => {
throw new ParserError({
cause: Cause.squash(
(error as Runtime.FiberFailure)[Runtime.FiberFailureCauseId],
),
});
});
}

throw new Error("Invalid parser");
Expand Down
8 changes: 4 additions & 4 deletions packages/uploadthing/src/internal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ type UploadErrorFn<TArgs extends AdapterFnArgs<any, any, any>> = (
) => MaybePromise<void>;

export interface UploadBuilder<TParams extends AnyParams> {
input: <TParser extends JsonParser>(
input: <TIn extends Json, TOut>(
juliusmarminge marked this conversation as resolved.
Show resolved Hide resolved
parser: TParams["_input"]["in"] extends UnsetMarker
? TParser
? JsonParser<TIn, TOut>
: 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"];
Expand Down Expand Up @@ -173,7 +173,7 @@ export interface FileRoute<TTypes extends AnyBuiltUploaderTypes> {
$types: TTypes;
routerConfig: FileRouterInputConfig;
routeOptions: RouteOptions;
inputParser: JsonParser;
inputParser: JsonParser<any>;
middleware: MiddlewareFn<any, ValidMiddlewareObject, any>;
onUploadError: UploadErrorFn<any>;
errorFormatter: (err: UploadThingError) => any;
Expand Down
2 changes: 1 addition & 1 deletion packages/uploadthing/src/internal/upload-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function internalCreateBuilder<
},

inputParser: {
parse: () => undefined,
parseAsync: () => Promise.resolve(undefined),
_input: undefined,
_output: undefined,
},
Expand Down
209 changes: 209 additions & 0 deletions packages/uploadthing/test/input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
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, ParserError } 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<string>(opts.input);
return {};
})
.onUploadComplete(noop);

type Input = inferEndpointInput<typeof fileRoute>;
expectTypeOf<Input>().toMatchTypeOf<string>();

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<typeof fileRoute>;
expectTypeOf<Input>().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) => {
Comment on lines +55 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

⚠️ Potential issue

Effect Schema optional type wrapping requires different implementation approach

The FIXME comment is valid. The codebase shows that Effect Schema is used differently in the handler implementation, where optional fields are handled through Schema.optional and Schema.optionalWith in a piped manner. The test's current approach of wrapping the entire object schema doesn't align with this pattern. This is a legitimate technical limitation that requires a different implementation approach rather than a documentation update.

  • The handler implementation in packages/uploadthing/src/internal/handler.ts demonstrates the correct pattern: SomeType.pipe(Schema.optional) for optional fields
  • PR refactor(client): decouple some effects for inferrence #794 shows that Effect Schema usage has been intentionally reduced in the client bundle for size optimization
  • The test should be updated to use the piped optional pattern instead of wrapping the entire object
🔗 Analysis chain

Address Effect Schema optional type compatibility

The FIXME comment indicates that Effect Schema's optional type wrapping is incompatible with the current approach. This limitation should be:

  1. Documented in the main documentation
  2. Tracked as a known limitation
  3. Investigated for potential workarounds

Let's verify if this is a known issue:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for related issues or documentation about Effect Schema optional type handling
rg -g '!*.test.ts' "Schema.*optional" 

Length of output: 476


Script:

#!/bin/bash
# Let's check if there's any documentation or comments about Effect Schema compatibility
rg -g '!*.test.ts' -i "effect.*schema.*compatibility|effect.*schema.*limitation"

# Also check for any related issues in markdown files
rg -g '*.md' -i "effect.*schema|optional.*type"

# Let's see the context around the Effect Schema usage in handler.ts
rg -B 3 -A 3 "Schema.*optional" packages/uploadthing/src/internal/handler.ts

Length of output: 2154


Script:

#!/bin/bash
# Let's check the actual implementation to understand how optional types are handled
ast-grep --pattern 'const $_: $_ = $_Schema.optional'

# Let's also check for any comments or documentation about schema handling
rg -g '!*.test.ts' -B 2 -A 2 "Schema.optional|optional.*schema"

# And check the specific PR that removed Effect Schema from client bundle
gh pr view 794 2>/dev/null || true

Length of output: 2056

const fileRoute = f(["image"])
.input(input)
.middleware((opts) => {
expectTypeOf<{ foo: string } | undefined>(opts.input);
return {};
})
.onUploadComplete(noop);

type Input = inferEndpointInput<typeof fileRoute>;
expectTypeOf<Input>().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(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(ParserError);
expect(err.cause).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<typeof fileRoute>;
expectTypeOf<Input>().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<typeof fileRoute>;
expectTypeOf<Input>().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<typeof fileRoute>;
expectTypeOf<Input>().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);
});
Loading
Loading