Skip to content

Commit

Permalink
Merge pull request #2 from rexfordessilfie/add-middleware-wrappers
Browse files Browse the repository at this point in the history
feat: add middleware wrappers
  • Loading branch information
rexfordessilfie authored Jun 4, 2023
2 parents de1af71 + 90ded84 commit d0dfa37
Show file tree
Hide file tree
Showing 8 changed files with 598 additions and 140 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
dist
node_modules
demo
scratch.ts
scratch.ts
.DS_Store
220 changes: 200 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# next-route-handler-wrappers 🎁
Reusable, composable middleware for Next.js App Router [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/router-handlers).
Reusable, composable middleware for Next.js App Router [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) and [Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware).

# Instructions 🚀
1. First install the library using your favorite package manager:
Expand Down Expand Up @@ -54,13 +54,17 @@ Reusable, composable middleware for Next.js App Router [Route Handlers](https://
```
# Features ✨
## `wrapper()`
This lets you create a wrapper around a route handler that performs some arbitrary piece of logic.
Here are some of the utility methods provided by this library.
## `wrapper()` / `wrapperM()`
This lets you create a wrapper around a route/middleware handler that performs some arbitrary piece of logic.
It gives you access to the route handler's `request`, an `ext` object containing path parameters, and a `next` function for executing the wrapped route handler.
**Example - `authenticated`**: Ensure a user has been authenticated with next-auth before continuing with request, then attach current user to the request.
### Examples
**`authenticated` wrapper**:
Ensure a user has been authenticated with next-auth before continuing with request, then attach current user to the request.
```ts
import { getServerSession } from "next-auth/react";
import { Session } from "next-auth";
Expand All @@ -83,7 +87,8 @@ export const authenticated = wrapper(
);
```
**Example - `restrictedTo`**: Ensure that a user has the right role to access the API route.
**`restrictedTo` wrapper**:
Ensure that a user has the right role to access the API route.
```ts
import { wrapper, InferReq } from "next-route-handler-wrappers";
import { NextResponse } from "next/server";
Expand Down Expand Up @@ -122,7 +127,7 @@ export const restrictedTo = <R extends Role>(role: R) =>
});
```
## `stack()`
## `stack()` / `stackM()`
This lets you combine multiple wrappers to be applied within the same request. The wrappers are executed with the *last* wrapper being wrapped closest to the route handler.
Building from the example above, we can combine `restrictedTo` and `authenticated` wrappers to restrict a route to authenticated users with a particular role.
Expand All @@ -138,7 +143,7 @@ const restrictedToSuperAdmin = stack(authenticated).with(
);
```
## `chain()`
## `chain()` / `chainM()`
This also lets us combine wrappers similarly to `stack`, except that the wrappers are executed with the *first* wrapper being wrapped closest to the route handler.
Building from the previous example, we can express the above wrappers with `chain` as:
Expand All @@ -153,7 +158,7 @@ const restrictedToSuperAdmin = chain(restrictedTo("admin")).with(authenticated);
In general, `stack` is more ergonomic since we add onto the back, versus at the front with `chain`.
## `merge()`
## `merge()` / `mergeM()`
This is the most primitive way to combine multiple wrappers. It takes in two wrapper and combines them into one. The second wrapper is wrapped closest to the route handler.
Both `stack` and `chain` are built on top of `merge`!
Expand All @@ -168,7 +173,7 @@ const restrictedToAdmin = merge(authenticated, restrictedTo("admin"));
const restrictedToSuperAdmin = merge(authenticated, restrictedTo("superAdmin"));
```
> NB: `stack` and `chain` have a `.with()` for endless wrapper combination, but `merge` does not. However, since the result of `merge` is a wrapper, we can combine multiple `merge` calls to achieve the same effect:
> The `stack` and `chain` have a `.with()` for endless wrapper combination, but `merge` does not. However, since the result of `merge` is a wrapper, we can combine multiple `merge` calls to achieve the same effect:
```ts
import { merge } from "next-route-handler-wrappers"
import { w1, w2, w3, w4 } from "lib/wrappers"
Expand All @@ -179,7 +184,179 @@ const superWrapper = merge(merge(merge(w1, w2), w3), w4);
# Use-Cases 📝
Here are some common ideas and use-cases for `next-route-handler-wrappers`:
## Matching Paths in `middleware.ts`
We can define a `withMatched` wrapper that selectively applies a middleware logic based on the request path, building on top of Next.js' ["Matching Paths"](https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths) documentation.
### `withMatched()`
```ts
import { wrapperM, MiddlewareWrapperCallback } from "next-route-handler-wrappers";
type MatchConfig = {
paths?: RegExp[];
};
/**
* A wrapper that only applies the wrapped handler if the request matches the given paths
* @param config
* @param cb
* @returns
*/
function withMatched<Req extends Request, Res extends Response | void>(
config: MatchedConfig = { paths: [] },
cb: MiddlewareWrapperCallback<Req, Res>
) {
const { paths } = config;
const pathsRegex = paths
? new RegExp(paths.map((r) => r.source).join("|"))
: /.*/;
return wrapperM<Req, Res>((next, req) => {
const isMatch = pathsRegex.test(new URL(req.url).pathname)
if (isMatch){
return cb(next, req);
}
return next();
});
}
```
### Usage - Middleware Logging:
We can define a basic middleware that only logs a greeting for requests that match a certain path.
```ts
// middleware.ts
import { withMatched } from "lib/wrappers";
const withMatchedGreeting = withMatched(
{ paths: [/^\/api(\/.*)?$/] },
(next, req: NextRequest) => {
console.log(`Hello '${req.nextUrl.pathname}'!`);
const res = next();
console.log(`Goodbye '${req.nextUrl.pathname}'!`);
return res;
}
);
export const middleware = withMatchedGreeting(() => {
return NextResponse.next();
});
```
### Usage - Middleware Authentication
Or we can define an authentication middleware that only applies to certain paths using NextAuth.js' `withAuth` middleware.
```ts
// middleware.ts
import withAuth, {
NextAuthMiddlewareOptions,
NextRequestWithAuth
} from "next-auth/middleware";
import { withMatched } from "lib/wrappers";
function withMatchedAuth(
config?: MatchConfig,
authOptions?: NextAuthMiddlewareOptions
) {
return withMatched(config, (next, req: NextRequestWithAuth) =>
// @ts-expect-error - next-auth types do not narrow down to the expected function type
withAuth(next, authOptions ?? {})(req)
);
}
const authMatchConfig: MatchConfig = {
paths: [/^\/dashboard.*$/],
};
const authOptions: NextAuthMiddlewareOptions = {
pages: {
signIn: "/signin",
},
};
const withAuthentication = withMatchedAuth(authMatchConfig, authOptions);
export const middleware = withMatchedAuth(() => {
return NextResponse.next();
});
```
> NB: The above example will only invoke the `withAuth` middleware if the request matches the given paths. See the next section for a complex example that always invokes the `withAuth` middleware, but only redirects if the request matches the given paths.
## `withProtected`
If you always want to invoke the `withAuth` middleware, (for example, to set the `req.nextauth.token`) property regardless of the request path - but still redirect if the path is 'protected', you can define a custom wrapper with `wrapperM` and override `withAuth`'s redirect logic through its `authorized` callback option.
For example here we show a more complex example with multiple levels of protected paths (regular protected paths and admin-protected paths):
```ts
import withAuth, {
NextAuthMiddlewareOptions,
NextRequestWithAuth
} from "next-auth/middleware";
type MatchConfig = {
paths?: RegExp[];
adminPaths?: RegExp[];
};
function withProtectedMatchConfig(config: MatchConfig = { paths: [], adminPaths: [] }) {
const { paths, adminPaths } = config;
const pathsRegex = paths
? new RegExp(paths.map((r) => r.source).join("|"))
: /.*/;
const adminPathsRegex = adminPaths
? new RegExp(adminPaths.map((r) => r.source).join("|"))
: /.*/;
const authOptions: NextAuthMiddlewareOptions = {
callbacks: {
authorized({ token, req }) {
const isAdminPath = adminPathsRegex.test(new URL(req.url).pathname);
if (isAdminPath) {
// Admin path, so allow only if token is present and user is admin
return !!token && token.role === "admin";
}
const isProtectedPath = pathsRegex.test(new URL(req.url).pathname);
if (isProtectedPath) {
// Protected path, so allow only if token is present (NB: default behavior of withAuth)
return !!token;
}
// If not protected path, allow through (i.e no redirect)
return true;
}
}
};
// Return a wrapper that invokes withAuth with the given options
return wrapperM((next, req: NextRequestWithAuth) =>
// @ts-expect-error - next-auth types do not narrow down to the expected function
withAuth(next, authOptions)(req)
);
}
```
The above callback logic is adapted from NextAuth.js docs [here](https://next-auth.js.org/configuration/nextjs#advanced-usage).
### Usage
```ts
// middleware.ts
import { withProtectedMatchConfig, MatchConfig } from "lib/wrappers";
const protectedMatchConfig: MatchConfig = {
paths: [/^\/dashboard.*$/],
adminPaths: [/^\/admin.*$/],
};
const withProtected = withProtectedMatchConfig(protectedMatchConfig);
export const middleware = withProtected(() => {
return NextResponse.next();
});
```
## Logging x Error Handling
For logging and handling errors at the route handler level, we can use a `logged` wrapper. This one uses the [`pino`](https://getpino.io/#/) logger, but you can use any logger you want.
### `logged()`
```ts
import { wrapper } from "next-route-handler-wrappers";
Expand Down Expand Up @@ -241,6 +418,8 @@ export const GET = logged((request, { params }) => {
```
## DB Connections (Mongoose)
We can use the `dbConnected` wrapper to ensure that we have a connection ready before making database operations in a single request.
### `dbConnected()`
```ts
import { NextRequest } from "next/server";
Expand All @@ -251,12 +430,11 @@ import { dbConnect } from "lib/dbConnect"; // Source: https://github.com/vercel/
export const dbConnected = wrapper(
async (
request: NextRequest & { db: models; dbPromise: Promise<void> },
request: NextRequest & { dbConnected: Promise<void> },
ext,
next
) => {
request.dbPromise = dbConnect();
request.db = models;
request.dbConnected = dbConnect();
return next();
}
);
Expand All @@ -268,18 +446,20 @@ export const dbConnected = wrapper(
// app/api/user/[id]/route.ts
import { dbConnected } from "lib/wrappers";
import { NextRequest, NextResponse } from "next/server";
import { User } from "lib/models";
export const GET = dbConnected(
async (request: NextRequest, { params }: { params: { id: string } }) => {
const { id } = params;
await request.dbPromise;
const user = await request.db.User.findById(id);
await request.dbConnected;
const user = await User.findById(id);
return NextResponse.json(user);
}
);
```
## Request Validation
We can perform validation of any parts of the request, including the body, query, or even path parameters. We can use the [`zod`](https://zod.dev) validator for this, and then attach the parsed values to the request object.
### `validated()`
```ts
import { wrapper } from "next-route-handler-wrappers";
Expand Down Expand Up @@ -341,7 +521,7 @@ import {
import { NextResponse } from "next/server";
import { z } from "zod";
import { logged, validated } from "@/app/lib/wrappers";
import { User } from "lib/models";
const wrapped = stack(logged).with(dbConnected).with(authenticated);
Expand All @@ -354,8 +534,8 @@ export const GET = wrappedGet(async function (
request,
{ params }: { params: { id: string } }
) {
await request.dbPromise;
const result = request.db.User.findById(params.id);
await request.dbConnected;
const result = User.findById(params.id);
if (request.parsedQuery.friends) {
const user = await result.populate("friends");
Expand All @@ -378,15 +558,15 @@ const wrappedPost = wrapped
)
.with(
validated({
body: validated(userUpdateSchema)
body: userUpdateSchema
})
);
export const POST = wrappedPost(async function (
request,
{ params }: { params: { id: string } }
) {
const user = await request.db.User.findByIdAndUpdate(
const user = await User.findByIdAndUpdate(
params.id,
request.parsedBody,
{ new: true }
Expand All @@ -395,7 +575,7 @@ export const POST = wrappedPost(async function (
});
```
# Using 3rd-Party Route Handlers
## With [tRPC](https://trpc.io)
Adapted from [here](https://trpc.io/docs/server/adapters/nextjs#route-handlers)
```ts
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
{
"name": "next-route-handler-wrappers",
"version": "1.0.0",
"version": "1.1.0",
"main": "dist/index.js",
"license": "MIT",
"types": "dist/index.d.ts",
"author": "Rexford Essilfie <[email protected]>",
"repository": "https://github.com/rexfordessilfie/next-route-handler-wrappers",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"build:watch": "tsc -p tsconfig.json -w",
"test": "ava"
"test": "ava",
"prepublishOnly": "rm -rf dist && npm run build"
},
"keywords": [
"next",
Expand All @@ -20,7 +23,6 @@
"middleware",
"api"
],
"author": "Rexford Essilfie <[email protected]>",
"ava": {
"extensions": {
"ts": "module"
Expand Down
Loading

0 comments on commit d0dfa37

Please sign in to comment.