Skip to content

Commit

Permalink
feat(servervalidationerror): adds a ServerValidationError, that user …
Browse files Browse the repository at this point in the history
…can throw from server action

When user throws the server validation error, it produces validationErrors in same way as when
schema validation fails.
  • Loading branch information
theboxer committed Jan 16, 2024
1 parent 2739f3a commit f606001
Show file tree
Hide file tree
Showing 4 changed files with 53 additions and 22 deletions.
18 changes: 7 additions & 11 deletions packages/example-app/src/app/login-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ const input = z.object({
password: z.string().min(8).max(100),
});

export const loginUser = action(input, async ({ username, password }) => {
export const loginUser = action(input, async ({ username, password }, _ctx, serverValidationError) => {
if (username === "johndoe") {
return {
error: {
reason: "user_suspended",
},
};
serverValidationError({
username: ['user_suspended'],
});
}

if (username === "user" && password === "password") {
Expand All @@ -23,9 +21,7 @@ export const loginUser = action(input, async ({ username, password }) => {
};
}

return {
error: {
reason: "incorrect_credentials",
},
};
serverValidationError({
username: ['incorrect_credentials'],
});
});
29 changes: 24 additions & 5 deletions packages/next-safe-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Infer, InferIn, Schema } from "@decs/typeschema";
import { wrap } from "@decs/typeschema";
import { isNotFoundError } from "next/dist/client/components/not-found.js";
import { isRedirectError } from "next/dist/client/components/redirect.js";
import type { MaybePromise } from "./utils";
import { buildValidationErrors, isError } from "./utils";
import type { MaybePromise, ValidationErrors } from "./utils";
import { buildValidationErrors, isError, isServerValidationError } from "./utils";

// TYPES

Expand All @@ -22,21 +22,30 @@ export type SafeClientOpts<Context> = {
export type SafeAction<S extends Schema, Data> = (input: InferIn<S>) => Promise<{
data?: Data;
serverError?: string;
validationErrors?: Partial<Record<keyof Infer<S> | "_root", string[]>>;
validationErrors?: ValidationErrors<S>;
}>;

/**
* Type of the function that executes server code when defining a new safe action.
*/
export type ServerCodeFn<S extends Schema, Data, Context> = (
parsedInput: Infer<S>,
ctx: Context
ctx: Context,
serverValidationError: (validationErrors: ValidationErrors<S>) => never,
) => Promise<Data>;

// UTILS

export const DEFAULT_SERVER_ERROR = "Something went wrong while executing the operation";

export class ServerValidationError<S extends Schema> extends Error {
public validationErrors: ValidationErrors<S>;
constructor(validationErrors: ValidationErrors<S>) {
super("Server Validation Error");
this.validationErrors = validationErrors;
}
}

// SAFE ACTION CLIENT

/**
Expand Down Expand Up @@ -69,6 +78,11 @@ export const createSafeActionClient = <Context>(createOpts?: SafeClientOpts<Cont
schema: S,
serverCode: ServerCodeFn<S, Data, Context>
): SafeAction<S, Data> => {
// Helper function to create ServerValidationError with injected schema
const serverValidationError = (validationErrors: ValidationErrors<S>) => {
throw new ServerValidationError<S>(validationErrors);
}

// This is the function called by client. If `input` fails the schema
// parsing, the function will return a `validationError` object, containing
// all the invalid fields provided.
Expand All @@ -88,7 +102,7 @@ export const createSafeActionClient = <Context>(createOpts?: SafeClientOpts<Cont

// Get `result.data` from the server code function. If it doesn't return
// anything, `data` will be `null`.
const data = ((await serverCode(parsedInput.data, ctx)) ?? null) as Data;
const data = ((await serverCode(parsedInput.data, ctx, serverValidationError)) ?? null) as Data;

return { data };
} catch (e: unknown) {
Expand All @@ -98,6 +112,11 @@ export const createSafeActionClient = <Context>(createOpts?: SafeClientOpts<Cont
throw e;
}

// If error is ServerValidationError, return validationErrors as if schema validation would fail
if (isServerValidationError<ValidationErrors<S>>(e)) {
return { validationErrors: e.validationErrors };
}

// If error cannot be handled, warn the user and return a generic message.
if (!isError(e)) {
console.warn("Could not handle server error. Not an instance of Error: ", e);
Expand Down
10 changes: 7 additions & 3 deletions packages/next-safe-action/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { Infer, Schema, ValidationIssue } from "@decs/typeschema";
import { ServerValidationError } from "./index";

export const isError = (error: unknown): error is Error => error instanceof Error;
export const isServerValidationError = <S extends Schema>(error: unknown): error is ServerValidationError<S> => error instanceof ServerValidationError;

export const isError = (error: any): error is Error => error instanceof Error;
export type MaybePromise<T> = Promise<T> | T;
export type ValidationErrors<S extends Schema> = Partial<Record<keyof Infer<S> | "_root", string[]>>;

// This function is used to build the validation errors object from a list of validation issues.
export const buildValidationErrors = <const S extends Schema>(issues: ValidationIssue[]) => {
const validationErrors = {} as Partial<Record<keyof Infer<S> | "_root", string[]>>;
const validationErrors = {} as ValidationErrors<S>;

const appendIssue = (path: keyof Infer<S> | "_root", message: string) => {
if (validationErrors[path]?.length) {
Expand All @@ -29,4 +33,4 @@ export const buildValidationErrors = <const S extends Schema>(issues: Validation
}

return validationErrors;
};
};
18 changes: 15 additions & 3 deletions packages/next-safe-action/src/zod.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { isNotFoundError } from "next/dist/client/components/not-found.js";
import { isRedirectError } from "next/dist/client/components/redirect.js";
import type { z } from "zod";
import type { SafeAction, SafeClientOpts, ServerCodeFn } from ".";
import { isError } from "./utils";
import type {SafeAction, SafeClientOpts, ServerCodeFn} from ".";
import { ServerValidationError} from ".";
import type { ValidationErrors } from "./utils";
import { isError, isServerValidationError } from "./utils";

// UTILS

Expand Down Expand Up @@ -40,6 +42,11 @@ export const createSafeActionClient = <Context>(createOpts?: SafeClientOpts<Cont
schema: S,
serverCode: ServerCodeFn<S, Data, Context>
): SafeAction<S, Data> => {
// Helper function to create ServerValidationError with injected schema
const serverValidationError = (validationErrors: ValidationErrors<S>) => {
throw new ServerValidationError<S>(validationErrors);
}

// This is the function called by client. If `input` fails the schema
// parsing, the function will return a `validationError` object, containing
// all the invalid fields provided.
Expand All @@ -63,7 +70,7 @@ export const createSafeActionClient = <Context>(createOpts?: SafeClientOpts<Cont

// Get `result.data` from the server code function. If it doesn't return
// anything, `data` will be `null`.
const data = ((await serverCode(parsedInput.data, ctx)) ?? null) as Data;
const data = ((await serverCode(parsedInput.data, ctx, serverValidationError)) ?? null) as Data;

return { data };
} catch (e: unknown) {
Expand All @@ -73,6 +80,11 @@ export const createSafeActionClient = <Context>(createOpts?: SafeClientOpts<Cont
throw e;
}

// If error is ServerValidationError, return validationErrors as if schema validation would fail
if (isServerValidationError<ValidationErrors<S>>(e)) {
return { validationErrors: e.validationErrors };
}

// If error cannot be handled, warn the user and return a generic message.
if (!isError(e)) {
console.warn("Could not handle server error. Not an instance of Error: ", e);
Expand Down

0 comments on commit f606001

Please sign in to comment.