在这篇文章中,我们将学习如何在 C-Shopping 电商开源项目中,基于 Next.js 14 ,处理所有 API 路由中添加身份验证和错误处理中间件的思路与实现。
这篇文章中的代码片段取自我最近开源项目 C-Shopping
完整的项目和文档可在https://github.com/huanghanzhilian/c-shopping 地址查看。
项目在线演示地址:
docker 部署地址:http://shop.huanghanlian.com/
vercel 部署地址:https://c-shopping-three.vercel.app/
在 Next.js14 中,/app/api
文件夹包含所有基于文件名路由的 api 接口
例如文件 /app/api/user/route.js
会自动映射到路由 /api/user
。API 路由处理程序导出一个默认函数,该函数传递给 HTTP 请求处理程序。
有关 Next.js API 路由的更多信息,请参阅 https://nextjs.org/docs/app/building-your-application/routing/route-handlers。
下面是一个 API 路由处理程序的基本示例,它将用户列表返回给 HTTP GET 请求。
只需要导出一个支持 HTTP 协议名称,再返回一个 Response ,就完成了一个 API
export async function GET() { const res = await fetch('https://data.mongodb-api.com/...', { headers: { 'Content-Type': 'application/json', 'API-Key': process.env.DATA_API_KEY, }, }) const data = await res.json() return Response.json({ data }) }
我们会发现,如果按照官方的文档来写 API ,虽然简单,但是毫无设计感,当面对复杂项目时候很多引用会重复出现,我们需要设计一些中间间,来帮助我们更好的扩展 API 编码。
为了增加对中间件的支持,我创建了apiHandler
包装器函数,该包装器接受一个 API 处理程序对象,并返回一个HTTP
方法(例如GET
, POST
, PUT
, DELETE
等),再到route
文件导出该API
,这样就既简单又高效的做好了基础的编码设计。
通过apiHandler
包装器函数,再扩展了jwtMiddleware
、identityMiddleware
、validateMiddleware
、errorHandler
,来更好的设计优化代码:
项目中的路径 /helpers/api/api-handler.js
import { NextRequest, NextResponse } from 'next/server' import { errorHandler, jwtMiddleware, validateMiddleware, identityMiddleware } from '.' export { apiHandler } function isPublicPath(req) { // public routes that don't require authentication const publicPaths = ['POST:/api/auth/login', 'POST:/api/auth/logout', 'POST:/api/auth/register'] return publicPaths.includes(`${req.method}:${req.nextUrl.pathname}`) } function apiHandler(handler, { identity, schema, isJwt } = {}) { return async (req, ...args) => { try { if (!isPublicPath(req)) { // global middleware await jwtMiddleware(req, isJwt) await identityMiddleware(req, identity, isJwt) await validateMiddleware(req, schema) } // route handler const respOnseBody= await handler(req, ...args) return NextResponse.json(responseBody || {}) } catch (err) { console.log('global error handler', err) // global error handler return errorHandler(err) } } }
下面代码我们可以看到,使用了apiHandler
包装器
body
、query
、params
,查询数据,最后通过统一的setJson
返回数据结构isJwt
是否需要 JWT 校验、schema
需要校验的字段和类型、identity
操作的用户是否符合权限等。项目中的路径 /app/api/user/[id]/route.js
import joi from 'joi' import { usersRepo, apiHandler, setJson } from '@helpers' const updateRole = apiHandler( async (req, { params }) => { const { id } = params const body = await req.json() await usersRepo.update(id, body) return setJson({ message: '更新成功', }) }, { isJwt: true, schema: joi.object({ role: joi.string().required().valid('user', 'admin'), }), identity: 'root', } ) const deleteUser = apiHandler( async (req, { params }) => { const { id } = params await usersRepo.delete(id) return setJson({ message: '用户信息已经删除', }) }, { isJwt: true, identity: 'root', } ) export const PATCH = updateRole export const DELETE = deleteUser export const dynamic = 'force-dynamic'
项目中JWT
身份验证中间件是使用jsonwebtoken
库来验证发送到受保护 API 路由的请求中的 JWT 令牌,如果令牌无效,则抛出错误,导致全局错误处理程序返回 401 Unauthorized 响应。JWT 中间件被添加到 API 处理程序包装函数中的 Next.js 请求管道中。
项目中的路径:/api/jwt-middleware.js
import { auth } from '../' async function jwtMiddleware(req, isJwt = false) { const id = await auth.verifyToken(req, isJwt) req.headers.set('userId', id) } export { jwtMiddleware }
项目中的路径:/helpers/auth.js
import jwt from 'jsonwebtoken' const verifyToken = async (req, isJwt) => { try { const token = req.headers.get('authorization') const decoded = jwt.verify(token, process.env.NEXT_PUBLIC_ACCESS_TOKEN_SECRET) const id = decoded.id return new Promise(resolve => resolve(id)) } catch (error) { if (isJwt) { throw error } } } const createAccessToken = payload => { return jwt.sign(payload, process.env.NEXT_PUBLIC_ACCESS_TOKEN_SECRET, { expiresIn: '1d', }) } export const auth = { verifyToken, createAccessToken, }
在项目设计中,暂时只设计了user
普通用户、admin
管理员用户,以及一个超级管理员权限root
字段,在apiHandler()
包装器函数调用时,可以来控制该接口的权限以及身份。
如果权限不匹配,将抛出全局错误,进入 Next.js 请求管道中,交给全局错误处理程序,从而做到接口异常处理。
项目中的路径:/helpers/api/identity-middleware.js
import { usersRepo } from '../db-repo' async function identityMiddleware(req, identity = 'user', isJwt = false) { if (identity === 'user' && isJwt === false) return const userId = req.headers.get('userId') const user = await usersRepo.getOne({ _id: userId }) req.headers.set('userRole', user.role) req.headers.set('userRoot', user.root) if (identity === 'admin' && user.role !== 'admin') { throw '无权操作' } if (identity === 'root' && !user.root) { throw '无权操作,仅超级管理可操作' } } export { identityMiddleware }
在apiHandler()
包装器函数调用时,通过joi
工具,schema 参数,来指定需要接收和校验的参数,从而避免一些冗余的字段传递,减少异常的发生。
项目中的路径:/helpers/api/validate-middleware.js
import joi from 'joi' export { validateMiddleware } async function validateMiddleware(req, schema) { if (!schema) return const optiOns= { abortEarly: false, // include all errors allowUnknown: true, // ignore unknown props stripUnknown: true, // remove unknown props } const body = await req.json() const { error, value } = schema.validate(body, options) if (error) { throw `Validation error: ${error.details.map(x => x.message).join(', ')}` } // update req.json() to return sanitized req body req.json = () => value }
使用全局错误处理程序捕获所有错误,并消除了在整个 Next.js API 中重复错误处理代码的需要。
通常按照惯例,'string'类型的错误被视为自定义(特定于应用程序)错误,这简化了抛出自定义错误的代码,因为只需要抛出一个字符串(例如抛出'Username 或 password is incorrect'),如果自定义错误以'not found'结尾,则返回 404 响应代码,否则返回标准的 400 错误响应。
如果错误是一个名为“UnauthorizedError”的对象,则意味着 JWT 令牌验证失败,因此 HTTP 401 未经授权的响应代码将返回消息“无效令牌”。
所有其他(未处理的)异常都被记录到控制台,并返回一个 500 服务器错误响应代码。
项目中的路径:/helpers/api/error-handler.js
import { NextResponse } from 'next/server' import { setJson } from './set-json' export { errorHandler } function errorHandler(err) { if (typeof err === 'string') { // custom application error const is404 = err.toLowerCase().endsWith('not found') const status = is404 ? 404 : 400 return NextResponse.json( setJson({ message: err, code: status, }), { status } ) } if (err.name === 'JsonWebTokenError') { // jwt error - delete cookie to auto logout return NextResponse.json( setJson({ message: 'Unauthorized', code: '401', }), { status: 401 } ) } if (err.name === 'UserExistsError') { return NextResponse.json( setJson({ message: err.message, code: '422', }), { status: 422 } ) } // default to 500 server error console.error(err) return NextResponse.json( setJson({ message: err.message, code: '500', }), { status: 500 } ) }
为什么要这样设计?我们不想在每个route
中,来回的去引用NextResponse
,这会使得代码可读性很差,所以在apiHandler
包装器函数中,调用了 HTTP handler ,拿到了路由管道中想要的数据,最后统一输出。
项目中的路径:/helpers/api/set-json.js
const setJson = ({ code, message, data } = {}) => { return { code: code || 0, message: message || 'ok', data: data || null, } } export { setJson }
至此,我们已经完成了API
的设计,这将会给后期的开发带来效率,但同时也带来了代码的难以理解度,只能说设计程序需要有取舍,合适就好。这是我自己基于Next.js Route
的一些设计,也欢迎大家一起共同探讨。
![]() | 1 Dragonphy 2024-02-07 08:40:25 +08:00 Next 最近人人喊打啊,都在推 Remix |
![]() | 2 jixiaopeng OP @Dragonphy 我觉得能解决问题就是好的框架,就和谈对象一样,要看双方适不适合 |
![]() | 3 lstz 2024-02-07 09:34:21 +08:00 via Android 怎么不上 ts |
![]() | 4 jixiaopeng OP @lstz 后续会持续迭代,肯定要上 TS |
![]() | 6 jixiaopeng OP |
![]() | 7 dream4ever 2024-02-07 10:03:57 +08:00 写系列文章的话,可以在每一篇文章中把之前的各篇文章链接也附上,阅读体验会更好。 |
![]() | 8 jixiaopeng OP @dream4ever 谢谢老师的建议,我一定会改正,再次感激提醒 |
9 horizon 2024-02-07 10:45:58 +08:00 很好,我用 blitzjs |
![]() | 10 jixiaopeng OP @horizon 感激很好的样子 |
![]() | 11 xiaojun996 2024-02-07 14:21:10 +08:00 不错不错,虽然我现在是用 nextjs + nestjs 开发了 2 个项目,没有用 nextjs 写 api ,不过你这个文章让我收获不少,谢谢 upup |
![]() | 12 jixiaopeng OP @xiaojun996 哈哈,一起共同进步 |
![]() | 13 lilei2023 2024-02-07 17:06:48 +08:00 页面路由权限怎么搞? 没找一个最佳的办法,尤其是在服务端发送请求判断的时候, |
![]() | 14 jixiaopeng OP @lilei2023 您可以看看我开源项目源码,我是用 layout 组件来处理,后续我会更新这类文章。 |
![]() | 15 rizon 2024-02-09 07:43:24 +08:00 via iPhone 文章挺好的。 顺便随手感慨一句:js 这种语言果然可读性很差啊,ts 也就好个些许。python 这种缩进型的读起来也是痛苦。 单说阅读,在各种语法糖的加持下,需要更多的理解消耗。有时候不能做到一眼扫一下就看完的效果。还需要脑内加工一下。 就随便感慨一句。 |
![]() | 16 jixiaopeng OP @rizon 确实是,我在想我要尽快改成 ts |
![]() | 17 cheunghy 2024-02-12 05:06:06 +08:00 next 新出的 app 路由,坑太多了 |
![]() | 18 jixiaopeng OP @cheunghy 确实是,需要去踩踩,不过理解了感觉还行 |