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 middleware chaining #89

Merged
merged 16 commits into from
Apr 3, 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
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
},
});
});
Loading