在本文中,我们将使用 Node.js 构建一个简单的博客 API。 API代表“应用程序编程接口”,它允许不同的软件系统相互通信。 在这种情况下,我们的博客 API 将允许我们创建、读取、更新和删除博客文章,以及管理用户身份验证。
为什么要使用 Node.js 构建博客 API? Node.js 是一种流行的开源运行时环境,用于在浏览器外部执行 JavaScript 代码。 它拥有庞大而活跃的开发人员社区以及丰富的库和框架,可以轻松构建可扩展的高性能 Web 应用程序。
以下是我们需要的:
npm init -y
npm install --save express dotenv cors express-rate-limit helmet express-fileupload
现在我们已经安装了依赖项,让我们为项目创建文件结构。 在项目目录中创建以下目录和文件:
让我们设置项目所需的环境变量。 随着我们的构建逐渐完成,为什么需要每个环境变量会变得逐渐清晰。
避免暴露 API ,因为这存在安全风险。 如果你使用版本控制,你可以设置一个配置文件,可以在不暴露任何秘密的情况下推送,或者你可以创建一个 .env.example 只显示变量名,没有值。
在 /src/config 中创建 index.js 文件:
require("dotenv").config();module.exports = { PORT: process.env.PORT, MONGODB_CONNECTION_URL: process.env.MONGODB_CONNECTION_URL, JWT_SECRET: process.env.JWT_SECRET, CLOUDINARY_URL: process.env.CLOUDINARY_URL,};
在根目录中,创建一个 .env 文件。 我们将在构建过程中更新这些值:
PORT=5000MONGODB_CONNECTION_URL=JWT_SECRET=CLOUDINARY_URL=
const express = require("express");const rateLimit = require("express-rate-limit");const helmet = require("helmet");const cors = require("cors");const CONFIG = require("./src/config");const app = express();app.use(cors());const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: true, message: "请求过多,请15分钟后重试",});app.use(limiter);app.use(helmet());app.use(express.json());app.use(express.urlencoded({ extended: true }));app.use( fileUpload({ createParentPath: true, useTempFiles: true, tempFileDir: "/tmp/", }));app.get("/", (req, res) => { return res.json({ status: true });});// 404 error处理app.use("*", (req, res) => { return res.status(404).json({ message: "路由未找到" });});// Error处理app.use(function (err, req, res, next) { console.log(err.message); res.status(err.status || 500).send("发生错误");});module.exports = app;
我们设置了以下中间件:
cors 允许跨源资源共享,这意味着我们的 API 可以从不同的域访问。
代码中添加了限速中间件,可以限制用户在一定时间内允许发出的请求数量。 这有助于防止恶意用户试图用暴力请求攻击服务器:
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: true, message: ""请求过多,请15分钟后重试",});app.use(limiter);
我们还有helmet中间件,它通过设置各种 HTTP 标头来帮助保护 API。
然后,是以下中间件,它允许 API 处理请求正文中的 JSON 和 URL 编码数据:
app.use(express.json()); app.use(express.urlencoded({ extended: true }));
最后,我们有 fileUpload 中间件,它允许 API 处理文件上传:
app.use(fileUpload({ createParentPath: true, useTempFiles: true, tempFileDir: "/tmp/", }));
const app = require("./app");const CONFIG = require("./src/config");app.listen(CONFIG.PORT, () => { console.log(`server listening on port ${CONFIG.PORT}`);});
前面我们设置了开发环境和基本文件结构。 现在,让我们设计 API 本身。 在节中,我们将讨论以下主题:
设计API 的第一步是决定要包含的路由和功能。
概述一下我们博客的要求:
考虑到上述要求,我们将定义路由如下:
博客路由:
作者路由: 我们希望只有经过身份验证的用户才能访问这些路由和所有 CRUD 操作。
认证路由: 用于管理用户身份验证。
定义好路由后,我们就可以开始考虑数据库的数据模型了。 数据模型表示将存储在数据库中的数据以及这些数据之间的关系。 我们将使用 Mongoose 来定义我们的架构。
我们将有两个数据模型:Blog和User。
字段名 | 数据类型 | 约束 |
firstname | String | required |
lastname | String | required |
String | required, unique, index | |
password | String | required |
articles | Array, [ObjectId] | ref - Blog |
字段名 | 数据类型 | 约束 |
title | String | required, unique, index |
description | String | |
tags | Array, [String] | |
imageUrl | String | |
author | ObjectId | ref - Users |
timestamp | Date | |
state | String | required, enum: ['draft', 'published'], default:'draft' |
readCount | Number | default:0 |
readingTime | String | |
body | String | required |
Mongoose 有一个名为 populate() 的方法,它允许您引用其他集合中的文档。 populate() 将自动用其他集合中的文档替换文档中的指定路径。 User 模型将其 articles 字段设置为 ObjectId 的数组。 ref 选项告诉 Mongoose 在填充期间使用哪个模型,在本例中为Blog模型。 我们在这里存储的所有 _id 必须是博客模型中的文章 _id。 同样,Blog 模型在其author字段中引用了 User 模型。
const mongoose = require("mongoose");const uniqueValidator = require('mongoose-unique-validator');const { Schema } = mongoose;const BlogSchema = new Schema({ title: { type: String, required: true, unique: true, index: true }, description: String, tags: [String], author: { type: Schema.Types.ObjectId, ref: "Users" }, timestamp: Date, imageUrl: String, state: { type: String, enum: ["draft", "published"], default: "draft" }, readCount: { type: Number, default: 0 }, readingTime: String, body: { type: String, required: true },});// 将 uniqueValidator 插件应用于blog模型BlogSchema.plugin(uniqueValidator);const Blog = mongoose.model("Blog", BlogSchema);module.exports = Blog;
title字段被定义为必填的字符串,并且在集合中的所有文档中必须是唯一的。 description 字段定义为字符串,tags 字段定义为字符串数组。 author字段定义为对用户集合中文档的引用,timestamp字段定义为日期。 imageUrl 字段定义为字符串,state 字段定义为具有一组允许值(“draft”或“published”)的字符串,readCount 字段定义为默认值为 0 的数字。 readingTime 字段定义为字符串,body 字段定义为必填字符串。
mongoose-unique-validator 是一个插件,它为 Mongoose 模式中的唯一字段添加预保存验证。 如果唯一字段的值已存在于集合中,它将验证模型中的唯一选项,并阻止插入文档。
const mongoose = require("mongoose");const uniqueValidator = require("mongoose-unique-validator");const bcrypt = require("bcrypt");const { Schema } = mongoose;const UserModel = new Schema({ firstname: { type: String, required: true }, lastname: { type: String, required: true }, email: { type: String, required: true, unique: true, index: true, }, password: { type: String, required: true }, articles: [{ type: Schema.Types.ObjectId, ref: "Blog" }],});// 将 uniqueValidator 插件应用于用户模型UserModel.plugin(uniqueValidator);UserModel.pre("save", async function (next) { const user = this; if (user.isModified("password") || user.isNew) { const hash = await bcrypt.hash(this.password, 10); this.password = hash; } else { return next(); }});const User = mongoose.model("Users", UserModel);module.exports = User;
firstname和lastname字段被定义为必填字符串,email字段被定义为必填字符串,并且在集合中的所有文档中必须是唯一的。 password 字段定义为必填字符串,articles 字段定义为对 Blog 集合中文档的引用数组。
pre函数在特定方法执行前执行特定代码。 在将用户文档保存到数据库之前,此处的预保存函数使用 npm 模块 bcrypt 对用户密码进行哈希加密处理。
现在我们已经定义了路由和数据模型,是时候设置数据库连接了。
npm install --save mongoose
const mongoose = require('mongoose');const connect = (url) => { mongoose.connect(url || 'mongodb://[localhost:27017](http://localhost:27017)')mongoose.connection.on("connected", () => { console.log("Connected to MongoDB Successfully"); });mongoose.connection.on("error", (err) => { console.log("An error occurred while connecting to MongoDB"); console.log(err); }); }module.exports = { connect };
connect 函数有一个可选的 url 参数,它指定要连接的数据库的 URL。 如果未提供 URL,则默认为“mongodb://localhost:27017”,这将连接到本机运行的MongoDB数据库实例的默认端口 (27017) 。
const database = require("./db");module.exports = { database,};
现在我们已经建立了数据库连接,在下文中,我们将深入探讨两个重要的概念——身份验证和数据验证。
让我们从安装所需的依赖项开始:
npm install joi jsonwebtoken passport passport-jwt passport-local passport-local-mongoose
为了在我们的 API 中对用户进行身份验证,我们将使用流行的 Passport.js 库。 当用户登录时,我们将为用户生成一个 JWT 令牌。 该令牌将用于授权用户对 API 的请求。
在
/authentication/passport.js 文件中,设置 JWT 策略:
const passport = require("passport"); const localStrategy = require("passport-local").Strategy; const { UserModel } = require("../models"); const JWTstrategy = require("passport-jwt").Strategy; const ExtractJWT = require("passport-jwt").ExtractJwt; passport.use( new JWTstrategy( { secretOrKey: process.env.JWT_SECRET, jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken() }, async (token, done) => { try { return done(null, token.user); } catch (error) { done(error); } } ) );
JSON Web Token (JWT) 策略允许我们通过验证用户的 JWT 来对用户进行身份验证。 要使用此策略,我们传入一个密钥(我们已将其存储在 .env 文件中)和一个函数。
ExtractJWT.fromAuthHeaderAsBearerToken() 方法将从请求的授权标头中提取 JWT 作为 Bearer 令牌。 如果找到 JWT,它将被解码并与 done 函数一起传递给该函数。 该函数将检查 JWT 中的用户对象以查看其是否有效。 如果有效,则调用带有用户对象的 done 函数,表明用户已通过身份验证,否则将调用 done 函数并出错。
对于 /signup 路由,我们将设置一个本地策略,它使用 passport-local 模块通过用户名(在本例中为他们的电子邮件地址)和密码对用户进行身份验证。 当用户注册时,我们将在数据库中创建一个新的用户文档并将其返回给 Passport。
passport.use( "signup", new localStrategy( { usernameField: "email", passwordField: "password", passReqToCallback: true }, async (req, email, password, done) => { try { const user = await UserModel.create({ ...req.body, password }); return done(null, user); } catch (error) { done(error); } } ) );
passport.use( "login", new localStrategy( { usernameField: "email", passwordField: "password", passReqToCallback: true }, async (req, email, password, done) => { try { const user = await UserModel.findOne({ email }); if (!user) { return done(null, false, { message: "User not found" }); } return done(null, user, { message: "Logged in Successfully" }); } catch (error) { return done(error); } } ));
我们仍然需要验证用户的密码。 在
/src/models/user.models.js 中,在 User 模型中创建一个名为 isValidPassword() 的方法。
UserModel.methods.isValidPassword = async function (password) { const user = this; const match = await bcrypt.compare(password, user.password); return match;};
isValidPassword 将密码作为参数,并使用 bcrypt 的比较方法将其与用户的散列密码进行比较。 该方法返回一个布尔值,指示密码是否匹配。
在
src/authentication/passport.js中,在登录策略中调用isValidPassword验证用户密码:
const validate = await user.isValidPassword(password);if (!validate) { return done(null, false, {message: "Wrong Password" });}
完整的文件应如下所示:
const passport = require("passport");const localStrategy = require("passport-local").Strategy;const { UserModel } = require("../models");const JWTstrategy = require("passport-jwt").Strategy;const ExtractJWT = require("passport-jwt").ExtractJwt;passport.use( new JWTstrategy( { secretOrKey: process.env.JWT_SECRET, jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken() }, async (token, done) => { try { return done(null, token.user); } catch (error) { done(error); } } ));passport.use( "signup", new localStrategy( { usernameField: "email", passwordField: "password", passReqToCallback: true }, async (req, email, password, done) => { try { const user = await UserModel.create({ ...req.body, password }); return done(null, user); } catch (error) { done(error); } } ));passport.use( "login", new localStrategy( { usernameField: "email", passwordField: "password", passReqToCallback: true }, async (req, email, password, done) => { try { const user = await UserModel.findOne({ email }); if (!user) { return done(null, false, { message: "User not found" }); } const validate = await user.isValidPassword(password); if (!validate) { return done(null, false, { message: "Wrong Password" }); } return done(null, user, { message: "Logged in Successfully" }); } catch (error) { return done(error); } } ));
为了验证用户输入,我们将使用 Joi 库。 Joi 提供了一种简单但功能强大的方法来定义和验证 Node.js 应用程序中的数据结构。
在 /validators 目录中,创建一个名为 author.validator.js 的文件:
const Joi = require("joi");const newArticleValidationSchema = Joi.object({ title: Joi.string().trim().required(), body: Joi.string().trim().required(), description: Joi.string().trim(), tags: Joi.string().trim(),});const updateArticleValidationSchema = Joi.object({ title: Joi.string().trim(), body: Joi.string().trim(), description: Joi.string().trim(), tags: Joi.string().trim(), state: Joi.string().trim(),});const newArticleValidationMW = async (req, res, next) => { const article = req.body; try { await newArticleValidationSchema.validateAsync(article); next(); } catch (error) { return next({ status: 406, message: error.details[0].message }); }};const updateArticleValidationMW = async (req, res, next) => { const article = req.body; try { await updateArticleValidationSchema.validateAsync(article); next(); } catch (error) { return next({ status: 406, message: error.details[0].message }); }};module.exports = { newArticleValidationMW, updateArticleValidationMW,};
我们导出两个中间件函数,newArticleValidationMW 和 updateArticleValidationMW。 newArticleValidationMW 使用
newArticleValidationSchema 来验证新建文章请求的请求正文是否包含所有必填字段(标题、正文)以及所有提供的字段是否格式正确。 如果所有字段都有效,它将调用下一个函数以继续。 updateArticleValidationMW,与前者类似,但它使用
updateArticleValidationSchema 来验证更新文章请求的请求。
这两个函数都使用 Joi 库提供的 validateAsync 方法来执行验证。 此方法接受一个对象(请求主体)并返回一个承诺(promise),如果对象无效则拒绝该对象,如果对象有效则返回该对象。
在 /validators 目录中,创建一个名为 user.validator.js 的文件:
const Joi = require("joi");const validateUserMiddleware = async (req, res, next) => { const user = req.body; try { await userValidator.validateAsync(user); next(); } catch (error) { return next({ status: 406, message: error.details[0].message }); }};const userValidator = Joi.object({ firstname: Joi.string().min(2).max(30).required(), lastname: Joi.string().min(2).max(30).required(), email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ["com", "net"] }, }), password: Joi.string() .pattern(new RegExp("^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})")) .required(),});module.exports = validateUserMiddleware;
const userValidator = require("./user.validator");const { newArticleValidationMW, updateArticleValidationMW,} = require("./author.validator");module.exports = { userValidator, newArticleValidationMW, updateArticleValidationMW,};
完成身份验证和验证后,我们就可以开始构建 API 路由和控制器了。
现在我们已经完成了 API 设计、模型设置、数据库连接建立和身份验证策略到位,是时候开始构建实际的 API 了。
const express = require("express");const { blogController } = require("../controllers");const blogRouter = express.Router();blogRouter.get("/", blogController.getPublishedArticles);blogRouter.get("/:articleId", blogController.getArticle);module.exports = blogRouter;
express.Router() 函数创建一个新的路由对象,可用于定义将用于处理对服务器的 HTTP 请求的路由。
定义的第一个路由是路由器根路径中的 GET 路由。 此路由将处理对服务器的 HTTP GET 请求,并将使用 blogController 中的 getPublishedArticles 函数返回所有已发布文章的列表。
第二个路由是 GET 路由,在路径中包含一个参数 :articleId。 此路由将处理对路径中具有特定文章 ID 的服务器的 HTTP GET 请求,并将使用 blogController 中的 getArticle 函数返回具有指定 ID 的文章。
在 /routes 目录中创建一个名为 author.routes.js 的文件并编写以下代码:
const express = require("express"); const { authorController } = require("../controllers");const { newArticleValidationMW, updateArticleValidationMW,} = require("../validators");const authorRouter = express.Router();// 创建新文章authorRouter.post("/", newArticleValidationMW, authorController.createArticle);// 改变状态authorRouter.patch( "/edit/state/:articleId", updateArticleValidationMW, authorController.editState);// 编辑文章authorRouter.patch( "/edit/:articleId", updateArticleValidationMW, authorController.editArticle);// 删除文章authorRouter.delete("/delete/:articleId", authorController.deleteArticle);// 根据创建的作者获取其创建的文章authorRouter.get("/", authorController.getArticlesByAuthor);module.exports = authorRouter;
在这里,我们导入了前文中创建的验证中间件:
const { newArticleValidationMW, updateArticleValidationMW,} = require("../validators");
当发出 POST 或 PATCH 请求时,验证中间件会检查请求主体的格式是否正确:
authorRouter.post("/", newArticleValidationMW, authorController.createArticle);authorRouter.patch( "/edit/state/:articleId", updateArticleValidationMW, authorController.editState);authorRouter.patch( "/edit/:articleId", updateArticleValidationMW, authorController.editArticle);
在 /routes 目录中为身份验证路由创建一个名为 auth.routes.js 的文件:
const express = require("express");const passport = require("passport");const { userValidator } = require("../validators");const { authController } = require("../controllers");const authRouter = express.Router();authRouter.post( "/signup", passport.authenticate("signup", { session: false }), userValidator, authController.signup);authRouter.post("/login", async (req, res, next) => passport.authenticate("login", (err, user, info) => { authController.login(req, res, { err, user, info }); })(req, res, next));module.exports = authRouter;
用户注册的路由使用 passport.authenticate() 函数,使用其注册策略对请求进行身份验证。 它还指定应使用 { session: false } 选项禁用session会话管理。 如果请求成功通过身份验证,则会将其传递给 authController.signup 函数进一步处理。
用户登录的路由类似,但它使用登录策略,如果身份验证成功,则将请求传递给 authController.login 函数。
这两个路由还使用 userValidator 中间件在将请求主体传递给控制器函数之前对其进行验证。
在 /routes 中创建一个 index.js 文件并导出路由:
const blogRouter = require("./blog.routes"); const authorRouter = require("./author.routes");const authRouter = require("./auth.routes");module.exports = { blogRouter, authorRouter, authRouter };
导入路由并在 app.js 文件中使用它们。
// {... some code ....}const { blogRouter, authRouter, authorRouter } = require("./src/routes");//中间件// {... some code ....}// Routesapp.use("/blog", blogRouter);app.use("/auth", authRouter);app.use( "/author/blog", passport.authenticate("jwt", { session: false }), authorRouter);// {... some code ....}
请记住,我们将路由分为博客路由和作者路由,因为我们想保护作者路由的安全。 我们使用 passport 来保护 /author/blog 路由,这样只有经过身份验证的用户才能访问这些资源。 未经身份验证的用户只能访问 /blog 路由。
现在我们已经定义了路由,我们需要编写代码来处理请求并发送适当的响应。 控制器是匹配路由时调用的函数,负责处理请求和发送响应。
在 /controllers 目录中创建一个名为 blog.controllers.js 的文件。 在此文件中定义由博客路由调用的函数:
const { BlogModel, UserModel } = require("../models");// 获取所有发表的文章exports.getPublishedArticles = async (req, res, next) => { try { const { author, title, tags, order = "asc", order_by = "timestamp,reading_time,read_count", page = 1, per_page = 20, } = req.query; // 过滤 const findQuery = { state: "published" }; if(author) { findQuery.author = author; } if (title) { findQuery.title = title; } if (tags) { const searchTags = tags.split(","); findQuery.tags = { $in: searchTags }; } // 排序 const sortQuery = {}; const sortAttributes = order_by.split(","); for (const attribute of sortAttributes) { if (order === "asc") { sortQuery[attribute] = 1; } if (order === "desc" && order_by) { sortQuery[attribute] = -1; } } //从数据库中获取所有已发表的文章 const articles = await BlogModel.find(findQuery) .populate("author", "firstname lastname email") .sort(sortQuery) .skip(page) .limit(per_page); return res.status(200).json({ message: "Request successful", articles: articles, }); } catch (error) { return next(error); }};//通过ID获取文章exports.getArticle = async (req, res, next) => { const { articleId } = req.params; try { const article = await BlogModel.findById(articleId).populate( "author", "firstname lastname email" ); article.readCount += 1; // 增加阅读数 await article.save(); return res.status(200).json({ message: "Request successful", article: article, }); } catch (error) { return next(error); }};
当匹配到 GET /blog 路由时,将调用 getPublishedArticles 函数,它将从数据库中检索所有已发布文章的列表,并将响应发送给客户端。 它首先从 req.query 对象中解构查询参数。 这些查询参数用于过滤和排序返回的文章。
此函数中的排序是使用 Mongoose .sort() 方法完成的,该方法将以字段名称作为键并将排序顺序作为值的对象作为参数。 字段名称和排序顺序在 sortQuery 对象中指定,该对象是根据 order 和 order_by 查询参数构造。
order_by 查询参数是一个字符串,可以包含多个字段名称,以逗号分隔。 sortAttributes 变量是这些字段名称的数组。 对于数组中的每个字段名,sortQuery 对象将以字段名作为键并以排序顺序作为值进行更新。 如果顺序查询参数是“asc”,排序顺序设置为1,如果顺序查询参数是“desc”,排序顺序设置为-1。
查找查询是使用 findQuery 对象构造的,该对象使用过滤器进行初始化以仅返回已发布的文章。 然后使用基于作者、标题和标签(如果存在)查询参数的附加过滤器更新 findQuery 对象。
例如,如果 author 查询参数存在,则 findQuery.author 字段设置为 author 查询参数的值。 这将过滤结果,以仅包括作者字段等于对应作者参数值的文章。 同样,如果存在 tags 查询参数,则 findQuery.tags 字段将设置为对应的 MongoDB 运算符,用于搜索标签与搜索标签匹配的文章。 搜索标签被指定为逗号分隔的字符串,并使用 split 方法拆分为一个数组。 $in 运算符用于搜索带有包含在搜索标签数组中标签的文章。
findQuery 对象作为参数传递给 BlogModel 对象的 .find() 方法。 此方法返回一个 Mongoose 查询对象,该对象可用于在博客集合中查找与指定过滤器匹配的文档。 然后在查询对象上调用 .populate() 方法以使用相应的用户文档填充作者字段,并且 .sort() 使用 sortQuery 对象对结果进行排序。 调用 .skip() 和 .limit() 对结果进行分页。 skip() 指定查询中要跳过的文档数,而 limit() 指定要返回的文档数。 page 和 per_page 查询参数用于确定传递给 skip() 和 limit() 的值。 默认情况下,page 设置为 1,per_page 设置为 20。这意味着如果未提供这些查询参数,默认行为是检索结果的第一页,每页 20 个文章。
getArticle 函数从数据库中检索具有指定 ID 的文章,并在将文章返回给客户端之前增加文章的阅读计数。
这两个函数都使用 async 关键字并包含一个 try/catch 块来处理函数执行期间可能发生的任何错误。 如果发生错误,将以错误作为参数调用next函数,以将错误传递给在我们的 app.js 中设置的错误处理中间件。
在 /controllers 目录中创建一个名为 author.controllers.js 的文件,并定义作者路由将调用的函数,如下所示:
const { BlogModel, UserModel } = require("../models");const { blogService } = require("../services");const moment = require("moment");// 新建文章exports.createArticle = async (req, res, next) => { try { const newArticle = req.body; // 计算阅读计数 const readingTime = blogService.calculateReadingTime(newArticle.body); if (newArticle.tags) { newArticle.tags = newArticle.tags.split(","); } if (req.files) { const imageUrl = await blogService.uploadImage(req, res, next); newArticle.imageUrl = imageUrl; } const article = new BlogModel({ author: req.user._id, timestamp: moment().toDate(), readingTime: readingTime, ...newArticle, }); article.save((err, user) => { if (err) { console.log(`err: ${err}`); return next(err); } }); // 将文章添加到数据库中用户的文章数组 const user = await UserModel.findById(req.user._id); user.articles.push(article._id); await user.save(); return res.status(201).json({ message: "Article created successfully", article: article, }); } catch (error) { return next(error); }};// 变更状态exports.editState = async (req, res, next) => { try { const { articleId } = req.params; const { state } = req.body; const article = await BlogModel.findById(articleId); // 检查用户是否有权更改状态 blogService.userAuth(req, res, next, article.author._id); // 验证请求 if (state !== "published" && state !== "draft") { return next({ status: 400, message: "Invalid state" }); } if (article.state === state) { return next({ status: 400, message: `Article is already in ${state} state` }); } article.state = state; article.timestamp = moment().toDate(); article.save(); return res.status(200).json({ message: "State successfully updated", article: article, }); } catch (error) { return next(error); }};// 编辑文章exports.editArticle = async (req, res, next) => { try { const { articleId } = req.params; const { title, body, tags, description } = req.body; // 检查用户是否有权编辑文章 const article = await BlogModel.findById(articleId); blogService.userAuth(req, res, next, article.author._id); // 如果提供了参数,更新它们 if (req.files) { const imageUrl = await blogService.uploadImage(req, res, next); article.imageUrl = imageUrl; } if (title) { article.title = title; } if (body) { article.body = body; article.readingTime = blogService.calculateReadingTime(body); } if (tags) { article.tags = tags.split(","); } if (description) { article.description = description; } if (title || body || tags || description ) { article.timestamp = moment().toDate(); } article.save(); return res.status(200).json({ message: "Article successfully edited and saved", article: article, }); } catch (error) { return next(error); }};//删除文章exports.deleteArticle = async (req, res, next) => { try { const { articleId } = req.params; const article = await BlogModel.findById(articleId); // 检查用户是否有权删除文章 blogService.userAuth(req, res, next, article.author._id); article.remove(); return res.status(200).json({ message: "Article successfully deleted", }); } catch (error) { return next(error); }};// 获取用户创建的所有文章exports.getArticlesByAuthor = async (req, res, next) => { try { const { state, order = "asc", order_by = "timestamp", page = 1, per_page = 20, } = req.query; const findQuery = {}; // 检查状态是否有效,如果有效,将其添加到查询中 if (state) { if (state !== "published" && state !== "draft") { return next({ status: 400, message: "Invalid state" }); } else { findQuery.state = state; } } // 排序 const sortQuery = {}; if (order !== "asc" && order !== "desc") { return next({ status: 400, message: "Invalid parameter order" }); } else { sortQuery[order_by] = order; } // 获取用户的文章 const user = await UserModel.findById(req.user._id).populate({ path: "articles", match: findQuery, options: { sort: sortQuery, limit: parseInt(per_page), skip: (page - 1) * per_page, }, }); return res.status(200).json({ message: "Request successful", articles: user.articles, }); } catch (error) { return next(error); }};
createArticle 函数首先从 req.body 对象中解构新文章数据,并使用 blogService 模块中的 calculateReadingTime 函数计算文章的阅读时间,我们将在接下来创建该模块一些抽象功能。
该函数检查 newArticle 数据中是否存在标签(tags)字段,并使用 split 方法将标签字符串拆分为一个数组。 回想一下,标签在博客模型中被定义为一个数组。
接下来,如果用户上传封面图片,我们想要访问该文件,将其上传到云存储服务,获取上传图片的 URL 并将其保存到我们的数据库中。已安装的 express-fileupload 将允许我们访问 req.files 对象。
最后,该函数使用 newArticle 数据以及当前时间戳和用户 ID ,创建一个新的 BlogModel 实例,并使用 save 方法将其保存到数据库中。 我们使用 npm 模块 moment 来处理时间戳。 您可以通过运行 npm install moment 来安装 moment。
作者字段是引用用户模型的 ObjectId。 在这里,我们分配了用户的 ID,以便我们可以在需要时使用 Mongoose .populate 方法来检索作者的详细信息。
同样,我们希望能够获取用户创建的所有文章,因此接下来,我们在向客户端返回响应之前更新数据库中用户的文章数组以包含新文章的 ID。
在 editState 函数中,我们从数据库中检索文章并检查用户是否有权更改文章的状态。 如果用户拥有权限,我们会更新数据库中文章的状态和时间戳。
在 editArticle 函数中,我们从数据库中检索文章并检查用户是否有权编辑该文章。 如果用户获得授权,我们将更新数据库中的文章数据。
在 /controllers 目录中为身份验证路由创建一个名为 auth.controllers.js 的文件:
const jwt = require("jsonwebtoken");const { UserModel } = require("../models");exports.signup = async (req, res, next) => { const { firstname, lastname, email, password } = req.body; try { const user = new UserModel({ firstname: firstname, lastname: lastname, email: email, password: password, }); user.save((err, user) => { if (err) { return next(err); } }); delete user.password; return res.status(201).json({ message: "Signup successful", user: user, }); } catch (error) { return next(error); }};exports.login = (req, res, { err, user, info }) => { if (!user) { return res.status(401).json({ message: "email or password is incorrect" }); } req.login(user, { session: false }, async (error) => { if (error) return res.status(401).json({ message: error }); const body = { _id: user._id, email: user.email }; const token = jwt.sign({ user: body }, process.env.JWT_SECRET, { expiresIn: "1h", }); return res.status(200).json({ message: "Login successful", token: token }); });};
注册函数使用提供的名字、姓氏、电子邮件和密码创建一个新的用户对象。 然后将此用户对象保存到数据库,并返回一条成功消息和用户对象,对象中不包含密码字段。 永远不要暴露密码。
当用户尝试登录时调用登录函数。如果提供的电子邮件和密码与数据库中的用户不匹配,它将返回 401 状态代码和错误消息。 否则,它会创建一个 JWT,其中包含用户 ID 和电子邮件作为负载,并使用密钥(我们存储在 .env 文件中)对其进行签名。由于我们不使用会话,注销很棘手,所以我们已经 为令牌设置一小时的过期时间。该函数返回一条成功消息和 JWT。
在 /controllers 目录中创建一个名为 /index.js 的文件:
const authController = require("./auth.controller"); const blogController = require("./blog.controller"); const authorController = require("./author.controller");module.exports = { authController, blogController, authorController };
将 author.controller.js 的一些逻辑抽象为 blogService 模块。
在开始之前,我们将使用 Cloudinary,这是一个媒体管理平台,可让您存储、优化和传送图像和视频。 按照 Cloudinary 网站上的说明设置您的帐户,然后通过运行以下命令安装 SDK:
npm install cloudinary
现在,在 /services 中,创建一个名为 blog.service.js 的文件,其中包含以下功能:
const cloudinary = require('cloudinary').v2;cloudinary.config({ secure: true});exports.userAuth = (req, res, next, authorId) => { // 如果作者与登录用户不同,则抛出错误 if (req.user._id !== authorId.toString()) { return next({ status: 401, message: "You are not authorized to access this resource", }); }};exports.calculateReadingTime = (text) => { const wordsPerMin = 200; const wordCount = text.trim().split(/\s+/).length; const readingTime = Math.ceil(wordCount / wordsPerMin); return readingTime > 1 ? `${readingTime} mins` : `${readingTime} min`;};exports.uploadImage = async (req, res, next) => { const filePath = req.files.file.tempFilePath; const options = { use_filename: true, unique_filename: false, overwrite: true, }; try { const uploadedImage = await cloudinary.uploader.upload(filePath, options); return uploadedImage.secure_url; } catch (error) { return next(error); }}
userAuth 函数检查发出请求的用户是否与正在访问的帖子的作者相同。 如果用户不是作者,它会抛出状态码为 401(未授权)的错误。
calculateReadingTime 函数以一段文本为参数,通过将文本中的字数除以每分钟平均字数来计算估计的阅读时间(以分钟为单位)(根据 Marc 的说法,成人的平均阅读速度约为 200wpm 来自比利时根特大学的 Brysbaert)。 然后它四舍五入到最接近的整数并返回一个包含阅读时间的字符串。
uploadImage 函数将图像上传到 Cloudinary 并返回安全 URL。 它以 req、res 和 next 作为参数,从 req 对象中检索上传图像的文件路径,并将其存储在 filePath 变量中(这是通过我们安装的 express-fileupload npm 包实现的)。 然后设置上传选项,包括 use_filename 和 overwrite 选项,这些选项指定上传的文件应使用其原始文件名并覆盖任何具有相同名称的现有文件。 然后该函数使用
cloudinary.uploader.upload 方法将文件上传到 Cloudinary。 如果上传成功,则返回上传文件的安全 URL。
在/services/index.js文件中:
const blogService = require('./blog.service.js');module.exports = { blogService}
有了路由和控制器,我们现在可以测试我们的 API 以确保它按预期工作。 您可以使用 Postman 测试 API 中的所有路由,以确保它们按预期工作。
要测试需要请求正文的路由,例如 POST 和 PATCH 路由,您需要在 Postman 请求表单中以适当的格式指定请求正文。 测试受保护路由时,您还需要在 Authorization 标头中提供token。
例如,如果您向 /author/blog 路由发送 POST 请求,请求正文中应该包含文章数据。
首先,您需要通过在终端中运行以下命令来启动服务器:
npm start
{ "email": "doe@example.com", "password": "Password1", "firstname": "jon", "lastname": "doe",}
如果成功,您将获得成功响应:
{ "message": "Signup successful", "user": { "firstname": "jon", "lastname": "doe", "email": "doe@example.com", }}
{ "email": "doe@example.com", "password": "Password1",}
如果成功,您将获得带有token的成功响应:
{ "message": "Login successful", "token": "sjlkafjkldsf.jsdntehwkljyroyjoh.tenmntiw"}
{ "title": "testing the routes", "body": "This is the body of the article", "description": "An article", "tags": "blog,test",}
你应该得到一个 401(Unauthorised) 状态码。 再试一次,但添加一个带有不记名令牌 Bearer {token} 的授权标头。 将 {token} 替换为您登录后获得的令牌。您应该会收到成功响应:
{ "message": "Article created successfully", "article": { "title": "testing the routes", "description": "An article", "tags": [ "blog", "test" ], "author": "6366b10174282b915e1be028", "timestamp": "2022-11-05T20:52:40.573Z", "state": "draft", "readCount": 0, "readingTime": "1 min", "body": "This is the body of the article", "_id": "6366cd18b34b65410bc391db" }}
测试其他路由。
就是这样! 通过实施和测试 API 路由和控制器,我们现在拥有一个功能齐全的博客 API。