Skip to content
7 changes: 2 additions & 5 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EndpointOptions } from "./endpoint";
import { _statusCode, APIError, type Status } from "./error";
import { _statusCode, APIError, ValidationError, type Status } from "./error";
import type {
InferParamPath,
InferParamWildCard,
Expand Down Expand Up @@ -183,10 +183,7 @@ export const createInternalContext = async (
const headers = new Headers();
const { data, error } = await runValidation(options, context);
if (error) {
throw new APIError(400, {
message: error.message,
code: "VALIDATION_ERROR",
});
throw new ValidationError(error.message, error.issues);
}
const requestHeaders: Headers | null =
"headers" in context
Expand Down
33 changes: 33 additions & 0 deletions src/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,39 @@ describe("response", () => {
message: "error message",
});
});

it("custom validation errors", async () => {
const endpoint = createEndpoint(
"/endpoint",
{
method: "POST",
body: z.string().min(1000),
onValidationError({ issues, message }) {
expect(typeof message).toBe("string");
expect(issues.length).toBeGreaterThan(0);
throw new APIError("I'M_A_TEAPOT", {
message: "Such a useful error status.",
});
},
},
async (c) => {
return c.json({
success: false, // Should never recieve this.
});
},
);
try {
const response = await endpoint({ body: "I'm less than 1000 characters" });
// This ensures that there is an error thrown.
expect(response).not.toBeCalled();
} catch (error) {
expect(error).toBeInstanceOf(APIError);
if (!(error instanceof APIError)) return;
// Ensure it's the validation error we defined.
expect(error.status).toBe("I'M_A_TEAPOT");
expect(error.message).toBe("Such a useful error status.");
}
});
});
describe("json", async () => {
it("should return the json directly", async () => {
Expand Down
41 changes: 35 additions & 6 deletions src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {
type Method,
} from "./context";
import type { CookieOptions, CookiePrefixOptions } from "./cookies";
import { APIError, type _statusCode, type Status } from "./error";
import { APIError, ValidationError, type _statusCode, type Status } from "./error";
import type { OpenAPIParameter, OpenAPISchemaType } from "./openapi";
import type { StandardSchemaV1 } from "./standard-schema";
import { isAPIError } from "./utils";
import { isAPIError, tryCatch } from "./utils";

export interface EndpointOptions {
/**
Expand Down Expand Up @@ -155,6 +155,14 @@ export interface EndpointOptions {
* @returns - The response to return
*/
onAPIError?: (e: APIError) => void | Promise<void>;
/**
* A callback to run before a validation error is thrown
* You can customize the validation error message by throwing your own APIError
*/
onValidationError?: ({
issues,
message,
}: { message: string; issues: readonly StandardSchemaV1.Issue[] }) => void | Promise<void>;
}

export type EndpointContext<Path extends string, Options extends EndpointOptions, Context = {}> = {
Expand Down Expand Up @@ -329,10 +337,31 @@ export const createEndpoint = <Path extends string, Options extends EndpointOpti
: [(Context & { asResponse?: AsResponse; returnHeaders?: ReturnHeaders })?]
) => {
const context = (inputCtx[0] || {}) as InputContext<any, any>;
const internalContext = await createInternalContext(context, {
options,
path,
});
const { data: internalContext, error: validationError } = await tryCatch(
createInternalContext(context, {
options,
path,
}),
);

if (validationError) {
// If it's not a validation error, we throw it
if (!(validationError instanceof ValidationError)) throw validationError;

// Check if the endpoint has a custom onValidationError callback
if (options.onValidationError) {
// This can possibly throw an APIError in order to customize the validation error message
await options.onValidationError({
message: validationError.message,
issues: validationError.issues,
});
}

throw new APIError(400, {
message: validationError.message,
code: "VALIDATION_ERROR",
});
}
const response = await handler(internalContext as any).catch(async (e) => {
if (isAPIError(e)) {
const onAPIError = options.onAPIError;
Expand Down
16 changes: 16 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { StandardSchemaV1 } from "./standard-schema";

export const _statusCode = {
OK: 200,
CREATED: 201,
Expand Down Expand Up @@ -145,3 +147,17 @@ export class APIError extends Error {
this.stack = "";
}
}

export class ValidationError extends APIError {
constructor(
public message: string,
public issues: readonly StandardSchemaV1.Issue[],
) {
super(400, {
message: message,
code: "VALIDATION_ERROR",
});

this.issues = issues;
}
}
21 changes: 21 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,24 @@ export function tryDecode(str: string) {
return str;
}
}

type Success<T> = {
data: T;
error: null;
};

type Failure<E> = {
data: null;
error: E;
};

export type Result<T, E = Error> = Success<T> | Failure<E>;

export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
try {
const data = await promise;
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}
12 changes: 4 additions & 8 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type ValidationResponse =
data: null;
error: {
message: string;
issues: readonly StandardSchemaV1.Issue[];
};
};

Expand Down Expand Up @@ -56,13 +57,13 @@ export async function runValidation(
if (options.requireHeaders && !context.headers) {
return {
data: null,
error: { message: "Headers is required" },
error: { message: "Headers is required", issues: [] },
};
}
if (options.requireRequest && !context.request) {
return {
data: null,
error: { message: "Request is required" },
error: { message: "Request is required", issues: [] },
};
}
return {
Expand All @@ -72,13 +73,8 @@ export async function runValidation(
}

export function fromError(error: readonly StandardSchemaV1.Issue[], validating: string) {
const errorMessages: string[] = [];

for (const issue of error) {
const message = issue.message;
errorMessages.push(message);
}
return {
message: `Invalid ${validating} parameters`,
Comment thread
ping-maxwell marked this conversation as resolved.
Outdated
issues: error,
};
}