Skip to content

Commit

Permalink
fix: don't validate schema for non-ok requests (#770)
Browse files Browse the repository at this point in the history
  • Loading branch information
juliusmarminge committed Apr 29, 2024
1 parent 0abfa03 commit 594ae8a
Show file tree
Hide file tree
Showing 30 changed files with 869 additions and 1,085 deletions.
6 changes: 6 additions & 0 deletions .changeset/hungry-flies-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"uploadthing": patch
"@uploadthing/shared": patch
---

fix: add missing `fast-check` peer dependency from `@effect/schema`
7 changes: 7 additions & 0 deletions .changeset/kind-years-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@uploadthing/shared": patch
uploadthing: patch
---

fix: better error logging for bad requests
fix: add missing peer dep
7 changes: 1 addition & 6 deletions .vscode/operators.code-snippets
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@
},
"Gen Function": {
"prefix": "gen",
"body": ["function* ($) {}"],
"body": ["function* () {}"],
"description": "Generator FUnction with $ input",
},
"Gen Yield * tmp": {
"prefix": "yy",
"body": ["yield* $($0)"],
"description": "Yield generator calling $()",
},
}
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"dependencies": {
"@effect/schema": "^0.66.10",
"effect": "^3.0.7",
"fast-check": "^3.18.0",
"std-env": "^3.7.0"
},
"devDependencies": {
Expand Down
31 changes: 27 additions & 4 deletions packages/shared/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { pipe } from "effect/Function";
import * as Schedule from "effect/Schedule";

import { FetchError } from "./tagged-errors";
import type { FetchEsque, ResponseEsque } from "./types";
import type { FetchEsque, Json, ResponseEsque } from "./types";
import { filterObjectValues } from "./utils";

export type FetchContextTag = {
Expand Down Expand Up @@ -50,21 +50,44 @@ export const fetchEff = (

export const fetchEffJson = <Schema>(
input: RequestInfo | URL,
/** Schema to be used if the response returned a 2xx */
schema: S.Schema<Schema, any>,
init?: RequestInit,
): Effect.Effect<Schema, FetchError | ParseError, FetchContextTag> =>
fetchEff(input, init).pipe(
): Effect.Effect<Schema, FetchError | ParseError, FetchContextTag> => {
const requestUrl =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;

return fetchEff(input, init).pipe(
Effect.andThen((res) =>
Effect.tryPromise({
try: () => res.json(),
try: async () => {
const json = await res.json();
return { ok: res.ok, json, status: res.status };
},
catch: (error) => new FetchError({ error, input }),
}),
),
Effect.andThen(({ ok, json, status }) =>
ok
? Effect.succeed(json)
: Effect.fail(
new FetchError({
error: `Request to ${requestUrl} failed with status ${status}`,
data: json as Json,
input,
}),
),
),
Effect.andThen(S.decode(schema)),
Effect.withSpan("fetchJson", {
attributes: { input: JSON.stringify(input) },
}),
);
};

export const parseRequestJson = <Schema>(
reqOrRes: Request | ResponseEsque,
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/tagged-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { TaggedError } from "effect/Data";

import type { Json } from "./types";

export class InvalidRouteConfigError extends TaggedError("InvalidRouteConfig")<{
reason: string;
}> {
Expand Down Expand Up @@ -51,4 +53,5 @@ export class RetryError extends TaggedError("RetryError") {}
export class FetchError extends TaggedError("FetchError")<{
readonly input: RequestInfo | URL;
readonly error: unknown;
readonly data?: Json;
}> {}
187 changes: 90 additions & 97 deletions packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as Effect from "effect/Effect";
import * as Unify from "effect/Unify";
import { process } from "std-env";

import { lookup } from "@uploadthing/mime-types";
Expand Down Expand Up @@ -47,86 +46,82 @@ export function getDefaultSizeForType(fileType: FileRouterInputKey): FileSize {
* ```
*/

export const fillInputRouteConfig = Unify.unify(
(
routeConfig: FileRouterInputConfig,
): Effect.Effect<ExpandedRouteConfig, InvalidRouteConfigError> => {
// If array, apply defaults
if (isRouteArray(routeConfig)) {
return Effect.succeed(
routeConfig.reduce<ExpandedRouteConfig>((acc, fileType) => {
acc[fileType] = {
// Apply defaults
maxFileSize: getDefaultSizeForType(fileType),
maxFileCount: 1,
minFileCount: 1,
contentDisposition: "inline" as const,
};
return acc;
}, {}),
);
}
export const fillInputRouteConfig = (
routeConfig: FileRouterInputConfig,
): Effect.Effect<ExpandedRouteConfig, InvalidRouteConfigError> => {
// If array, apply defaults
if (isRouteArray(routeConfig)) {
return Effect.succeed(
routeConfig.reduce<ExpandedRouteConfig>((acc, fileType) => {
acc[fileType] = {
// Apply defaults
maxFileSize: getDefaultSizeForType(fileType),
maxFileCount: 1,
minFileCount: 1,
contentDisposition: "inline" as const,
};
return acc;
}, {}),
);
}

// Backfill defaults onto config
const newConfig: ExpandedRouteConfig = {};
for (const key of objectKeys(routeConfig)) {
const value = routeConfig[key];
if (!value) return Effect.fail(new InvalidRouteConfigError(key));
// Backfill defaults onto config
const newConfig: ExpandedRouteConfig = {};
for (const key of objectKeys(routeConfig)) {
const value = routeConfig[key];
if (!value) return Effect.fail(new InvalidRouteConfigError(key));

const defaultValues = {
maxFileSize: getDefaultSizeForType(key),
maxFileCount: 1,
minFileCount: 1,
contentDisposition: "inline" as const,
};
const defaultValues = {
maxFileSize: getDefaultSizeForType(key),
maxFileCount: 1,
minFileCount: 1,
contentDisposition: "inline" as const,
};

newConfig[key] = { ...defaultValues, ...value };
}
newConfig[key] = { ...defaultValues, ...value };
}

return Effect.succeed(newConfig);
},
);

export const getTypeFromFileName = Unify.unify(
(
fileName: string,
allowedTypes: FileRouterInputKey[],
): Effect.Effect<
FileRouterInputKey,
UnknownFileTypeError | InvalidFileTypeError
> => {
const mimeType = lookup(fileName);
if (!mimeType) {
if (allowedTypes.includes("blob")) return Effect.succeed("blob");
return Effect.fail(new UnknownFileTypeError(fileName));
}
return Effect.succeed(newConfig);
};

// If the user has specified a specific mime type, use that
if (allowedTypes.some((type) => type.includes("/"))) {
if (allowedTypes.includes(mimeType)) {
return Effect.succeed(mimeType);
}
export const getTypeFromFileName = (
fileName: string,
allowedTypes: FileRouterInputKey[],
): Effect.Effect<
FileRouterInputKey,
UnknownFileTypeError | InvalidFileTypeError
> => {
const mimeType = lookup(fileName);
if (!mimeType) {
if (allowedTypes.includes("blob")) return Effect.succeed("blob");
return Effect.fail(new UnknownFileTypeError(fileName));
}

// If the user has specified a specific mime type, use that
if (allowedTypes.some((type) => type.includes("/"))) {
if (allowedTypes.includes(mimeType)) {
return Effect.succeed(mimeType);
}
}

// Otherwise, we have a "magic" type eg. "image" or "video"
const type = (
mimeType.toLowerCase() === "application/pdf"
? "pdf"
: mimeType.split("/")[0]
) as AllowedFileType;

if (!allowedTypes.includes(type)) {
// Blob is a catch-all for any file type not explicitly supported
if (allowedTypes.includes("blob")) {
return Effect.succeed("blob");
} else {
return Effect.fail(new InvalidFileTypeError(type, fileName));
}
// Otherwise, we have a "magic" type eg. "image" or "video"
const type = (
mimeType.toLowerCase() === "application/pdf"
? "pdf"
: mimeType.split("/")[0]
) as AllowedFileType;

if (!allowedTypes.includes(type)) {
// Blob is a catch-all for any file type not explicitly supported
if (allowedTypes.includes("blob")) {
return Effect.succeed("blob");
} else {
return Effect.fail(new InvalidFileTypeError(type, fileName));
}
}

return Effect.succeed(type);
},
);
return Effect.succeed(type);
};

export function generateUploadThingURL(path: `/${string}`) {
let host = "https://uploadthing.com";
Expand All @@ -138,25 +133,25 @@ export function generateUploadThingURL(path: `/${string}`) {

export const FILESIZE_UNITS = ["B", "KB", "MB", "GB"] as const;
export type FileSizeUnit = (typeof FILESIZE_UNITS)[number];
export const fileSizeToBytes = Unify.unify(
(fileSize: FileSize): Effect.Effect<number, InvalidFileSizeError> => {
const regex = new RegExp(
`^(\\d+)(\\.\\d+)?\\s*(${FILESIZE_UNITS.join("|")})$`,
"i",
);

// make sure the string is in the format of 123KB
const match = fileSize.match(regex);
if (!match) {
return Effect.fail(new InvalidFileSizeError(fileSize));
}
export const fileSizeToBytes = (
fileSize: FileSize,
): Effect.Effect<number, InvalidFileSizeError> => {
const regex = new RegExp(
`^(\\d+)(\\.\\d+)?\\s*(${FILESIZE_UNITS.join("|")})$`,
"i",
);

// make sure the string is in the format of 123KB
const match = fileSize.match(regex);
if (!match) {
return Effect.fail(new InvalidFileSizeError(fileSize));
}

const sizeValue = parseFloat(match[1]);
const sizeUnit = match[3].toUpperCase() as FileSizeUnit;
const bytes = sizeValue * Math.pow(1024, FILESIZE_UNITS.indexOf(sizeUnit));
return Effect.succeed(Math.floor(bytes));
},
);
const sizeValue = parseFloat(match[1]);
const sizeUnit = match[3].toUpperCase() as FileSizeUnit;
const bytes = sizeValue * Math.pow(1024, FILESIZE_UNITS.indexOf(sizeUnit));
return Effect.succeed(Math.floor(bytes));
};

export const bytesToFileSize = (bytes: number) => {
if (bytes === 0 || bytes === -1) {
Expand Down Expand Up @@ -267,19 +262,17 @@ export function semverLite(required: string, toCheck: string) {
export const getFullApiUrl = (
maybeUrl?: string,
): Effect.Effect<URL, InvalidURLError> =>
Effect.gen(function* ($) {
Effect.gen(function* () {
const base = (() => {
if (typeof window !== "undefined") return window.location.origin;
if (process.env?.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "http://localhost:3000";
})();

const url = yield* $(
Effect.try({
try: () => new URL(maybeUrl ?? "/api/uploadthing", base),
catch: () => new InvalidURLError(maybeUrl ?? "/api/uploadthing"),
}),
);
const url = yield* Effect.try({
try: () => new URL(maybeUrl ?? "/api/uploadthing", base),
catch: () => new InvalidURLError(maybeUrl ?? "/api/uploadthing"),
});

if (url.pathname === "/") {
url.pathname = "/api/uploadthing";
Expand Down
1 change: 1 addition & 0 deletions packages/uploadthing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"@uploadthing/shared": "workspace:*",
"consola": "^3.2.3",
"effect": "^3.0.7",
"fast-check": "^3.18.0",
"std-env": "^3.7.0"
},
"devDependencies": {
Expand Down

0 comments on commit 594ae8a

Please sign in to comment.