HAPIJS打造NodeJS RESTful API的例子

在Node.JS环境下,我们实现REST(Representational State Transfer) API(Application Programming Interface)并不困难。不过,要打造出真正高效高弹性的RESTful API之前,我们还有许多值得深思的问题。今天,我们来看看利用HAPIJS如何实现RESTful API,以作为HAPIJS入门初体验的延伸阅读。

API目标

我们先来整理本次API例子的逻辑目标。

  • 用户验证授权验证
  • 所有用户可以查看数据库中公开的文章
  • 通过验证的用户可以建立文章
  • 注册用户可以修改他们自己的文章
  • 注册用户可以删除他们自己建立的文章

URL路径组织

现在,我们思考一下怎么组织API的端点(endpoint)。初步方案如下:

Endpoint HTTP verbs comment
http://api.app.com/articles GET 返回所有公开的文章
http://api.app.com/articles POST 新建一篇文章
http://api.app.com/articles/:id PUT 注册用户修改一篇隶属于自己的已有文章
http://api.app.com/articles/:id DELETE 注册用户删除一篇属于自己的已有文章
http://api.app.com/articles/:id GET 获取一篇公开的指定文章或者获取一篇隶属于自己的私有文章

为了保持示例的简介,我并没有将验证置入路径中。

所使用的工具以及应用运行环境

毋庸置疑的,我们使用的是Hapi.js,那么NODE环境自然是少不了的。 关于其他的环境配置(例如:eslintrc/babelrc等)很简单。如果您确实没接触过,请查看HAPIJS入门篇

NODE NPM依赖包

本示例说依赖的NPM包如下:


$ yarn global add knex
$ yarn add hapi knex mysql jsonwebtoken hapi-auth-jwt node-uuid
$ yarn add --dev babel-core babel-preset-es2015

其中,jsonwebtoken、hapi-auth-jwt是验证需要的包。knex和mysql则是后端数据库的依赖包。node-uuid用于生产记录的guid.我们稍后一步一步会接触到。
接下来,我们将重点介绍一下本示例中数据库和验证相关工具包。

KnexJS – RMDB工具包

KnexJS.org是一款基于JS的关系型数据库工具包,其功能包括SQL语法构建以及knex-cli(创建、回滚等操作)。我们用本章示例所需的数据库来做演示。如果您对KnexJS有兴趣,请移步至官方文档(本段开头有链接)。

MySQL数据库结构

我们的示例数据库结构图如下:

MYSQL DB Diagram for HapiJS RESTful
由MYSQL WorkBench制作的数据库结构示例图。

两张表的描述如下:
MYSQL DB Tables Description for HapiJS RESTful DEMO
两张数据表的字段描述,数据库命名为articlebase。

Knex设定数据库配置

第一步,我们先要将数据库的配置信息写入我们的web应用,以便KnexJS后续使用。操作如下:


$ vim knexfile.js
###
module.exports = {
  development: {
    migrations: { tableName: 'knex_migrations' }, // 用以记录历史维护操作的表格
    seeds: { tableName: './seeds' }, // 并不会真正在数据库建立表格

    client: 'mysql',
    connection: {
      host: '127.0.0.1',
      user: 'articlebase',
      password: 'article123456',
      database: 'articlebase',
      charset: 'utf8',
    },
  },
};
###

Schemas创建数据表

我们利用Knex-cli指令来分别生成3个用于生成MySQL数据表和测试数据的文件。


$ mkdir -P {migrations,seeds}
$ knex migrate:make Datastructure #在migrations目录下生成一个类似于20170419212741_Datastructures.js文件
$ knex seed:make 01_Users #在seeds目录下生成文件01_Users.js
$ knex seed:make 02_Articles #在seeds目录下生成文件02_Articles.js

默认生成的文件只带有一个通用样例,我们需要根据自己的项目去修改,我们示例中如下:


// migrations/20170419212741_Datastructure.js
exports.up = (knex, Promise) => {                                                
  return knex                                                                    
    .schema                                                                      
    .createTable('users', (usersTable) => {                                      
      // Primary Key                                                             
      usersTable.increments();                                                   
                                                                                 
      // Data                                                                    
      usersTable.string('name', 50).notNullable();                               
      usersTable.string('username', 50).notNullable().unique();                  
      usersTable.string('email', 250).notNullable().unique();                    
      usersTable.string('password', 128).notNullable();                          
      usersTable.string('guid', 50).notNullable().unique();                      
      usersTable.timestamp('created_at').notNullable();                          
    })                                                                           
    .createTable('articles', (articlesTable) => {                                
      // Primary Key                                                             
      articlesTable.increments();                                                
      articlesTable.string('owner', 36).references('guid').inTable('users');     
                                                                                 
      // Data                                                                    
      articlesTable.string('name', 250).notNullable();                           
      articlesTable.string('species', 250).notNullable();                        
      articlesTable.string('picture_url', 250).notNullable();                    
      articlesTable.string('guid', 36).notNullable().unique();                   
      articlesTable.boolean('isPublic').notNullable().defaultTo(true);           
      articlesTable.timestamp('created_at').notNullable();                       
    });                                                                          
};                                                                               
                                                                                 
exports.down = (knex, Promise) => {                                              
  return knex                                                                    
    .schema                                                                      
    .dropTableIfExists('articles')      // 必须先删除articles,因为有关联到users表的外键                                         
    .dropTableIfExists('users');                                                 
};                                                                               

代码本身没有什么难度。我们要理解的是:exports.up是运行knex migrate:latest时建立表格用的。而exports.down是运行knex migrate:rollback回滚操作时执行的。

seeds插入测试的原始数据


// seeds/01_Users.js
exports.seed = (knex, Promise) => {
  const tableName = 'users';
  const rows = [
    {
      name: 'B!ng',
      username: 'putty.biz',
      password: 'password',
      email: 'null@putty.biz',
      guid: 'f03ede7c-b121-4113-bbc7-130a3e87988d',
    },
  ];
  // Deletes ALL existing entries
  return knex(tableName)
    .del()
    .then(() => knex(tableName).insert(rows));
};


// seeds/02_Articles.js
exports.seed = (knex, Promise) => {
  const tableName = 'articles';
  const rows = [
    {
      owner: 'f03ede7c-b121-4113-bbc7-130a3e87988d',
      species: 'C1',
      name: 'test article 1',
      picture_url: 'http://cdn-7.mrdowling.com/images/706iwojima.jpg',
      guid: '4c8d84f1-9e41-4e78-a254-0a5680cd19d3',
      isPublic: true,
    },
    {
      owner: 'f03ede7c-b121-4113-bbc7-130a3e87988d',
      species: 'C2',
      name: 'test article 2',
      picture_url: 'http://cdn-7.mrdowling.com/images/706iwojima.jpg',
      guid: 'ddb8a136-6df4-4cf3-98c6-d29b9da4fbc6',
      isPublic: false,
    },
  ];
  // Deletes ALL existing entries
  return knex(tableName)
    .del()
    .then(() => knex(tableName).insert(rows));
};

以上两个seeds文件夹下的脚本分别用于填充2个数据表的测试数据。

执行表格创建、测试数据以及检验

接下来,我们运行knex-cli指令来生成表格和数据。


$ knex --help
$ knex migrate:latest
$ knex seed:run
$ mysql -uarticlebase -particle123456
MariaDB [(none)]> use articlebase;
MariaDB [articlebase]> select * from articles\G;
MariaDB [articlebase]> select * from users\G;

以上SQL查询中,我省略了结果,正常将返回我们seeds中插入的数据记录。

JWT验证机制

本例中,我们采用的是基于JWT.io的token验证机制。 大家可以通过官网jwt.io做一些了解。我下面简单介绍一下它token的构造。

一条JWT token的构成是xxx.yyy.zzz三部分。每一部分都有一个特定的名称, 分别为HEADER,PLAYLOAD和SIGNATURE。大家可以在https://jwt.io/#debugger的右侧看到。我们要生存token很简单,根据官网上debugger部分右侧默认的值修改成我们需要的即可。

官方debugger默认没有关于时间的限定,我们可以使用如下图两个键值。

JWT Debugger
JWT Debugger

其中的iatexp的值格式是unix时间戳(epoch time)。使用以下bash指令即可获取:


$ date +%s
$ date -d '+1 hour' +%s

添加JWT验证机制到HapiJS应用

我们现在将JWT验证添加到我们的HapiJS网站中(原始代码拷贝自HapiJS初体验)。


// src/server.js
....
// jwt authentication
server.register(require('hapi-auth-jwt'), (err) => {
  if (err) console.error(err);
  server.auth.strategy('token', 'jwt', {
    key: 'vZvYpkTzqXMp8PpYXKwqc9ShQ1UhyAfy',
    verifyOptions: {
      algorithms: ['HS256'],
    },
  });
});
....

在这里,我们向server注册了一个新的来自hapi-jwt-auth的模块。接着,我们注册了一个新的验证机制token。验证策略的选项为:

  • key – 私有密钥用于签名和验证
  • verifyOption – 指定签名和验证的算法是HMAC256

我们也可以添加validateFunc选项,经常与crpto加密模块一起使用。

测试工具

与《NODE JS和EXPRESS 4 ROUTER搭建实现RESTFUL API》中一样,我们还是使用Chrome插件POSTMAN进行的API测试。

不同的是:我们这里引入了验证机制。不过,我们在请求头部加入Authorization: Bearer 即可,如下:

HapiJS RESTful Post Man with Authentication
将JWT的token加入PostMan的header即可。

另外,POSTMAN对于JSON的发送,请参照下图:

JSON Data POSTMAN
POSTMAN中如何发送JSON数据!

好了,为了控制篇幅,我之后不再提工具和测试相关的内容啦。

HAPIJS添加各个端点路由

现在,万事俱备,只欠东风。我们开始将之前规划好的端点添加为HAPI网站应用中的路由。

独立的路由文件

我们将路由脚本独立出来,以方便管理。基本格式如下:


// ./src/routes.js
import jwt from 'jsonwebtoken';
import GUID from 'node-uuid';
import Knex from './knex';

const routes = [
 {},
 {},
];

export default routes;

我们将routes.js作为模块引入到server.js中,然后使用forEach去历遍routes数组中的每条路由。

// ./src/server.js
import Hapi from 'hapi';        
import routes from './routes';  
....
// jwt authentication                               
server.register(require('hapi-auth-jwt'), (err) => {
  if (err) console.error(err);                      
  server.auth.strategy('token', 'jwt', {            
    key: 'vZvYpkTzqXMp8PpYXKwqc9ShQ1UhyAfy',        
    verifyOptions: {                                
      algorithms: ['HS256'],                        
    },                                              
  });                                               
                                                    
  routes.forEach((route) => {                       
    console.log(`attaching ${ route.path }`);       
    server.route(route);                            
  });                                               
});                                                 
.....

获取所有公开文章的端点 – GET – /articles


// ./src/routes.js
...
const routes = [                                                               
  {                                                                            
    method: 'GET',                                                             
    path: '/articles',                                                         
    handler: (request, reply) => {                                             
      const getOperation = Knex('articles')                                    
        .where({                                                               
          isPublic: 1,                                                         
        })                                                                     
        .select('name', 'species', 'picture_url').then((results) => {          
          if (!results || results.length === 0) {                              
            reply({                                                            
              error: true,                                                     
              errMessage: 'no public articles found',                          
            });                                                                
          }                                                                    
          reply({                                                              
            dataCount: results.length,                                         
            data: results,                                                     
          });                                                                  
        })                                                                     
        .catch((err) => {                                                      
          reply(`server-side error ${err}`);                                   
        });                                                                    
    },                                                                         
  },                                                                           
];
...

用户验证 – POST – /auth


// ./src/routes.js
...
const routes = [
....
 {                                                               
   path: '/auth',                                                
   method: 'POST',                                               
   handler: (request, reply) => {                                
     const { username, password } = request.payload;             
     const getOperation = Knex('users').where({                  
       username,                                                 
     })                                                          
       .select('guid', 'password')                               
       .then(([user]) => {                                       
         if (!user) {                                            
           reply({                                               
             error: true,                                        
             errMessage: 'the specified user was not found',     
           });                                                   
           return;                                               
         }                                                       
         if (user.password === password) {                       
           const token = jwt.sign({                              
             username,                                           
             scope: user.guid,                                   
           }, 'vZvYpkTzqXMp8PpYXKwqc9ShQ1UhyAfy', {              
             algorithm: 'HS256',                                 
             expiresIn: '1h',                                    
           });                                                   
           reply({                                               
             token,                                              
             scope: user.guid,                                   
           });                                                   
         } else {                                                
           reply('incorrect password');                          
         }                                                       
       })                                                        
       .catch((err) => {                                         
         reply(`server-side error ${err}`);                      
       });                                                       
   },                                                            
 },    
...
];                                                          
...

授权用户创建文章 – POST – /articles


// ./src/routes.js
...
const routes = [
....
 {                                                              
   path: '/articles',                                           
   method: 'POST',                                              
   config: {                                                    
     auth: {                                                    
       strategy: 'token',                                       
     },                                                         
   },                                                           
   handler: (request, reply) => {                               
     const { article } = request.payload;                       
     const guid = GUID.v4();                                    
     const insertOperation = Knex('articles').insert({          
       owner: request.auth.credentials.scope,                   
       name: article.name,                                      
       species: article.species,                                
       picture_url: article.picture_url,                        
       guid,                                                    
     })                                                         
       .then((res) => {                                         
         reply({                                                
           data: guid,                                          
           message: 'sucessfully created article',              
         });                                                    
       })                                                       
       .catch((err) => {                                        
         reply(`serverside error ${err}`);                      
       });                                                      
   },                                                           
 },    
...
];                                                          
...                                                         

授权用户更新自己的文章 – PUT – /articles/{articleGuid}


// ./src/routes.js
...
  {                                                                                                   
    path: '/articles/{articleGuid}',                                                                  
    method: 'PUT',                                                                                    
    config: {                                                                                         
      auth: {                                                                                         
        strategy: 'token',                                                                            
      },                                                                                              
      pre: [                                                                                          
        {                                                                                             
          method: (request, reply) => {                                                               
            const { articleGuid } = request.params;                                                   
            const { scope } = request.auth.credentials;                                               
            const getOperation = Knex('articles').where({                                             
              guid: articleGuid,                                                                      
            })                                                                                        
              .select('owner')                                                                        
              .then(([result]) => {                                                                   
                if (!result) {                                                                        
                  reply({                                                                             
                    error: true,                                                                      
                    errMessage: `the article with id ${articleGuid} was not found`,                   
                  }).takeover();                                                                      
                }                                                                                     
                if (result.owner !== scope) {                                                         
                  reply({                                                                             
                    error: true,                                                                      
                    errMessage: `the article with id ${articleGuid} is not the current scope`,        
                  }).takeover();                                                                      
                }                                                                                     
                return reply.continue();                                                              
              });                                                                                     
          },                                                                                          
        },                                                                                            
      ],                                                                                              
    },                                                                                                
    handler: (request, reply) => {                                                                    
      const { articleGuid } = request.params;                                                         
      const { article } = request.payload;                                                            
      const insertOperation = Knex('articles').where({                                                
        guid: articleGuid,                                                                            
      }).update({                                                                                     
        name: article.name,                                                                           
        species: article.species,                                                                     
        picture_url: article.picture_url,                                                             
        isPublic: article.isPublic,                                                                   
      })                                                                                              
        .then((res) => {                                                                              
          reply({                                                                                     
            message: 'successfully updated article',                                                  
          });                                                                                         
        })                                                                                            
        .catch((err) => {                                                                             
          reply(`server-side error ${err}`);                                                          
        });                                                                                           
    },                                                                                                
  },                                                                                                  
...
];                                                          
...    

这里在授权验证token之后,还会有一段pre的逻辑验证,代码不难理解,请大家自行理解一番。

对于DELETE而言,基本上与PUT是一致的,主要是要验证用户与指定文章的匹配问题。

限于篇幅,我并没有对代码做过多的解释,希望大家谅解! 本文重点在于整理思路,以及一些NODE相关依赖包/工具的使用。

如果您实在觉得理解有困难,那么我建议您先看一看基础的ES6语法特性以及EXPRESSJS。因为网上HapiJS相关资料不是很多,但EXPRESS的是一打一打的。其实,两个框架的基本思想和实现是类似的。