Skip to content

Commit

Permalink
feat: support middleware chaining (#89)
Browse files Browse the repository at this point in the history
See #88
  • Loading branch information
TheEdoRan committed Apr 3, 2024
1 parent 9e38c05 commit 714554d
Show file tree
Hide file tree
Showing 37 changed files with 1,037 additions and 577 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ https://github.com/TheEdoRan/next-safe-action/assets/1337629/7ebc398e-6c7d-49b2-

- ✅ Pretty simple
- ✅ End-to-end type safety
-Context based clients (with middlewares)
-Powerful middleware system
- ✅ Input validation using multiple validation libraries
- ✅ Advanced server error handling
- ✅ Optimistic updates
Expand Down
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 21 additions & 18 deletions packages/example-app/src/app/(examples)/direct/login-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,32 @@ import { action } from "@/lib/safe-action";
import { returnValidationErrors } from "next-safe-action";
import { z } from "zod";

const input = z.object({
const schema = z.object({
username: z.string().min(3).max(10),
password: z.string().min(8).max(100),
});

export const loginUser = action(input, async ({ username, password }, ctx) => {
if (username === "johndoe") {
returnValidationErrors(input, {
export const loginUser = action
.metadata({ actionName: "loginUser" })
.schema(schema)
.define(async ({ username, password }, ctx) => {
if (username === "johndoe") {
returnValidationErrors(schema, {
username: {
_errors: ["user_suspended"],
},
});
}

if (username === "user" && password === "password") {
return {
success: true,
};
}

returnValidationErrors(schema, {
username: {
_errors: ["user_suspended"],
_errors: ["incorrect_credentials"],
},
});
}

if (username === "user" && password === "password") {
return {
success: true,
};
}

returnValidationErrors(input, {
username: {
_errors: ["incorrect_credentials"],
},
});
});
23 changes: 13 additions & 10 deletions packages/example-app/src/app/(examples)/hook/deleteuser-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
import { ActionError, action } from "@/lib/safe-action";
import { z } from "zod";

const input = z.object({
const schema = z.object({
userId: z.string().min(1).max(10),
});

export const deleteUser = action(input, async ({ userId }) => {
await new Promise((res) => setTimeout(res, 1000));
export const deleteUser = action
.metadata({ actionName: "deleteUser" })
.schema(schema)
.define(async ({ userId }) => {
await new Promise((res) => setTimeout(res, 1000));

if (Math.random() > 0.5) {
throw new ActionError("Could not delete user!");
}
if (Math.random() > 0.5) {
throw new ActionError("Could not delete user!");
}

return {
deletedUserId: userId,
};
});
return {
deletedUserId: userId,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { action } from "@/lib/safe-action";
import { z } from "zod";

const input = z
const schema = z
.object({
user: z.object({
id: z.string().uuid(),
Expand Down Expand Up @@ -68,8 +68,11 @@ const input = z
}
});

export const buyProduct = action(input, async () => {
return {
success: true,
};
});
export const buyProduct = action
.metadata({ actionName: "buyProduct" })
.schema(schema)
.define(async () => {
return {
success: true,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,23 @@ const incrementLikes = (by: number) => {
return likes;
};

const input = z.object({
const schema = z.object({
incrementBy: z.number(),
});

export const addLikes = action(input, async ({ incrementBy }) => {
await new Promise((res) => setTimeout(res, 2000));
export const addLikes = action
.metadata({ actionName: "addLikes" })
.schema(schema)
.define(async ({ incrementBy }) => {
await new Promise((res) => setTimeout(res, 2000));

const likesCount = incrementLikes(incrementBy);
const likesCount = incrementLikes(incrementBy);

// This Next.js function revalidates the provided path.
// More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath
revalidatePath("/optimistic-hook");
// This Next.js function revalidates the provided path.
// More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath
revalidatePath("/optimistic-hook");

return {
likesCount,
};
});
return {
likesCount,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { action } from "@/lib/safe-action";
import { randomUUID } from "crypto";
import { schema } from "./validation";

export const buyProduct = action(schema, async ({ productId }) => {
return {
productId,
transactionId: randomUUID(),
transactionTimestamp: Date.now(),
};
});
export const buyProduct = action
.metadata({ actionName: "buyProduct" })
.schema(schema)
.define(async ({ productId }) => {
return {
productId,
transactionId: randomUUID(),
transactionTimestamp: Date.now(),
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ const schema = zfd.formData({
password: zfd.text(z.string().min(8)),
});

export const signup = action(schema, async ({ email, password }) => {
console.log("Email:", email, "Password:", password);
return {
success: true,
};
});
export const signup = action
.metadata({ actionName: "signup" })
.schema(schema)
.define(async ({ email, password }) => {
console.log("Email:", email, "Password:", password);
return {
success: true,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,44 @@
import { authAction } from "@/lib/safe-action";
import { maxLength, minLength, object, string } from "valibot";

const input = object({
const schema = object({
fullName: string([minLength(3, "Too short"), maxLength(20, "Too long")]),
age: string([minLength(2, "Too young"), maxLength(3, "Too old")]),
});

export const editUser = authAction(
input,
// Here you have access to `userId`, which comes from `buildContext`
// return object in src/lib/safe-action.ts.
// \\\\\
async ({ fullName, age }, { userId }) => {
if (fullName.toLowerCase() === "john doe") {
return {
error: {
cause: "forbidden_name",
},
};
}
export const editUser = authAction
.metadata({ actionName: "editUser" })
.schema(schema)
.define(
// Here you have access to `userId`, and `sessionId which comes from middleware functions
// defined before.
// \\\\\\\\\\\\\\\\\\
async ({ fullName, age }, { ctx: { userId, sessionId } }) => {
if (fullName.toLowerCase() === "john doe") {
return {
error: {
cause: "forbidden_name",
},
};
}

const intAge = parseInt(age);

const intAge = parseInt(age);
if (Number.isNaN(intAge)) {
return {
error: {
reason: "invalid_age", // different key in `error`, will be correctly inferred
},
};
}

if (Number.isNaN(intAge)) {
return {
error: {
reason: "invalid_age", // different key in `error`, will be correctly inferred
success: {
newFullName: fullName,
newAge: intAge,
userId,
sessionId,
},
};
}

return {
success: {
newFullName: fullName,
newAge: intAge,
userId,
},
};
}
);
);
93 changes: 69 additions & 24 deletions packages/example-app/src/lib/safe-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,8 @@ import { DEFAULT_SERVER_ERROR, createSafeActionClient } from "next-safe-action";

export class ActionError extends Error {}

const handleReturnedServerError = (e: Error) => {
// If the error is an instance of `ActionError`, unmask the message.
if (e instanceof ActionError) {
return e.message;
}

// Otherwise return default error message.
return DEFAULT_SERVER_ERROR;
};

export const action = createSafeActionClient({
// You can provide a custom log Promise, otherwise the lib will use `console.error`
// You can provide a custom logging function, otherwise the lib will use `console.error`
// as the default logging system. If you want to disable server errors logging,
// just pass an empty Promise.
handleServerErrorLog: (e) => {
Expand All @@ -23,23 +13,78 @@ export const action = createSafeActionClient({
e.message
);
},
handleReturnedServerError,
handleReturnedServerError: (e) => {
// If the error is an instance of `ActionError`, unmask the message.
if (e instanceof ActionError) {
return e.message;
}

// Otherwise return default error message.
return DEFAULT_SERVER_ERROR;
},
}).use(async ({ next, metadata }) => {
// Here we use a logging middleware.
const start = Date.now();

// Here we await the next middleware.
const result = await next({ ctx: null });

const end = Date.now();

// Log the execution time of the action.
console.log(
"LOGGING MIDDLEWARE: this action took",
end - start,
"ms to execute"
);

// Log the result
console.log("LOGGING MIDDLEWARE: result ->", result);

// Log metadata
console.log("LOGGING MIDDLEWARE: metadata ->", metadata);

// And then return the result of the awaited next middleware.
return result;
});

export const authAction = createSafeActionClient({
// You can provide a middleware function. In this case, context is used
// for (fake) auth purposes.
middleware(parsedInput) {
async function getSessionId() {
return randomUUID();
}

export const authAction = action
// Clone the base client to extend this one with additional middleware functions.
.clone()
// In this case, context is used for (fake) auth purposes.
.use(async ({ next }) => {
const userId = randomUUID();

console.log("HELLO FROM FIRST AUTH ACTION MIDDLEWARE, USER ID:", userId);

return next({
ctx: {
userId,
},
});
})
// Here we get `userId` from the previous context, and it's all type safe.
.use(async ({ ctx, next }) => {
// Emulate a slow server.
await new Promise((res) =>
setTimeout(res, Math.max(Math.random() * 2000, 500))
);

const sessionId = await getSessionId();

console.log(
"HELLO FROM ACTION MIDDLEWARE, USER ID:",
userId,
"PARSED INPUT:",
parsedInput
"HELLO FROM SECOND AUTH ACTION MIDDLEWARE, SESSION ID:",
sessionId
);

return { userId };
},
handleReturnedServerError,
});
return next({
ctx: {
...ctx, // here we spread the previous context to extend it
sessionId, // with session id
},
});
});

0 comments on commit 714554d

Please sign in to comment.