From b049dfca8065db43d0980770168c00c9e0e66583 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Sun, 7 Apr 2024 16:22:05 +0800 Subject: [PATCH] Add enhanced route handler functionality and improve code This commit provides the addition of a new enhanced route handler functionality for Next.js utilities. The handler facilitates additional functionality, allowing more control over HTTP requests. Changes also include improved code where 'zodParseFactory' functionality moved to utils for better code reuse and organization. --- packages/next/README.md | 81 ++++++++++++++++++++++ packages/next/package.json | 3 +- packages/next/src/actions/index.ts | 16 +---- packages/next/src/routes/index.ts | 105 +++++++++++++++++++++++++++++ packages/next/src/utils/index.ts | 14 ++++ 5 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 packages/next/README.md create mode 100644 packages/next/src/routes/index.ts create mode 100644 packages/next/src/utils/index.ts diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 000000000..f8b49ea57 --- /dev/null +++ b/packages/next/README.md @@ -0,0 +1,81 @@ +# Next.js Utilities / @kit/next + +This package provides utilities for working with Next.js. + +## Server Actions + +The `enhanceAction` function allows you to wrap a Next.js Server Action with additional functionality. + +```ts +import { enhanceAction } from '@kit/next/actions'; + +export const myServerAction = enhanceAction(async (data, user) => { + // "data" has been parsed with the schema + // and will correctly be typed as the schema type + // in the case below, data will be of type { id: number } + + // "user" is the user object from the session + + // if "captcha" is true, the action will require a captcha +}, { + captcha: true, + schema: z.object({ + id: z.number() + }), +}); +``` + +The `enhanceAction` function takes two arguments: +1. The action function +2. An options object + +The options object can contain the following properties: +- `captcha` - If true, the action will require a captcha to be passed to the body as `captchaToken` +- `schema` - A zod schema that the data will be validated against + +When successfully called, the action will return the result of the action function. + +1. The user will be automatically authenticated and the result will be passed as the second argument to the action function. +2. The data will be parsed/validated with the schema and passed as the first argument to the action function. +3. If the `captcha` option is true, the action will require a `captchaToken` to be passed in the body. + +The consumer can call the action like so: + +```ts +import { myServerAction } from 'path/to/myServerAction'; + +const result = await myServerAction({ id: 1 }); +``` + +or with an optional captcha token: + +```ts +import { myServerAction } from 'path/to/myServerAction'; + +const result = await myServerAction({ id: 1, captchaToken: 'captchaToken' }); +``` + +## Route Handlers + +The function `enhanceRouteHandler` allows you to wrap a Next.js API Route Handler with additional functionality. + +```ts +import { enhanceRouteHandler } from '@kit/next/routes'; + +export const POST = enhanceRouteHandler(({ request, body, user }) => { + // "body" has been parsed with the schema + // and will correctly be typed as the schema type + // in the case below, data will be of type { id: number } + + // "user" is the user object from the session + + // "request" is the raw request object passed by POST + + // if "captcha" is true, the action will require a captcha +}, { + captcha: true, + schema: z.object({ + id: z.number() + }), +}); +``` \ No newline at end of file diff --git a/packages/next/package.json b/packages/next/package.json index e20007b4b..34a9c2346 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -10,7 +10,8 @@ }, "prettier": "@kit/prettier-config", "exports": { - "./actions": "./src/actions/index.ts" + "./actions": "./src/actions/index.ts", + "./routes": "./src/routes/index.ts" }, "peerDependencies": { "@kit/auth": "workspace:*", diff --git a/packages/next/src/actions/index.ts b/packages/next/src/actions/index.ts index 3c07d4d25..7887573d4 100644 --- a/packages/next/src/actions/index.ts +++ b/packages/next/src/actions/index.ts @@ -2,24 +2,14 @@ import { redirect } from 'next/navigation'; import type { User } from '@supabase/supabase-js'; +import 'server-only'; import { z } from 'zod'; import { verifyCaptchaToken } from '@kit/auth/captcha/server'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; -const parseFactory = - (schema: T) => - (data: unknown): z.infer => { - try { - return schema.parse(data); - } catch (err) { - console.error(err); - - // handle error - throw new Error(`Invalid data: ${err}`); - } - }; +import { zodParseFactory } from '../utils'; /** * @@ -58,7 +48,7 @@ export function enhanceAction< } // validate the schema - const parsed = parseFactory(config.schema); + const parsed = zodParseFactory(config.schema); const data = parsed(params); // pass the data to the action diff --git a/packages/next/src/routes/index.ts b/packages/next/src/routes/index.ts new file mode 100644 index 000000000..eb71780b4 --- /dev/null +++ b/packages/next/src/routes/index.ts @@ -0,0 +1,105 @@ +import { redirect } from 'next/navigation'; +import { NextRequest, NextResponse } from 'next/server'; + +import { User } from '@supabase/supabase-js'; + +import 'server-only'; +import { z } from 'zod'; + +import { verifyCaptchaToken } from '@kit/auth/captcha/server'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client'; + +import { zodParseFactory } from '../utils'; + +interface HandlerParams { + request: NextRequest; + user: User; + body: Body; +} + +/** + * Enhanced route handler function. + * + * This function takes a request and parameters object as arguments and returns a route handler function. + * The route handler function can be used to handle HTTP requests and apply additional enhancements + * based on the provided parameters. + * + * Usage: + * export const POST = enhanceRouteHandler( + * ({ request, body, user }) => { + * return new Response(`Hello, ${body.name}!`); + * }, + * { + * schema: z.object({ + * name: z.string(), + * }), + * }, + * ); + * + */ +export const enhanceRouteHandler = < + Body, + Schema extends z.ZodType, +>( + // Route handler function + handler: + | ((params: HandlerParams>) => NextResponse | Response) + | (( + params: HandlerParams>, + ) => Promise), + + // Parameters object + params?: { + captcha?: boolean; + schema?: Schema; + }, +) => { + /** + * Route handler function. + * + * This function takes a request object as an argument and returns a response object. + */ + return async function routeHandler(request: NextRequest) { + // Verify the captcha token if required + if (params?.captcha) { + const token = captchaTokenGetter(request); + + // If the captcha token is not provided, return a 400 response. + if (token) { + await verifyCaptchaToken(token); + } else { + return new Response('Captcha token is required', { status: 400 }); + } + } + + const client = getSupabaseRouteHandlerClient(); + const auth = await requireUser(client); + + // If the user is not authenticated, redirect to the specified URL. + if (auth.error) { + return redirect(auth.redirectTo); + } + + const user = auth.data; + + // clone the request to read the body + // so that we can pass it to the handler safely + let body = await request.clone().json(); + + if (params?.schema) { + body = zodParseFactory(params.schema)(body); + } + + // all good, call the handler with the request, body and user + return handler({ request, body, user }); + }; +}; + +/** + * Get the captcha token from the request headers. + * @param request + */ +function captchaTokenGetter(request: NextRequest) { + return request.headers.get('x-captcha-token'); +} diff --git a/packages/next/src/utils/index.ts b/packages/next/src/utils/index.ts new file mode 100644 index 000000000..35bfb0a6d --- /dev/null +++ b/packages/next/src/utils/index.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const zodParseFactory = + (schema: T) => + (data: unknown): z.infer => { + try { + return schema.parse(data); + } catch (err) { + console.error(err); + + // handle error + throw new Error(`Invalid data: ${err}`); + } + };