Rails 使用 Rails 5 创建 API-Only 应用

smartepsh · 2016年07月20日 · 最后由 lifengsoft 回复于 2018年03月01日 · 7785 次阅读

使用Rails5的API-Only…...已使AMS返回的媒体类型为注册的标准类型:vnd.api+json

根据使用Rails构建API实践帖子改写,使用Active Model Serializers输出 因为是纯新手,所以想先盯着输出这里的东西,所以其他的东西,我就抱歉啦,标题代码逻辑什么的我都直接照抄啦,请不要告我侵权ಠ౪ಠ 全部完成后会at原作者的 @kayakjiang

建议和原文同时食用,好多思路部分的东西我没有搬过来...

完成原文前5章,后续不打算做了,一个是因为后面只是部分gem的使用,还有版本控制还没找到合适的解决方案.

写在前面,关于AMS中JSONAPI的媒体类型

单独发过一个帖子,在使用AMS做JSONAPI的过程中,我不知是不是因为自己不会使用的关系,导致返回的媒体类型为application/json,而不是JSONAPI所注册过的vpn.api+json.所以暂时通过以下方法进行更改:

在initializer下:
api_mime_types = %W(
  application/vnd.api_json
  text/x-json
  applicaiton/json
)

Mime::Type.register 'applicaiton/vnd.api+json', :json, api_mime_types

即可将返回的媒体类型修改为正确的vpn.api+json,但是总感觉这种方法不是太合适.

首先建立一个项目:

rails new demo --api

所需Gem会逐渐列出

一.加入第一个API resource

配置路由

config/routes.rb
resources :users, only: [:index, :create, :show, :update, :destroy]

UsersController

生成控制器:rails g controller usesrs

app/controllers/users_controller.rb
class UsersController < BaseController
  def show
    @user = User.find(params[:id])
    render json: @user
  end

  def index
    render json: User.all
  end
end

ActiveModel::Serializer

安装gem:gem 'active_model_serializers'bundle install


共有3种输出格式(Adapter):

  • :attributes(default)
  • :json
  • :json_api

修改默认输出格式为json_api(按需更改)

也可在具体文件内单独指定Adapter类型(如: adapter: :json_api)

config/initializers/ams_adapter.rb(文件名任意,只要在initializers文件夹下即可)
ActiveModel::Serializer.config.adapter = :json_api

UserSerializer

生成Serializer:rails g serializer user

app/serializers/user_serializer.rb
class Api::V1::UserSerializer < ActiveModel::Serializer
  attributes :id,:name,:email,:activated,:admin
end

User模型和users表

rails g model User

db/migrate/xxxxxx_create_users.rb
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :email
      t.string :name
      t.datetime :activated
      t.boolean :admin, default: false
      t.timestamps null: false
    end
  end
end

数据迁移:rails db:migrate 种子数据:

db/seeds.rb
users = User.create([
  {
    email: 'test00@mail.com',
    name: 'test00',
    activated: DateTime.now,
    admin: false
  },
  {
    email: 'test01@mail.com',
    name: 'test01',
    activated: DateTime.now,
    admin: false
  },
  {
    email: 'test02@mail.com',
    name: 'test02',
    activated: DateTime.now,
    admin: false
  },
  {
    email: 'test03@mail.com',
    name: 'test03',
    activated: DateTime.now,
    admin: false
  },
  {
    email: 'test04@mail.com',
    name: 'test04',
    activated: DateTime.now,
    admin: false
  }])

创建种子数据:rails db:seed

测试一下(并不是专业的测试)

启动rails服务器:rails s 使用curl请求:curl -i http://localhost:3000/users/1.json 得到结果(3种Adapter为显示区别均已列出,后续部分只输出JSON_API)

#JSON_API
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"26bfbe5f8e1fb23b6069c3625394a924"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 75723ba3-d2e6-4099-9ffa-be3daccf673b
X-Runtime: 0.002995
Transfer-Encoding: chunked

{"data":{"id":"1","type":"users","attributes":{"name":"test00","email":"test00@mail.com","activated":"2016-07-20T05:20:11.904Z","admin":false}}}


#JSON
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"5b726a6e35cc53e1e21dab387723cf2d"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: ac41ba6b-269d-4d9e-9d10-1b276fcced47
X-Runtime: 0.126139
Transfer-Encoding: chunked

{"user":{"id":1,"name":"test00","email":"test00@mail.com","activated":"2016-07-20T05:20:11.904Z","admin":false}}


#attributes
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"f90830c5ced0658dc653b158c52ffb3d"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 3fc863d4-2b9f-497b-94dc-adb840af82e3
X-Runtime: 0.124103
Transfer-Encoding: chunked

{"id":1,"name":"test00","email":"test00@mail.com","activated":"2016-07-20T05:20:11.904Z","admin":false}

二.增加认证

给User模型增加authentication_token属性

rails g migration add_authentication_token_to_users

db/migrate/xxxxxx_add_authentication_token_to_users.rb
class AddAuthenticationTokenToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :authentication_token, :string
  end
end

rails db:migrate

生成authentication_token

app/model/user.rb
class User < ApplicationRecord
  before_create :generate_authentication_token
  has_secure_password

  def generate_authentication_token
    loop do
      self.authentication_token = SecureRandom.base64(64)
      break if !User.find_by(authentication_token: authentication_token)
    end
  end

  def reset_auth_token!
    generate_authentication_token
    save
  end
end

sessions endpoint

生成sessions控制器:rails g controller sessions

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def create
    @user = User.find_by(email: create_params[:email])
    if @user && @user.authenticate(create_params[:password])
      self.current_user = @user
      render json: current_user, serializer: SessionSerializer
      #必须显示指定serializer,不知原因,求解释
      #应该是根据对象类型自动匹配到user上了
      #也可以直接修改UserSerializer添加token字段
    else
      return api_error(status: 401)
    end
  end

  private
  def create_params
    params.require(:user).permit(:email,:password)
  end
end

给User模型增加password

安装Gem:gem 'bcrypt'bundle install

app/models/user.rb
class User < ApplicationRecord
  + has_secure_password
end

增加password_digest属性:rails g migration add_password_digest_to_users

db/migrate/xxxxxx_add_password_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :password_digest, :string
  end
end

rails db:migrate

给已存在的测试用户增加密码和token

rails c打开终端,执行:

User.all.each {|user|
  user.password = '123123'
  user.reset_auth_token!
  }

实现和current_user相关的方法

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  + attr_accessor :current_user
end

实现api_error方法

app/controllers/application_controller.rb
lass ApplicationController < ActionController::API
#原文使用render nothing: true方法
#运行时报warning说5.1要移除nothing了
#这里改为官方建议的head方法
  + def api_error(opts = {})
  + render head: :unauthorized, status: opts[:status]
  + end
end

SessionSerializer

生成Serializer:rails g serializer session

app/serializers/session_serializer.rb
class SessionSerializer < ActiveModel::Serializer
  attributes :id,:name,:admin,:token

  def token
    object.authentication_token
  end
end

如上文所说,可以不要这个Serializer,直接修改UserSerializer就可以

配置和session相关的路由

config/routes.rb
Rails.application.routes.draw do
  + resources :sessions, only: [:create]
end

又一个不正经的测试

使用正确的数据获取token

url -i -X POST -d "user[email]=test00@mail.com&user[password]=123123" http://localhost:3000/sessions 得到结果:

HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"b71c6480196ab554d31d298a293adc75"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 0e0507cf-81a1-4972-aaa0-764945bd820c
X-Runtime: 0.093730
Transfer-Encoding: chunked

{"data":{"id":"1","type":"users","attributes":{"name":"test00","admin":false,"token":"zKif6laUSlSJY4EDi3owNcJEFPKm87bvFKpXMLTROMRT2DZrbtsBMBWJalab6wS96a3ntR7yTar5Z3yVYEDeOg=="}}}

顺利得到了token


使用不正确的数据

curl -i -X POST -d "user[email]=test00@mail.com&user[password]=123" http://localhost:3000/sessions 密码改为错误的"123"

得到结果:

HTTP/1.1 401 Unauthorized
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/plain; charset=utf-8
Cache-Control: no-cache
X-Request-Id: 99856444-b644-43d4-99db-34c1a2afac4d
X-Runtime: 0.068964
Transfer-Encoding: chunked

我们得到了401 Unauthorized错误.

三.Authenticate User

使用token和email看能否识别出用户

首先实现authenticate_user!方法

app/controllers/application.rb
class ApplicationController < ActionController::API
+ def authenticate_user!
+   token, options = ActionController::HttpAuthentication::Token.token_and_options(request)

+    user_email = options.blank??nil:options[:email]
+    user = user_email && User.find_by(email: user_email)

+    if user && ActiveSupport::SecurityUtils.secure_compare(user.authentication_token,token)
+      self.current_user = user
+    else
+      return unauthenticated!
+    end
  end

然后实现update API,并加入before_action :authenticate_user!, only:[:update]

app/conctrollers/users_controller.rb
lass UsersController < ApplicationController
  before_action :authenticate_user!, only: [:update]

+  def update
+    @user = User.find(params[:id])
+    @user.update_attributes(update_params)
+    render json: @user
+  end

  private
  def update_params
    params.require(:user).permit(:name)
  end
end

再加入认证失败返回的方法

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
+ def unauthenticated!
+   api_error(status: 401)
+ end
end

不正经的测试

使用curl提交修改2号用户的用户名: curl -i -X PUT -d "user[name]=gg-user" --header "Authorization: Token token=4Vrayf/5g+720JRWQV4BQkk+uT5UeBmgR6LsacBNJfCwXWI3rgNknN97pyxsg8YWJIm0oNq4vKdqIhcatcHhsQ==, email=test01@mail.com" http://localhost:3000/users/2 得到结果:

HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"d6a64e0caf907e3c60b69c31c2a9d23d"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 054a8ccc-ac68-4cd6-8fb1-6ac33ee191f4
X-Runtime: 0.611735
Transfer-Encoding: chunked

{"data":{"id":"2","type":"users","attributes":{"name":"gg-user","email":"test01@mail.com","activated":"2016-07-20T05:20:11.905Z","admin":false,"authentication-token":"4Vrayf/5g+720JRWQV4BQkk+uT5UeBmgR6LsacBNJfCwXWI3rgNknN97pyxsg8YWJIm0oNq4vKdqIhcatcHhsQ=="}}}

可以看到用户名已经被更改了;再使用错误的token看看效果:

HTTP/1.1 401 Unauthorized
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/plain; charset=utf-8
Cache-Control: no-cache
X-Request-Id: ac4241f9-7125-4142-a281-3ba060061b9f
X-Runtime: 0.002108
Transfer-Encoding: chunked

返回401错误,如愿.

四.增加授权Authorization

使用pundit进行权限认证

安装Gem: gem 'pundit'bundle install

添加引用app/controllers/application.rb
class ApplicationController < ActionController::API
+ include Pundit
end

执行安装:rails g pundit:install 会生成一个app/policies/application_policy.rb文件

app/controllers/application.rb
class ApplicationController < ActionController::API
+ include Pundit
end

将policies目录放入rails自动加载路径中

config/application.rb
module ApiDemo
  class Application < Rails::Application
  +  config.autoload_paths << Rails.root.join('app/policies')
  end
end

创建和User相关的权限机制

app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy

  def show?
    return true
  end

  def create?
    return true
  end

  def update?
    return true if user.admin?
    return true if record.id == user.id
  end

  def destroy?
    return true if user.admin?
    return true if record.id == user.id
  end

  class Scope < ApplicationPolicy::Scope
    def resolve
      scope.all
    end
  end

end

使用UserPolicy

app/controllers/users_controller.rb
class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    #return api_error(status: 403) if !UserPolicy.new(current_user, @user).update?
    #或可使用此方法,无需捕获异常
    authorize @user, :update?
    @user.update_attributes(update_params)
    render json: @user
  end

end

捕获抛出的异常:

app/controllers/application_controller.rb
class ApplicationController < ActionController::API

+ rescue_from Pundit::NotAuthorizedError, with: :deny_access

+ def deny_access
+   api_error(status: 403)
+ end
end

测试依然不正经

使用user/1的数据去更新user/2的: curl -i -X PUT -d "user[name]=gg22-user" --header "Authorization: Token token=zKif6laUSlSJY4EDi3owNcJEFPKm87bvFKpXMLTROMRT2DZrbtsBMBWJalab6wS96a3ntR7yTar5Z3yVYEDeOg==, email=test00@mail.com" http://localhost:3000/users/2

得到结果:

HTTP/1.1 403 Forbidden
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/plain; charset=utf-8
Cache-Control: no-cache
X-Request-Id: 3263a340-13af-44ab-ad04-872a015f79fe
X-Runtime: 0.018907
Transfer-Encoding: chunked

返回403,如愿.

五.微博模型及分页

建立micropost模型

rails g model Micropost

db/migrate/xxx_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[5.0]
  def change
    create_table :microposts do |t|
      t.string :title
      t.text :content
      t.integer :user_id
      t.timestamps null:false
    end
  end
end

执行迁移:rails db:migrate

准备100条微博数据:

db/seeds.rb
+ 100.times do |i|
+   Micropost.create(user_id: 1, title: "title-#{i}", content: "content-#{i}")
+ end

rails db:seed

MicropostsController

生成控制器:rails g controller microposts 配置路由:

config/routes.rb
Rails.application.routes.draw do
+ scope path: '/user/:user_id' do
+   resources :microposts, only[:index]
+ end
end

定义index方法

app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController

  def index
    user = User.find(params[:user_id])
    #使用分页函数
    @microposts = paginate(user.microposts)
    #添加meta数据方法
    render json: @microposts, meta: paginate_meta(@microposts)
  end

end

分页

使用kaminair进行分页 安装Gem:gem 'kaminari'bundle install

实现paginate和meta数据方法

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
+ def paginate_meta(resource)
+   {
+     current_page: resource.current_page,
+     next_page: resource.next_page,
      prev_page: resource.prev_page,
+     total_pages: resource.total_pages,
+     total_count: resource.total_count
+   }
+ end
#对于新手(我)来说,关于参数的深坑...
#构造url的时候,参数之间请添加转义符连接,不要单独使用&
#不要问我怎么知道的...😂
+ def paginate(resource)
+   resource = resource.page(params[:page] || 1)
+   if params[:per_page]
+     resource = +resource.per(params[:per_page])
+   end
+   return resource
+ end
end

MicropostSerializer

生成Serializer:rails g serializer micropost

app/serializer/MicropostSerializer.rb
class MicropostSerializer < ActiveModel::Serializer
  attributes :id,:title,:content
end

添加关联

app/models/user.rb
class User < ApplicationRecord
+ has_many :microposts
end
app/model/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :users
end

测试依旧不正经,json数据经过在线工具添加缩进

curl -i -X GET http://localhost:3000/user/1/microposts?per_page=3 得到结果:

HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"c1d821a3d3a878d1d86b3819c8589f39"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 4f4a6d88-8041-4ac3-ad10-02a3ed4bf886
X-Runtime: 0.033613
Transfer-Encoding: chunked

{
  "data": [
    {
      "id": "1", 
      "type": "microposts", 
      "attributes": {
        "title": "title-0", 
        "content": "content-0"
      }
    }, 
    {
      "id": "2", 
      "type": "microposts", 
      "attributes": {
        "title": "title-1", 
        "content": "content-1"
      }
    }, 
    {
      "id": "3", 
      "type": "microposts", 
      "attributes": {
        "title": "title-2", 
        "content": "content-2"
      }
    }
  ], 
  "links": {
    "self": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=1&page%5Bsize%5D=3&per_page=3", 
    "next": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=2&page%5Bsize%5D=3&per_page=3", 
    "last": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=34&page%5Bsize%5D=3&per_page=3"
  }, 
  "meta": {
    "current-page": 1, 
    "next-page": 2, 
    "prev-page": null, 
    "total-pages": 34, 
    "total-count": 100
  }
}

curl -i -X GET http://localhost:3000/user/1/microposts?per_page=3\&page=8 得到结果:

HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"27662403e9ec3ef2fca76266b97cbdb6"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 1f17e152-b4a7-4176-aec9-915a689bab10
X-Runtime: 0.006062
Transfer-Encoding: chunked

{
  "data": [
    {
      "id": "22", 
      "type": "microposts", 
      "attributes": {
        "title": "title-21", 
        "content": "content-21"
      }
    }, 
    {
      "id": "23", 
      "type": "microposts", 
      "attributes": {
        "title": "title-22", 
        "content": "content-22"
      }
    }, 
    {
      "id": "24", 
      "type": "microposts", 
      "attributes": {
        "title": "title-23", 
        "content": "content-23"
      }
    }
  ], 
  "links": {
    "self": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=8&page%5Bsize%5D=3&per_page=3", 
    "first": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=1&page%5Bsize%5D=3&per_page=3", 
    "prev": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=7&page%5Bsize%5D=3&per_page=3", 
    "next": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=9&page%5Bsize%5D=3&per_page=3", 
    "last": "http://localhost:3000/user/1/microposts?page%5Bnumber%5D=34&page%5Bsize%5D=3&per_page=3"
  }, 
  "meta": {
    "current-page": 8, 
    "next-page": 9, 
    "prev-page": 7, 
    "total-pages": 34, 
    "total-count": 100
  }
}

如果使用JSON_API,会自动添加links

切记

curl里的连接,如果需要传递多个参数,参数之间请使用\&,不要单独使用&

现有问题

  • 没搞清为啥原文可以用BaseController...或者说原文忘加include了?
  • 为什么curl里的地址还需要使用转义符
  • AMS的返回的媒体类型问题该如何正确的解决?
共收到 20 条回复

#1楼 @stargwq 昨天发了一遍,晚上回去一看是错的...刚又看了遍,好像还是错的...改好了...

#必须显示指定serializer,不知原因,求解释
#应该是根据对象类型自动匹配到user上了

是的 active_model_serializers 会找模型对应的serializer 如果不指定的话

#3楼 @jicheng1014 谢谢回复.看到你的头像感觉有救了...前两天翻帖子知道你们前端是用react做的,当时还想问一下前后端是怎么结合在一起的.

#4楼 @smartepsh 你问的3L情况我不知道。 但是我们公司现在是前后端分离的。 后端只提供api

#4楼 @smartepsh 完全独立的项目

前端完全是js 项目 后端完全是rails api

#5楼 @hging 对,我也是想前后端彻底分离,现在的问题就是我是新手直接切入的rails5的API-only,感觉没摸清体系结构,好多rails里的方法不能引用.官方guide只有一篇,其他的资料感觉也好少,感觉有点难下手.我们是第一次做,感觉好纠结.

#6楼 @jicheng1014 是的,之前翻api帖子的时候,看到过你的回复,对头像印象很深😂 能不能给点文档什么的参考一下,我就看了active_model_serializer的文档和官方的关于api的一篇guide,其他的实在是难找...

@kayakjiang @jicheng1014 你好,又有一个问题想问下,原文中分V1、V2两个版本的API,是通过不同的view渲染的。

如果我使用rails来构建API,通过serializer渲染,如何分进行版本区分?

我尝试过,Serializer通过对象类型自动调用对应的渲染模板,也就是说如果model不变,那输出应该也是不变的。是不是如果要进行版本区分,就需要显示指定Serializer呢?

你如果用的0.8 版本的ActiveModelSerializer,有gem https://github.com/skalee/active_model_serializers-namespaces

你如果用的0.8 以上版本的,没做,但是有解决思路 用 defined? 稍微封装下你的applicationController 检测是否存在带版本的serializer 如果有就用,没有就用默认的,

例如 访问 V1::UsersController, V1::UserSerializer 没有 就用UserSerializer

缺陷是访问会变得慢一点

#9楼 @smartepsh 你自己弄下嘛

#11楼 @kayakjiang 哈哈,算是弄了一会了,github上我看issue列表里关于这个的问题不算少,但是没有官方的方案.

#10楼 @jicheng1014 谢谢回复,我试试看.

👍 不错, 想的挺仔细的.

#14楼 @flypiggys 谢谢,只是"临摹"而已.

Ruby 2.4.1 Rails 5.1.1 跟着原来的帖子:《使用Rails构建API实践》敲命令的时候,用rails 替代 bundle exe rails ,可以实现。 但是照着你的步骤实现反而不行

MiracleWong 回复

具体是在哪里出错呢?应该就是一些命令的小改动吧。

smartepsh 回复

我也不是很清楚,按照Rails 使用 Rails 构建 API 实践:https://ruby-china.org/topics/25822 的是可以走通的,很新的新手,什么都不太懂,另外在搞分页,你使用kaminari的方式和Github:https://github.com/kaminari/kaminari 上很不一样

smartepsh 回复

@smartepsh @MiracleWong 请教下, 直接rails 和 bundle exe rails 有什么区别么? 一个是执行系统的rails,另一个是执行本项目bin/ 下的rails? 是这个区别?

你的controller没有继承BaseController,所有不能使用

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册