基于Node.js,MySQL和JWT的Rest API应用

时间:2022-02-09 17:09:59

本文介绍基于Node.js和MySQL搭建一个简单的Rest API应用,认证基于JWT(JSON Web Token)。其他用到的技术包括:路由:Express,ORM/数据库:Sequelize, 认证:Passport。
 

源码的Github地址:https://github.com/brianalois/node_rest_api_mysql

App结构

这里结合了标准的express app结构和sequelize的组织代码的结构,以及使用了一些Laravel结构。

— bin
— config
- - - config.js
— controllers
- - - CompanyController.js
- - - HomeController.js
— — — UserController.js
— middleware
- - - custom.js
- - - passport.js
— models
— — — index.js
— — — company.js
— — — user.js
— public
— routes
— — — v1.js
- seeders
- services
- - - AuthService.js
.env
app.js
global_functions.js

查阅代码

从.env开始

这个文件用于配置环境变量:

APP=dev
PORT=3000
DB_DIALECT=mysql
DB_HOST=localhost
DB_PORT=3306
DB_NAME=dbNameChange
DB_USER=rootChange
DB_PASSWORD=passwordChange
JWT_ENCRYPTION=PleaseChange
JWT_EXPIRATION=10000

主文件app.js

引入定义全局变量和全局函数的文件。

require('./config/config');     
require('./global_functions'); 

添加依赖和初始化server:

const express      = require('express');
const logger       = require('morgan');
const bodyParser   = require('body-parser');
const passport     = require('passport');
const v1 = require('./routes/v1');
const app = express();
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
//Passport
app.use(passport.initialize());

连接数据库和加载数据模型:

const models = require("./models");
models.sequelize.authenticate().then(() => {
    console.log('Connected to SQL database');
})
.catch(err => {
    console.error('Unable to connect to SQL database:', err);
});
if(CONFIG.app==='development'){
    models.sequelize.sync();//creates tables from models
    // models.sequelize.sync({ force: true });//good for testing
}

配置CORS,以便让其他网站可以请求api:

app.use(function (req, res, next) {
    // 设置允许请求的域名
    res.setHeader('Access-Control-Allow-Origin', '*');
    // 允许请求的方法
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
    // 允许的请求头
    res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With, content-type, Authorization, Content-Type');
    // 允许请求带有cookie信息
    res.setHeader('Access-Control-Allow-Credentials', true);
    
    next();
});

搭建路由和处理错误

app.use('/v1', v1);
app.use('/', function(req, res){
   res.statusCode = 200;// 返回成功的请求码
   res.json({status:"success", message:"Parcel Pending API", data:{}})
});
// 捕获404,并处理错误
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});
// 错误处理
app.use(function(err, req, res, next) {
  //设置国际化,只在开发模式下提供错误信息
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};
  // 渲染错误页面
  res.status(err.status || 500);
  res.render('error');
});
module.exports = app;

config/config.js

app.js需要先加载此文件。它配置了全局的变量。一旦加载完config.js后就可以在所有的地方使用它,且不用再次引入。

require('dotenv').config();//初始化环境变量
CONFIG = {} //Make this global to use all over the application
CONFIG.app          = process.env.APP   || 'development';
CONFIG.port         = process.env.PORT  || '3000';
CONFIG.db_dialect   = process.env.DB_DIALECT    || 'mysql';
CONFIG.db_host      = process.env.DB_HOST       || 'localhost';
CONFIG.db_port      = process.env.DB_PORT       || '3306';
CONFIG.db_name      = process.env.DB_NAME       || 'name';
CONFIG.db_user      = process.env.DB_USER       || 'root';
CONFIG.db_password  = process.env.DB_PASSWORD   || 'db-password';
CONFIG.jwt_encryption  = process.env.JWT_ENCRYPTION || 'jwt_please_change';
CONFIG.jwt_expiration  = process.env.JWT_EXPIRATION || '10000';

global_functions.js

这个文件定义了一些全局函数,在app.js加载完后,我们就可以在任何地方使用这些全局函数,且不用再次引入。

to函数用于处理promise和错误。

to = function(promise) {
    return promise
    .then(data => {
        return [null, data];
    }).catch(err =>
        [pe(err)]
    );
}
pe = require('parse-error');
TE = function(err_message, log){ // TE 表示抛出错误
    if(log === true){
        console.error(err_message);
    }
    throw new Error(err_message);
}
ReE = function(res, err, code){ // 错误的web响应
    if(typeof err == 'object' && typeof err.message != 'undefined'){
        err = err.message;
    }
    if(typeof code !== 'undefined') res.statusCode = code;
    return res.json({success:false, error: err});
}
ReS = function(res, data, code){ // 成功的web响应
    let send_data = {success:true};
    if(typeof data == 'object'){
        send_data = Object.assign(data, send_data);//合并对象
    }
    if(typeof code !== 'undefined') res.statusCode = code;
    return res.json(send_data)
};
//这里用于处理所有被拒绝的promise
process.on('unhandledRejection', error => {
    console.error('Uncaught Error', pe(error));
});

搭建数据库和加载模型

models/index.js

'use strict';
var fs        = require('fs');
var path      = require('path');
var Sequelize = require('sequelize');
var basename  = path.basename(__filename);
var db        = {};
const sequelize = new Sequelize(CONFIG.db_name, CONFIG.db_user, CONFIG.db_password, {
  host: CONFIG.db_host,
  dialect: CONFIG.db_dialect,
  port: CONFIG.db_port,
  operatorsAliases: false
});

使用环境变量连接sequelize。

在model目录下加载所有的模型

fs
  .readdirSync(__dirname)
  .filter(file => {
    return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
  })
  .forEach(file => {
    var model = sequelize['import'](path.join(__dirname, file));
    db[model.name] = model;
  });
Object.keys(db).forEach(modelName => {
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

导出Sequelize

db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;

User模型

models/user.js

导入模型

'use strict';
const bcrypt         = require('bcrypt');
const bcrypt_p           = require('bcrypt-promise');
const jwt              = require('jsonwebtoken');

使用钩子函数和自定义的方法来构建Schema。在这里我们使用beforeSave钩子函数在每次修改密码时对密码进行哈希散列。

在User模型上自定义了一个方法用产生用户的JWT token。

'use strict';
const bcrypt         = require('bcrypt');
const bcrypt_p           = require('bcrypt-promise');
const jwt              = require('jsonwebtoken');
module.exports = (sequelize, DataTypes) => {
    var Model = sequelize.define('User', {
        first     : DataTypes.STRING,
        last      : DataTypes.STRING,
        email     : {type: DataTypes.STRING, allowNull: true, unique: true, validate: { isEmail: {msg: "Phone number invalid."} }},
        phone     : {type: DataTypes.STRING, allowNull: true, unique: true, validate: { len: {args: [7, 20], msg: "Phone number invalid, too short."}, isNumeric: { msg: "not a valid phone number."} }},
        password  : DataTypes.STRING,
    });
    Model.associate = function(models){
        this.Companies = this.belongsToMany(models.Company, {through: 'UserCompany'});
    };
    Model.beforeSave(async (user, options) => {
        let err;
        if (user.changed('password')){
            let salt, hash
            [err, salt] = await to(bcrypt.genSalt(10));
            if(err) TE(err.message, true);
            [err, hash] = await to(bcrypt.hash(user.password, salt));
            if(err) TE(err.message, true);
            user.password = hash;
        }
    });
    Model.prototype.comparePassword = async function (pw) {
        let err, pass
        if(!this.password) TE('password not set');
        [err, pass] = await to(bcrypt_p.compare(pw, this.password));
        if(err) TE(err);
        if(!pass) TE('invalid password');
        return this;
    }
    Model.prototype.getJWT = function () {
        let expiration_time = parseInt(CONFIG.jwt_expiration);
        return "Bearer "+jwt.sign({user_id:this.id}, CONFIG.jwt_encryption, {expiresIn: expiration_time});
    };
    Model.prototype.toWeb = function (pw) {
        let json = this.toJSON();
        return json;
    };
    return Model;
};

Company模型

models/company.js

'use strict';
module.exports = (sequelize, DataTypes) => {
  var Model = sequelize.define('Company', {
    name: DataTypes.STRING
  });
  Model.associate = function(models){
      this.Users = this.belongsToMany(models.User, {through: 'UserCompany'});
  };
  Model.prototype.toWeb = function (pw) {
      let json = this.toJSON();
      return json;
  };
  return Model;
};

App的路由

routes/v1.js

导入模块以及配置passport中间件。

const express         = require('express');
const router         = express.Router();
const UserController   = require('./../controllers/UserController');
const CompanyController = require('./../controllers/CompanyController');
const HomeController   = require('./../controllers/HomeController');
const custom           = require('./../middleware/custom');
const passport         = require('passport');
const path              = require('path');

基本的增删查改的路由,可以使用postman或者curl来测试。在app.js里给api限定了版本,所以需要添加/v1/{api}来访问。如:

url: localhost:3000/v1/users

User路由

router.post('/users', UserController.create); //create   
router.get('/users',passport.authenticate('jwt', {session:false}), UserController.get);  //read
router.put('/users',passport.authenticate('jwt', {session:false}), UserController.update); //update
router.delete('/users',passport.authenticate('jwt',{session:false}), UserController.remove); //delete
router.post(    '/users/login',     UserController.login);

Company路由

router.post(    '/companies',           
passport.authenticate('jwt', {session:false}), CompanyController.create);
router.get(     '/companies',             passport.authenticate('jwt', {session:false}), CompanyController.getAll);
router.get(     '/companies/:company_id', passport.authenticate('jwt', {session:false}), custom.company, CompanyController.get);
router.put(     '/companies/:company_id', passport.authenticate('jwt', {session:false}), custom.company, CompanyController.update);
router.delete(  '/companies/:company_id', passport.authenticate('jwt', {session:false}), custom.company, CompanyController.remove);

导出路由

module.exports = router;
middleware/passport.js

引入模块

const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const User = require('../models').User;

这里使用了passport中间件来对所有的路由定义用户信息。我们把userId存放在jwt token里,它包含在头信息Authorization里,如Bearer a23uiabsdkjd….。

passport中间件读取token里的user id,然后获取用户信息,并把它发送给controller。

module.exports = function(passport){
    var opts = {};
    opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken();
    opts.secretOrKey = CONFIG.jwt_encryption;
    passport.use(new JwtStrategy(opts, async function(jwt_payload, done){
        let err, user;
        [err, user] = await to(User.findById(jwt_payload.user_id));
        console.log('user', user.id);
        if(err) return done(err, false);
        if(user) {
            return done(null, user);
        }else{
            return done(null, false);
        }
    }));
}

自定义中间件

middleware/custom.js

const Company             = require('./../models').Company;
let company = async function (req, res, next) {
    let company_id, err, company;
    company_id = req.params.company_id;
    [err, company] = await to(Company.findOne({where:{id:company_id}}));
    if(err) return ReE(res, "err finding company");
    if(!company) return ReE(res, "Company not found with id: "+company_id);
    let user, users_array, users;
    user = req.user;
    [err, users] = await to(company.getUsers());
    users_array = users.map(obj=>String(obj.user));
    if(!users_array.includes(String(user._id))) return ReE(res, "User does not have permission to read app with id: "+app_id);
    req.company = company;
    next();
}
module.exports.company = company;

Controller

ontrollers/UserController.js

引入模块

const User          = require('../models').User;
const authService   = require('./../services/AuthService');

新建create

记住ReE是一个辅助函数,它让所有的错误响应格式相同。 使用service来执行实际的创建用户。

const create = async function(req, res){
    res.setHeader('Content-Type', 'application/json');
    const body = req.body;
    if(!body.unique_key && !body.email && !body.phone){
        return ReE(res, 'Please enter an email or phone number to register.');
    } else if(!body.password){
        return ReE(res, 'Please enter a password to register.');
    }else{
        let err, user;
        [err, user] = await to(authService.createUser(body));
        if(err) return ReE(res, err, 422);
        return ReS(res, {message:'Successfully created new user.', user:user.toWeb(), token:user.getJWT()}, 201);
    }
}
module.exports.create = create;

查询get

从中间件passport的req.user返回用户信息。需要在请求的头信息包含token,Authorization: Bearer Jasud2732r…

const get = async function(req, res){
    res.setHeader('Content-Type', 'application/json');
    let user = req.user;
    return ReS(res, {user:user.toWeb()});
}
module.exports.get = get;

更新update

const update = async function(req, res){
    let err, user, data
    user = req.user;
    data = req.body;
    user.set(data);
    [err, user] = await to(user.save());
    if(err){
        if(err.message=='Validation error') err = 'The email address or phone number is already in use';
        return ReE(res, err);
    }
    return ReS(res, {message :'Updated User: '+user.email});
}
module.exports.update = update;

删除remove

const remove = async function(req, res){
    let user, err;
    user = req.user;
    [err, user] = await to(user.destroy());
    if(err) return ReE(res, 'error occured trying to delete user');
    return ReS(res, {message:'Deleted User'}, 204);
}
module.exports.remove = remove;

登陆Login

返回认证的token

const login = async function(req, res){
    const body = req.body;
    let err, user;
    [err, user] = await to(authService.authUser(req.body));
    if(err) return ReE(res, err, 422);
    return ReS(res, {token:user.getJWT(), user:user.toWeb()});
}
module.exports.login = login;

AuthService

services/AuthService.js

引入模块

const User        = require('./../models').User;
const validator   = require('validator');

如果用户可以使用电子邮件或电话号码,我们很乐意。这个方法把它组合在unique_key变量里。我们将在创建用户函数中使用它。

const getUniqueKeyFromBody = function(body){// 这里提供了三种选择,unique_key,email和phone
    let unique_key = body.unique_key;
    if(typeof unique_key==='undefined'){
        if(typeof body.email != 'undefined'){
            unique_key = body.email
        }else if(typeof body.phone != 'undefined'){
            unique_key = body.phone
        }else{
            unique_key = null;
        }
    }
    return unique_key;
}
module.exports.getUniqueKeyFromBody = getUniqueKeyFromBody;

新建用户

验证email是否合法,或者phone是否合法,以及它们是否唯一,然后把它保存在数据库。

const createUser = async function(userInfo){
    let unique_key, auth_info, err;
    auth_info={}
    auth_info.status='create';
    unique_key = getUniqueKeyFromBody(userInfo);
    if(!unique_key) TE('An email or phone number was not entered.');
    if(validator.isEmail(unique_key)){
        auth_info.method = 'email';
        userInfo.email = unique_key;
        [err, user] = await to(User.create(userInfo));
        if(err) TE('user already exists with that email');
        return user;
    }else if(validator.isMobilePhone(unique_key, 'any')){
        auth_info.method = 'phone';
        userInfo.phone = unique_key;
        [err, user] = await to(User.create(userInfo));
        if(err) TE('user already exists with that phone number');
        return user;
    }else{
        TE('A valid email or phone number was not entered.');
    }
}
module.exports.createUser = createUser;

验证用户

const authUser = async function(userInfo){//returns token
    let unique_key;
    let auth_info = {};
    auth_info.status = 'login';
    unique_key = getUniqueKeyFromBody(userInfo);
    if(!unique_key) TE('Please enter an email or phone number to login');
    if(!userInfo.password) TE('Please enter a password to login');
    let user;
    if(validator.isEmail(unique_key)){
        auth_info.method='email';
        [err, user] = await to(User.findOne({where:{email:unique_key}}));
        console.log(err, user, unique_key);
        if(err) TE(err.message);
    }else if(validator.isMobilePhone(unique_key, 'any')){//checks if only phone number was sent
        auth_info.method='phone';
        [err, user] = await to(User.findOne({where:{phone:unique_key }}));
        if(err) TE(err.message);
    }else{
        TE('A valid email or phone number was not entered');
    }
    if(!user) TE('Not registered');
    [err, user] = await to(user.comparePassword(userInfo.password));
    if(err) TE(err.message);
    return user;
}
module.exports.authUser = authUser;

Company Controller

controllers/CompanyController.js

这是和UserController的结构是一样的。

Create

const Company = require('../models').Company;
const create = async function(req, res){
    res.setHeader('Content-Type', 'application/json');
    let err, company;
    let user = req.user;
    let company_info = req.body;
    [err, company] = await to(Company.create(company_info));
    if(err) return ReE(res, err, 422);
    company.addUser(user, { through: { status: 'started' }})
    [err, company] = await to(company.save());
    if(err) return ReE(res, err, 422);
    let company_json = company.toWeb();
    company_json.users = [{user:user.id}];
    return ReS(res,{company:company_json}, 201);
}
module.exports.create = create;

获取属于用户的所有公司

const getAll = async function(req, res){
    res.setHeader('Content-Type', 'application/json');
    let user = req.user;
    let err, companies;
    [err, companies] = await to(user.getCompanies());
    let companies_json =[]
    for( let i in companies){
        let company = companies[i];
        let users =  await company.getUsers()
        let company_info = company.toWeb();
        let users_info = []
        for (let i in users){
            let user = users[i];
            // let user_info = user.toJSON();
            users_info.push({user:user.id});
        }
        company_info.users = users_info;
        companies_json.push(company_info);
    }
    return ReS(res, {companies:companies_json});
}
module.exports.getAll = getAll;

Get

const get = function(req, res){
    res.setHeader('Content-Type', 'application/json');
    let company = req.company;
    return ReS(res, {company:company.toWeb()});
}
module.exports.get = get;

更新Upate

const update = async function(req, res){
    let err, company, data;
    company = req.company;
    data = req.body;
    company.set(data);
    [err, company] = await to(company.save());
    if(err){
        return ReE(res, err);
    }
    return ReS(res, {company:company.toWeb()});
}
module.exports.update = update;

删除Remove

const remove = async function(req, res){
    let company, err;
    company = req.company;
    [err, company] = await to(company.destroy());
    if(err) return ReE(res, 'error occured trying to delete the company');
    return ReS(res, {message:'Deleted Company'}, 204);
}
module.exports.remove = remove;

这里简单介绍了整个代码,到此就结束了。

意译原文:https://codeburst.io/build-a-rest-api-for-node-mysql-2018-jwt-6957bcfc7ac9