1 初步实现用Rails+qq邮箱发送邮件
Rails已经内置发邮件的功能
Action Mailer Basics — Ruby on Rails Guides
1.1 创建Mailer
1
|
$ bin/rails generate mailer User
|
1
2
3
4
5
6
7
|
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
# 全局发送邮件地址
default from: "845217811@qq.com"
# html默认布局
layout 'mailer'
end
|
1
2
3
4
5
6
7
8
|
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def welcome_email(code)
# 给模板传递变量
@code = code
mail(to: "845217811@qq.com", subject: 'Welcome to My Awesome Site')
end
end
|
1
2
3
4
5
6
7
8
9
10
11
|
# app/views/user_mailer/welcome_email.html.erb
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
</head>
<body>
<%# 使用传递的变量 %>
hi <%= @code %>
</body>
</html>
|
1.2 开启qq的smtp服务
1
|
EDITOR="code --wait" bin/rails credentials:edit
|
1
|
email_password: ycmbmysnijqcbfgb
|
- 然后在 Rails 中使用
Rails.application.credentials.email_password
获取对应的值
1.3 配置邮件服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# config/environments/development.rb
...
# 是否抛出邮件错误
config.action_mailer.raise_delivery_errors = true
# 是否使用缓存
config.action_mailer.perform_caching = false
#
config.action_mailer.smtp_settings = {
address: 'smtp.qq.com',
port: 587,
domain: 'smtp.qq.com',
user_name: '845217811@qq.com',
password: Rails.application.credentials.email_password,
authentication: 'plain',
anable_starttls_auto: true,
open_timeout: 10,
read_timeout: 10
}
...
|
1
2
3
|
$ bin/rails console
# 使用UserMailer内置方法即可
$ UserMailer.welcome_email('123456').deliver
|
2 TDD——测试驱动开发
我们目前使用的测试库是Rspect
,如果需要在测试后自动生成Api文档,则需额外安装rspec_api_documentation
zipmark/rspec_api_documentation: Automatically generate API documentation from RSpec (github.com)
2.1 测试验证码是否正常发送
1
2
3
4
5
6
|
# Add "gem 'rspec_api_documentation'" to your Gemfile
$ bundle install
# 创建api文档测试文件夹
$ mkdir spec/acceptance
# 创建验证码测试文件
$ code spec/acceptance/validation_codes_spec.rb
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# spec/acceptance/validation_codes_spec.rb
require 'rails_helper'
require 'rspec_api_documentation/dsl'
resource "验证码" do
post "/api/v1/validation_codes" do
# 用于形成文档中的参数表格
parameter :email, type: :string
# 用于声明请求参数
let(:email) { '1@qq.com' }
example "请求发送验证码" do
do_request
expect(status).to eq 200
expect(response_body).to eq ' '
end
end
end
|
1
2
3
4
5
6
7
8
9
10
11
|
# spec/spec_helper.rb
...
Rspec.configure do |config|
config.before(:each) do |spec|
# 如果是acceptance类型的测试
if spec.metadata[:type].equal? :acceptance
header 'Accept', 'application/json'
header 'Content-Type', 'application/json'
end
end
...
|
1
2
3
4
|
# app/models/validation_code.rb
class ValidationCode < ApplicationRecord
validates :email, presence: true
end
|
1
2
3
4
5
6
7
8
9
10
11
12
|
# app/controllers/api/v1/validation_codes_controller.rb
class Api::V1::ValidationCodesController < ApplicationController
def create
code = SecureRandom.random_number.to_s[2..7]
validation_code = ValidationCode.new email: params[:email], kind: 'sign_in', code: code
if validation_code.save
render status: 200
else
render json: {errors: validation_code.errors}, status: 400
end
end
end
|
1
2
3
|
$ bin/rake docs:generate
# docker开发环境无法直接浏览生成的html
$ npx http-server doc/api
|
-
解决Api文档中请求体与响应体无法正常显示的问题
1
2
3
4
5
6
7
|
# spec/spec_helper.rb
require 'rspec_api_documentation'
RspecApiDocumentation.configure do |config|
# 配置文档中请求体的格式为json
config.request_body_formatter = :json
end
...
|
1
2
3
4
5
|
# 将代码克隆到vendor中,别忘删除掉.git
$ git clone https://github.com/jrg-team/rspec_api_documentation.git vendor/rspec_api_documentation
# Gemfile
gem 'rspec_api_documentation', path: './vendor/rspec_api_documentation'
$ bundle
|
2.2 完善测试
2.2.1 TDD完善测试内容及业务功能
上面只是测试了请求与响应,另外还需要测试是否调用了UserMailer的方法(至于是否成功发送到用户的邮箱,那是另外一个单元测试需要做的),以及重复发送请求的响应(429)
1
2
|
# 测试是否调用了UserMailer中的welcome_email方法
expect(UserMailer).to receive(:welcome_email).with(email)
|
1
2
3
4
5
6
7
8
9
10
11
|
class UserMailer < ApplicationMailer
def welcome_email(email)
# 先按创建时间排序,以确保code是最新的
# 再根据传入的email到数据库中查询响应的code
validation_code = ValidationCode.order(create_at: :desc).find_by_email(email)
# 给模板传递变量
@code = validation_code.code
# 发送邮件
mail(to: email, subject: '验证码')
end
end
|
1
2
3
4
5
6
7
|
class Api::V1::ValidationCodesController < ApplicationController
def create
...
if validation_code.save
UserMailer.welcome_email(validation_code.email)
render status: 200
...
|
2.2.2 利用生命周期函数优化Controller代码
Controller在应用程序中主要作为逻辑层,所以与数据有关的操作(生成code,发送email)应放入Model中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# app/models/validation_code.rb
class ValidationCode < ApplicationRecord
validates :email, presence: true
# 在创建这条记录之前,调用generate_code方法生成随机code
before_create :generate_code
# 在save这条记录之后,调用send_email方法发送邮件
after_create :send_email
def generate_code
# self相当于js中的this,指代当前实例对象
self.code = SecureRandom.random_number.to_s[2..7]
end
def send_email
UserMailer.welcome_email(self.email)
end
end
# app/controllers/api/v1/validation_codes_controller.rb
class Api::V1::ValidationCodesController < ApplicationController
def create
validation_code = ValidationCode.new email: params[:email], kind: 'sign_in'
if validation_code.save
render status: 200
else
render json: { errors: validation_code.errors }, status: 400
end
end
end
|
2.2.3 利用enum解决kind字段的映射问题
1
2
|
# app/models/validation_code.rb
enum kind: { sign_in: 0, reset_password: 1 }
|
2.2.4 区别acceptance与requests测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# spec/requests/validation_codes_spec.rb
require 'rails_helper'
RSpec.describe "ValidationCodes", type: :request do
describe "验证码" do
it "60秒内不能重复发送" do
post '/api/v1/validation_codes', params: {
email: '845217811@qq.com'
}
expect(response).to have_http_status(200)
post '/api/v1/validation_codes', params: {
email: '845217811@qq.com'
}
expect(response).to have_http_status(429)
end
end
end
|
1
2
3
4
5
6
7
8
9
10
11
|
# app/controllers/api/v1/validation_codes_controller.rb
...
# 重复请求验证码的校验
if ValidationCode.exists?(email: params[:email], kind: 'sign_in', created_at: 1.minute.ago..Time.now)
render status 429
return
end
...
# 测试
$ rspec
|
3 登录接口
3.1 JWT概念
3.1.1 cookie
因为HTTP是无状态的协议(每次客户端和服务端会话完成时,服务端不会保存任何会话信息),每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次的发送者是不是同一个人。所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。
当登录成功后,服务器会将登录信息()添加到Set-Cookie响应头字段(可以有多个)中下发给客户端,客户端会将这个登录信息保存在浏览器的cookie中,后续对这个域名发起请求时会自动携带全部cookie作为请求头字段
3.1.2 session
配合cookie使用,用户在服务端记录用户登录状态
当用户登录成功时,服务器端会维护一份对应登录用户加密后的session信息(例如userID),用于校验不同用户的登录状态
3.1.3 JWT(json web token)
当用户量过大时,session会占用服务器过多资源,所以提出了JWT的用户认证方案
JWT属于一种特殊的token
JWT 是自包含的(自包含了用户信息和加密的数据),因此减少或者不需要查询数据库,从而减轻服务端压力
3.2 JWT后端实现
3.2.1 TDD实现登录接口
bin/rails g controller api/v1/sessions_controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# spec/requests/api/v1/sessions_spec.rb
require 'rails_helper'
RSpec.describe "Sessions", type: :request do
describe "会话" do
it "登录(创建)会话" do
# 在数据库中创建一个假用户
User.create email:'845217811@qq.com'
# code临时规定为'123456'
post '/api/v1/session', params: {
email: '845217811@qq.com', code:'123456'
}
expect(response).to have_http_status(200)
json = JSON.parse response.body
# 期待测试通过,且响应体存在JWT字符串
expect(json['JWT']).to be_a(String)
end
end
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
# 由于单元测试写死了code为123456,因此需要为测试环境单独写逻辑
if Rails.env.test?
return render status: 401 if params[:code] != '123456'
else
canSignin = ValidationCodes.exists? email: params[:email], code: params[:code], used_at: nil
return render status: 401 unless canSignin
# unless 也可以写做 if not
end
user = User.find_by_email params[:email]
if user.nil?
render status: 404, json: {errors: '用户不存在'}
else
# 登录成功的条件有两条:
# 1 validation存在且没有被验证过(Validation.exists? used_at: nil)
# 2 数据库存在此用户(User.find_by_email)
render stauts: 200, json: {jwt: 'xxxxxxxxxxxxx'}
end
end
|
jwt/ruby-jwt: A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard. (github.com)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# Gemfile
gem 'jwt'
# console
$ bundle
# app/controllers/api/v1/sessions_controller.rb
require 'jwt'
class Api::V1::SessionsController < ApplicationController
def create
...
# 加密私钥使用rails管理
# EDITOR="code --wait" bin/rails credentials:edit
# hmac_secret = 'b70db9f7-e84f-45fe-bd16-db83684d8c0e'
hmac_secret = Rails.application.credentials.hmac_secret
payload = { user_id: user.id }
# encode方法接受三个参数 分别是 payload数据,加密用私钥,加密算法
token = JWT.encode payload, hmac_secret, 'HS256'
render stauts: 200, json: { jwt: token }
...
end
end
|
3.2.2 TDD实现获取当前用户接口
bin/rails g controller api/v1/me_controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
require 'rails_helper'
RSpec.describe "Me", type: :request do
describe "获取当前登录用户" do
it "获取" do
user = User.create email:'845217811@qq.com'
# 先登录
post '/api/v1/session', params: {
email: '845217811@qq.com', code:'123456'
}
json = JSON.parse response.body
jwt = json['jwt']
# 再获取当前登录用户,记得设置请求头
get '/api/v1/me', headers: {"Authorization": "Bearer #{jwt}"}
expect(response).to have_http_status(200)
json = JSON.parse response.body
# 期待返回的用户id与我们创建的用户id一致
expect(json['resource']['id']).to eq user.id
end
end
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# app/controllers/api/v1/mes_controller.rb
class Api::V1::MesController < ApplicationController
def show
header = request.headers["Authorization"]
# 去掉 'Bearer'
jwt = header.split(' ')[1] rescue ''
# rescue就相当于js中的try/catch的catch
payload = JWT.decode jwt, Rails.application.credentials.hmac_secret, true, { algorithm: 'HS256' } rescue nil
return head 400 if payload.nil?
user_id = payload[0]['user_id'] rescue nil
user = User.find user_id
return head 404 if user.nil?
render json: { resource: user }
end
end
|
3.3 中间件与鉴权
3.3.1 JWT中间件
Rails on Rack — Ruby on Rails Guides
经过登录接口的开发过程,我们熟悉了jwt的加密解密以及登录的测试流程
-
我们会发现,在处理任何响应时都需要从请求头中取出jwt,对其解密并从中提取user_id。这个过程是比较频繁的
-
可以将这个逻辑抽离为中间件,在执行controller之前对jwt进行解析并提取用户信息
-
什么是中间件
- 出现在:
- 用户——
访问
——路由——执行
——controller——处理请求
——响应——返回
——用户
- 这个过程中间的东西就是中间件
执行
和返回
就是中间件常出现的位置
-
bin/rails middleware
显示全部中间件
-
touch lib/auto_jwt.rb
在lib下创建中间件文件(lib是自己给自己写的库,vendor是别人给自己写的库)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# lib/auto_jwt.rb
class AutoJwt
# 包括两个方法,initialize和call
def initialize(app)
@app = app
end
# 中间件被调用时需要执行的方法
# 参数的env包括请求与响应的所有信息
def call(env)
# 获取header中的jwt
header = env['HTTP_AUTHORIZATION']
jwt = header.split(' ')[1] rescue ''
payload = JWT.decode jwt, Rails.application.credentials.hmac_secret, true, { algorithm: 'HS256' } rescue nil
# 将解析出来的user_id写入env中
env['current_user_id'] = payload[0]['user_id'] rescue nil
# 继续执行controller,如果不写这句,controller的逻辑就自动跳过了
# @status, @headers, @response就是controller返回的响应数据
@status, @headers, @response = @app.call(env)
[@status, @headers, @response]
# call方法必须返回状态码、响应头、响应体
# [200, {}, ['Hello World', 'Hi']]
end
end
|
1
2
3
4
5
6
7
8
9
10
11
12
|
# config/application.rb
...
require_relative "../lib/auto_jwt"
Bundler.require(*Rails.groups)
module Mangosteen1
class Application < Rails::Application
...
config.middleware.use AutoJwt
end
end
|
1
2
3
4
5
6
7
8
9
10
11
|
# app/controllers/api/v1/mes_controller.rb
class Api::V1::MesController < ApplicationController
def show
# 在request中拿到中间件解析出来的user_id
# user_id = payload[0]['user_id'] rescue nil
user_id = request.env['current_user_id'] rescue nil
user = User.find user_id
return head 404 if user.nil?
render json: { resource: user }
end
end
|
1
2
3
4
|
# 执行指定的测试用例
$ rspec -e "获取当前登录用户"
# 也可以指定行数
$ rspec spec/requests/api/v1/me_spec.rb:5
|
3.3.2 TDD实现Items接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# spec/requests/api/v1/items_spec.rb
it "按时间筛选" do
# 三条数据 两条符合
item1 = Item.create amount: 100, created_at: Time.new(2018, 1, 2)
item2 = Item.create amount: 100, created_at: Time.new(2018, 1, 2)
item3 = Item.create amount: 100, created_at: Time.new(2019, 1, 2)
# 按时间筛选
get '/api/v1/items?created_after=2018-01-01&created_before=2018-01-03'
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 2
expect(json['resources'][0]['id']).to eq item1.id
expect(json['resources'][1]['id']).to eq item2.id
end
# app/controllers/api/v1/items_controller.rb
def index
# ruby使用A..B表示范围
items = Item.where({created_at: params[:created_after]..params[:created_before]}).page params[:page]
...
end
|
- 测试边界条件——
created_after=created_at
Time.new
默认使用当前时区(+8),而参数默认使用标准时区(+0)
- 统一为标准时区
1
2
3
|
Time.new(2018, 1, 1, 0, 0, 0, '+00:00')
Time.new(2018, 1, 1, 0, 0, 0, 'Z')
'2018-01-01'
|
1
2
3
4
5
6
7
8
9
10
11
12
|
# spec/requests/api/v1/items_spec.rb
it "按时间筛选(created_at===created_after)" do
# 指定时间为标准时区时间
# item1 = Item.create amount: 100, created_at: Time.new(2018, 1, 1, 0, 0, 0, 'Z')
# item1 = Item.create amount: 100, created_at: Time.new(2018, 1, 1, 0, 0, 0, '+00:00')
item1 = Item.create amount: 100, created_at: '2018-01-01'
get '/api/v1/items?created_after=2018-01-01&created_before=2018-01-03'
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 1
expect(json['resources'][0]['id']).to eq item1.id
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# spec/requests/api/v1/items_spec.rb
it "按时间筛选(只传created_after)" do
# 指定时间为标准时区时间
item1 = Item.create amount: 100, created_at: '2018-01-01'
item2 = Item.create amount: 100, created_at: '2017-01-01'
get '/api/v1/items?created_after=2018-01-01'
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 1
expect(json['resources'][0]['id']).to eq item1.id
end
it "按时间筛选(只传created_before)" do
# 指定时间为标准时区时间
item1 = Item.create amount: 100, created_at: '2018-01-01'
item2 = Item.create amount: 100, created_at: '2019-01-01'
get '/api/v1/items?created_before=2018-01-03'
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 1
expect(json['resources'][0]['id']).to eq item1.id
end
|
当单元测试通过后,就可以写acceptance测试来更新Api文档了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
# 创建验证码测试文件
$ code spec/acceptance/items_spec.rb
# spec/acceptance/items_spec.rb
require 'rails_helper'
require 'rspec_api_documentation/dsl'
resource "账目" do
get "/api/v1/items" do
# 描述请求 parameter
parameter :page, '页码'
parameter :created_after, '创建时间起点(筛选条件)'
parameter :created_before, '创建时间终点(筛选条件)'
# 描述响应 response_field
# response_field :id, 'ID', scope: :resources
# response_field :amount, '金额(单位:分)', scope: :resources
# 以上两句有相同的scope,所以可以简写为下面的
with_options :scope => :resources do
response_field :id, 'ID'
response_field :amount, '金额(单位:分)'
end
# 构造示例请求
let(:created_after) {'2021-01-01'}
let(:created_before) {'2022-01-01'}
example "获取账目" do
11.times do Item.create amount: 100, created_at: '2021-6-30' end
do_request
expect(status).to eq 200
json = JSON.parse response_body
expect(json['resources'].size).to eq 10
end
end
end
# 创建文档
$ bin/rake docs:generate
|
3.3.3 鉴权
每个账目都是属于对应用户的,这就需要我们在每次请求数据时确认登录状态及用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
# spec/requests/api/v1/items_spec.rb
it "能成功创建并分页返回数据" do
# 构造用户
user1 = User.create email: '1@qq.com'
user2 = User.create email: '2@qq.com'
# 为每个用户构造数据
11.times { Item.create amount: 99, user_id: user1['id'] }
11.times { Item.create amount: 99, user_id: user2['id'] }
expect(Item.count).to eq 22
# 用户登录,获取user1的jwt
post '/api/v1/session', params: {
email: user1.email, code:'123456'
}
json = JSON.parse response.body
jwt = json['jwt']
# get获取user1的items时要带上权限请求头
get '/api/v1/items', headers: {"Authorization": "Bearer #{jwt}"}
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 10
get '/api/v1/items?page=2', headers: {"Authorization": "Bearer #{jwt}"}
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 1
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
# app/controllers/api/v1/items_controller.rb
def index
# 获取items前鉴权,获取登录用户id
current_user_id = request.env['current_user_id']
return head 401 if current_user_id.nil?
# ruby使用A..B表示范围
items = Item.where({user_id: current_user_id})
.where({created_at: params[:created_after]..params[:created_before]})
.page params[:page]
# 也可以Item.page(params[:page]).per(100)自定义pageSize
render json: { resources: items, pager: {
page: params[:page] || 1,
per_page: Item.default_per_page,
count: Item.count
}}
end
|
- 每次测试都需要重复的进行登录操作,从而获取jwt,放入请求头,非常麻烦
- 考虑到每个jwt都属于用户自己,所以干脆让每个用户能够生成自己的jwt与Auth请求头即可
- 在user的model层添加
generate_jwt
和generate_auth_header
两个实例方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# app/models/user.rb
class User < ApplicationRecord
validates :email, presence: true
def generate_jwt
hmac_secret = Rails.application.credentials.hmac_secret
# self相当于this
payload = { user_id: self.id }
# ruby会自动返回最后一句
JWT.encode payload, hmac_secret, 'HS256'
end
def generate_auth_header
{"Authorization": "Bearer #{self.generate_jwt}"}
end
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# app/controllers/api/v1/sessions_controller.rb
render stauts: 200, json: { jwt: user.generate_jwt }
# spec/requests/api/v1/items_spec.rb
it "能成功创建并分页返回数据" do
# 构造用户
user1 = User.create email: '1@qq.com'
user2 = User.create email: '2@qq.com'
# 为每个用户构造数据
11.times { Item.create amount: 99, user_id: user1['id'] }
11.times { Item.create amount: 99, user_id: user2['id'] }
expect(Item.count).to eq 22
# get获取user1的items时要带上权限请求头
get '/api/v1/items', headers: user1.generate_auth_header
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 10
get '/api/v1/items?page=2', headers: user1.generate_auth_header
expect(response).to have_http_status(200)
json = JSON.parse response.body
expect(json['resources'].size).to eq 1
end
# 其他测试略
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# spec/acceptance/items_spec.rb
require 'rails_helper'
require 'rspec_api_documentation/dsl'
resource "账目" do
get "/api/v1/items" do
# 指定验证方式 基础验证(Bear) 值用变量表示
authentication :basic, :auth
...
let(:created_after) {'2021-01-01'}
let(:created_before) {'2022-01-01'}
let(:current_user) { User.create email: '1@qq.com' }
let(:auth) { "Bearer #{current_user.generate_jwt}" }
example "获取账目" do
11.times do Item.create amount: 100, created_at: '2020-10-30', user_id: current_user.id end
do_request
expect(status).to eq 200
json = JSON.parse response_body
expect(json['resources'].size).to eq 10
end
end
end
|
3.4 JWT续期与Refresh Token
3.4.1 jwt过期
- jwt过期时间一般设置为2h~24h后
- 只需在jwt的payload中添加exp字段,接收以秒为单位的int数据,这里设置为2h后过期
1
2
3
4
5
6
|
# app/models/user.rb
def generate_jwt
...
payload = { user_id: self.id, exp: (Time.now + 2.hours).to_i }
...
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# lib/auto_jwt.rb
def call(env)
# jwt跳过以下路径
return @app.call(env) if ['/api/v1/session'].include?(env['PATH_INFO'])
...
# payload = JWT.decode jwt, Rails.application.credentials.hmac_secret, true, { algorithm: 'HS256' } rescue nil
# JWT.decode方法会自动进行过期时间检查
# 改写为更全面的rescue,区别过期报错与其他报错
begin
payload = JWT.decode jwt, Rails.application.credentials.hmac_secret, true, { algorithm: 'HS256' }
rescue JWT::ExpiredSignature
return [401, {}, [JSON.generate({reason: 'jwt expired'})]]
rescue
return [401, {}, [JSON.generate({reason: 'jwt invalid'})]]
end
...
end
|
-
jwt过期测试
-
使用rails内置的时间测试库,模拟控制程序时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# spec/requests/api/v1/me_spec.rb
require 'active_support/testing/time_helpers'
RSpec.describe "Me", type: :request do
include ActiveSupport::Testing::TimeHelpers
...
it "jwt过期" do
# 先把时间倒回到三小时前,创建jwt
travel_to Time.now - 3.hours
user = User.create email:'845217811@qq.com'
jwt = user.generate_jwt
# 回到现在
travel_back
get '/api/v1/me', headers: {"Authorization": "Bearer #{jwt}"}
expect(response).to have_http_status(401)
end
end
|
- 修改了jwt过期时间及中间件逻辑,重新运行rspec单元测试,在
/api/v1/items
接口创建数据的单元测试报错,如果我们想暂时跳过这个单元测试内容,可以在it
前加x
,将这个测试用例置为pendding状态即可
1
2
3
4
5
6
|
# spec/requests/api/v1/items_spec.rb
describe 'POST /items' do
xit '能够创建一条数据' do
...
end
end
|
3.4.2 jwt续期
- 当jwt过期后(2h),如果让用户登录以申请新的jwt,会严重影响用户体验
- 此时基于refreshToken的jwt续期方案就应运而生了,流程如下
- 用户成功登录后,会将jwt及
refreshToken
返回给客户端,这个token
需要维护在服务端,记录用户id
、token内容
及创建token的时间
- 当jwt过期时,如果距
token
的创建时间范围在七天内(一般有效期7天),那么自动申请新的jwt返回给客户端,自动完成jwt的续期
3.5 补充登录Api文档
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# spec/acceptance/sessions_spec.rb
require 'rails_helper'
require 'rspec_api_documentation/dsl'
resource "会话" do
post "/api/v1/session" do
parameter :email, '邮箱', required: true
parameter :code, '验证码', required: true
response_field :jwt, '用于认证用户身份的token'
let(:email) { '1@qq.com' }
let(:code) { '123456' }
example "登录" do
do_request
expect(status).to eq 200
json = JSON.parse response_body
expect[json['jwt']].to be_a String
end
end
end
|
1
2
3
4
5
6
7
8
9
10
|
# app/controllers/api/v1/sessions_controller.rb
...
# 首次登录自动创建用户
user = User.find_or_create_by email: params[:email]
render stauts: :ok, json: { jwt: user.generate_jwt }
# if user.nil?
# render status: 404, json: {errors: '用户不存在'}
# else
# render stauts: 200, json: { jwt: user.generate_jwt }
# end
|
1
2
3
4
5
6
7
8
9
10
11
|
# spec/requests/api/v1/sessions_spec.rb
it "首次登录" do
# 直接登录 期待自动创建用户
post '/api/v1/session', params: {
email: '845217811@qq.com', code:'123456'
}
expect(response).to have_http_status(200)
json = JSON.parse response.body
# 期待测试通过,且响应体存在JWT字符串
expect(json['jwt']).to be_a(String)
end
|
4 其他接口
- 创建流程
- 创建model,执行db:migrate同步数据库
- 创建controller
- 写测试
- 写代码
- 写文档
- tags相关接口略,详见commit
GSemir0418/account-record-rails at be693cbeb5cc4196d9393b0f1318871c8b9b8b96 (github.com)
4.1 TDD创建账目接口
4.1.1 修改数据库字段
1
2
3
4
5
6
7
8
9
10
11
12
|
# console
$ bin/rails g migration AddKindToItem
# db/migrate/20220708093801_add_kind_to_item.rb
class AddKindToItem < ActiveRecord::Migration[7.0]
def change
add_column :items, :kind, :integer, default: 1, null: false
end
end
# console
$ bin/rails db:migrate RAILS_ENV=test
|
1
2
3
4
|
# app/models/item.rb
class Item < ApplicationRecord
enum kind: {expense: 1, income: 2}
end
|
4.1.2 统一时间格式(ISO8601)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# spec/requests/api/v1/items_spec.rb
it '登录创建数据' do
user = User.create email: '1@qq.com'
expect {
post '/api/v1/items', params: {
amount: 99,
tags_id: [1, 2],
# 时间格式统一为标准时间
happen_at: '2018-10-01T00:00:00+00:00'
},
headers: user.generate_auth_header
}.to change {Item.count}.by(+1)
expect(response).to have_http_status(201)
json = JSON.parse response.body
expect(json['resource']['amount']).to eq(99)
expect(json['resource']['user_id']).to eq(user.id)
expect(json['resource']['id']).to be_an(Numeric)
expect(json['resource']['happen_at']).to eq "2018-10-01T00:00:00.000Z"
end
it '创建时amount、happen_at数据必填' do
user = User.create email: '1@qq.com'
post '/api/v1/items', params: {},
headers: user.generate_auth_header
expect(response).to have_http_status(422)
json = JSON.parse response.body
# kind有默认值1,暂时不测
expect(json['errors']['amount'][0]).to eq "can't be blank"
expect(json['errors']['happen_at'][0]).to eq "can't be blank"
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# app/models/item.rb
class Item < ApplicationRecord
enum kind: {expense: 1, income: 2}
validates :amount, presence: true
validates :kind, presence: true
validates :happen_at, presence: true
# 稍后会自定义tags_id校验
validates :tags_id, presence: true
end
# app/controllers/api/v1/items_controller.rb
def create
# 由于tags_id是数组类型,在使用params.permit方法是需要声明tags_id的类型(写在最后)
item = Item.new params.permit(:amount, :happen_at, tags_id:[] )
item.user_id = request.env['current_user_id']
if item.save
render json: { resource: item }, status: 201
else
render json: { errors: item.errors }, status: 422
end
end
|
- 关于tags_id字段,存在一个问题
- 实际上,一个用户在创建账目时,只能使用自己(当前用户)的tags作为标签,不允许使用其他用户的tags
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
# app/models/item.rb
class Item < ApplicationRecord
enum kind: {expense: 1, income: 2}
validates :amount, presence: true
validates :kind, presence: true
validates :happen_at, presence: true
# validates :tags_id, presence: true
# 有s的是默认校验,传值即可
# 没有s的是自定义校验,指定校验方法
validate :check_tags_id_belong_to_user
def check_tags_id_belong_to_user
# 遍历当前用户的全部tags
all_tag_ids = Tag.where(user_id: self.user_id).map(|tag| tag.id)
# 传入的tags_id如果与all_tag_ids不存在交集
if self.tags_id & all_tag_ids != self.tags_id
# 说明不是当前用户的tags,报错
self.errors.add :tags_id, "不属于当前用户"
end
end
end
# spec/requests/api/v1/items_spec.rb
it '创建时的tags_id不属于用户' do
user = User.create email: '1@qq.com'
tag1 = Tag.create name: 'x', sign: 'x', user_id: user.id
tag2 = Tag.create name: 'y', sign: 'y', user_id: user.id
post '/api/v1/items', params: {
tags_id: [tag1.id,tag2.id, 3],
amount: 99,
happen_at: '2018-10-01T00:00:00+00:00'
},
headers: user.generate_auth_header
expect(response).to have_http_status(422)
json = JSON.parse response.body
expect(json['errors']['tags_id'][0]).to eq "不属于当前用户"
end
|
4.2 TDD账目统计接口
1
2
3
4
5
6
7
8
9
|
# config/routes.rb
...
# resources :items
resources :items do
collection do
get :summary
end
end
...
|
4.2.1 按时间分组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
describe '统计账目' do
it '按天分组' do
user = User.create! email: '1@qq.com'
tag = Tag.create! name: 'tag1', sign: 'x', user_id: user.id
# 只有时间(北京时间+08:00)不同的六条数据
Item.create! amount: 100, kind: 'expense', tags_id: [tag.id], happen_at: '2018-06-18T00:00:00+08:00', user_id: user.id
Item.create! amount: 200, kind: 'expense', tags_id: [tag.id], happen_at: '2018-06-18T00:00:00+08:00', user_id: user.id
Item.create! amount: 100, kind: 'expense', tags_id: [tag.id], happen_at: '2018-06-20T00:00:00+08:00', user_id: user.id
Item.create! amount: 200, kind: 'expense', tags_id: [tag.id], happen_at: '2018-06-20T00:00:00+08:00', user_id: user.id
Item.create! amount: 100, kind: 'expense', tags_id: [tag.id], happen_at: '2018-06-19T00:00:00+08:00', user_id: user.id
Item.create! amount: 200, kind: 'expense', tags_id: [tag.id], happen_at: '2018-06-19T00:00:00+08:00', user_id: user.id
get '/api/v1/items/summary', params: {
happened_after: '2018-01-01',
happened_before: '2019-01-01',
kind: 'expense',
group_by: 'happen_at'
}, headers: user.generate_auth_header
expect(response).to have_http_status 200
json = JSON.parse response.body
expect(json['groups'].size).to eq 3
expect(json['groups'][0]['happen_at']).to eq '2018-06-18'
expect(json['groups'][0]['amount']).to eq 300
expect(json['groups'][1]['happen_at']).to eq '2018-06-19'
expect(json['groups'][1]['amount']).to eq 300
expect(json['groups'][2]['happen_at']).to eq '2018-06-20'
expect(json['groups'][2]['amount']).to eq 300
expect(json['total']).to eq 900
end
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
def summary
# 最后的hash be like {2018-06-18:300,2018-06-19:300,2018-06-20:300,}
hash = Hash.new
# 1.拿到该用户在时间范围内全部的支出/收入的items
items = Item
.where(user_id: request.env['current_user_id'])
.where(kind: params[:kind])
.where(happen_at: params[:happened_after]..params[:happened_before])
# 2.遍历items,借助hash累加每天的amount
items.each do |item|
# 规范格式(%F相当于%Y-%m-%d的简写)
key = item.happen_at.in_time_zone('Beijing').strftime('%F')
# 如果hash[key]没有值,则初始化为零,相当于hash[key] = hash[key] || 0
hash[key] ||= 0
# 加上当前金额
hash[key] += item.amount
end
# 3.将hash遍历为数组(map),并排序
groups = hash
.map { |key, value| {"happen_at": key, amount: value} }
# <=> spaceship operator 返回-1 0 1
# sort!表示改变当前数组,不创建新数组
.sort { |a, b| a[:happen_at] <=> b[:happen_at] }
render json: {
groups: groups,
# 求和利用items.sum(求和的字段)
total: items.sum(:amount)
}
end
|
4.2.2 按tag_id分组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
it '按标签ID分组' do
user = User.create! email: '1@qq.com'
tag1 = Tag.create! name: 'tag1', sign: 'x', user_id: user.id
tag2 = Tag.create! name: 'tag2', sign: 'x', user_id: user.id
tag3 = Tag.create! name: 'tag3', sign: 'x', user_id: user.id
Item.create! amount: 100, kind: 'expense', tags_id: [tag1.id, tag2.id], happen_at: '2018-06-18T00:00:00+08:00', user_id: user.id
Item.create! amount: 200, kind: 'expense', tags_id: [tag2.id, tag3.id], happen_at: '2018-06-18T00:00:00+08:00', user_id: user.id
Item.create! amount: 300, kind: 'expense', tags_id: [tag3.id, tag1.id], happen_at: '2018-06-18T00:00:00+08:00', user_id: user.id
# tag3: 500, tag1: 400, tag2: 300
get '/api/v1/items/summary', params: {
happened_after: '2018-01-01',
happened_before: '2019-01-01',
kind: 'expense',
group_by: 'tag_id'
}, headers: user.generate_auth_header
expect(response).to have_http_status 200
json = JSON.parse response.body
expect(json['groups'].size).to eq 3
expect(json['groups'][0]['tag_id']).to eq tag3.id
expect(json['groups'][0]['amount']).to eq 500
expect(json['groups'][1]['tag_id']).to eq tag1.id
expect(json['groups'][1]['amount']).to eq 400
expect(json['groups'][2]['tag_id']).to eq tag2.id
expect(json['groups'][2]['amount']).to eq 300
expect(json['total']).to eq 600
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
def summary
hash = Hash.new
items = Item
.where(user_id: request.env['current_user_id'])
.where(kind: params[:kind])
.where(happen_at: params[:happened_after]..params[:happened_before])
items.each do |item|
# 按时间分组,hash的key为happen_at
if params[:group_by] == 'happen_at'
key = item.happen_at.in_time_zone('Beijing').strftime('%F')
hash[key] ||= 0
hash[key] += item.amount
# 按tag_id分组,hash的key为tags_id,此时需要遍历内部的tag_id
else
item.tags_id.each do |tag_id|
hash[tag_id] ||= 0
hash[tag_id] += item.amount
end
end
end
groups = hash.map { |key, value| {"#{params[:group_by]}": key, amount: value} }
# 按时间升序排序
if params[:group_by] == 'happen_at'
groups.sort! { |a, b| a[:happen_at] <=> b[:happen_at] }
# 按tag_id的金额降序排序,注意else if写作elsif
elsif params[:group_by] == 'tag_id'
groups.sort! { |a, b| b[:amount] <=> a[:amount] }
end
render json: {
groups: groups,
total: items.sum(:amount)
}
end
|
4.2.3 更新summary文档
略