0

Tạo JSON API với Rails 5

So với các phiên bản trước của Rails, Rails 5 đã được tích hợp gem rails-api. Điều này giúp cho việc tạo API trong Rails dễ dàng hơn so với trước đây. Bài viết này sẽ hướng dẫn cách tạo JSON API với Rails 5 bằng tùy chọn --api mới. Ngoài ra, tôi sẽ demo chức năng xác thực bằng một số tính năng mới trong Rails 5. Mã nguồn bài viết trên GitHub.

Rails 5 API

Khi tạo ứng dụng mới trong Rails 5, sẽ có thêm tùy chọn cờ --api. Nó sẽ tạo ra ứng dụng Rails nhẹ hơn, chỉ phục vụ cho dữ liệu API. Trước đây, cần dùng gem rails-api để làm điều này.

JSON:API

JSON:API sẽ định nghĩa cách server trả về dữ liệu dạng JSON, cách client nhận dữ liệu, thực hiện lọc, sắp xếp, phân trang, xử lý lỗi, mối quan hệ giữa các dữ liệu, mã trạng thái HTTP trả về và những thứ khác. Các thông số kỹ thuật này được định nghĩa trong JSON format chuẩn của Rails 5, tuy nhiên bạn có thể tùy biến nó.

Ứng dụng

Để show được hết các tính năng được mô tả bên trên, tôi sẽ tạo một ứng dụng dạng "blog", cho phép người dùng có thể đăng bài viết. Để đơn giản, sẽ chỉ có chức năng tạo, xác thực người dùng và đăng bài, không có đổi mật khẩu, phân quyền hay comment cho từng bài.

Cài đặt Rails 5 và tạo ứng dụng API

Để sử dụng được Rails 5, bạn phải dùng phiên bản Ruby 2.2.2 trở lên.

$ gem install rails
$ rails new rails5_json_api_demo --api

Các tùy chọn khác bạn có thể xem chi tiết với lệnh rails -h. Ví dụ, sử dụng -C để bỏ qua ActionCable (web sockets), -M để bỏ qua ActionMailer, ...

Sử dụng CORS

CORS là cơ chế cho phép hạn chế nguồn tài nguyên trên một trang web từ những domain bên ngoài nguồn tài nguyên chuẩn.

gem rack-cors
$ bundle install

Để sử dụng CORS, cần tùy chỉnh file config/initializers/cors.rb (Tham khảo rack-cors GitHub repo). Ứng dụng này sử dụng tùy chọn mặc định. Tôi sẽ bỏ example.com và thay thế bằng *, trong trường hợp này không cần quan tâm request đến từ đâu.

Có thể dùng JSON-P thay thế cho CORS, tuy nhiên tôi sẽ không dùng nó ở đây. CORS được xác nhận bởi Rails và được W3C đề nghị, và tôi muốn sử dụng Rails mặc định. Ngoài ra, JSON-P chỉ hỗ trợ phương thức GET, không đủ cho ứng dụng này.

Serialization

Rails có cung cấp JSON serialization, tuy nhiên tôi sẽ dùng gem active_model_serializers. Gem này cung cấp JsonApi Adapter, sẽ tiết kiệm rất nhiều thời gian.

gem 'active_model_serializers', '~> 0.10.0'

Chạy bundle install, sau đó tạo file config/initializers/active_model_serializers.rb và thêm nội dung sau:

ActiveModel::Serializer.config.adapter = :json_api

Trong thư mục config/environments/, thêm config sau vào 2 file development.rbtest.rb:

Rails.application.routes.default_url_options = {
  host: 'localhost',
  port: 3000
}

Tạo User

Xác thực là tính năng quan trong khi tạo user. Trong ứng dụng này, user chỉ có quyền tạo hoặc chỉnh sửa bài viết. Tôi sẽ sử dụng phương thức has_secure_token trong Rails 5. Bạn có thể dùng gem Devise hoặc JWT để thực hiện xác thực. Tuy nhiên, tôi cần sự đơn giản và cũng đang muốn tìm hiểu phương thức has_secure_token mới này.

Bắt đầu migrate dữ liệu, chạy lệnh sau :

$ rails g migration CreateUsers

File được tạo ra có dạng như sau:

class CreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      t.timestamps
      t.string     :full_name
      t.string     :password_digest
      t.string     :token
      t.text       :description
    end
    add_index :users, :token, unique: true
  end
end

Một thay đổi trong Rails 5 với việc migrate dữ liệu. Bạn sẽ migrate với lệnh rails chứ không phải với rake như trước nữa. Đơn giản là để tạo bảng trong cơ sở dữ liệu, chạy lệnh rails db:migrate. Tạo model app/models/user.rb:

class User < ApplicationRecord
  has_secure_token
  has_secure_password

  validates :full_name, presence: true
end

Thêm route:

Rails.application.routes.draw do
  resources :users
end

Khi thêm resource vào route trong ứng dụng Rails sử dụng cờ --api, route cho newedit không được tạo ra. Đây chính là điều chúng ta cần.

Tiếp theo, tạo serializer cho model. Tạo file app/serializers/user_serializer.rb:

class UserSerializer < ActiveModel::Serializer
  attributes :id, :full_name, :description, :created_at
end

Chỉ cần những thông tin id, full_name, created_atdescription là đủ. Bước cuối là tạo users controller. Tạo file app/controllers/users_controller.rb:

class UsersController < ApplicationController
  def index
    users = User.all
    render json: users
  end
end

Như bạn thấy, ActiveModel::Serializers được tích hợp đầy đủ trong Rails controller. Tạo 1 user mới trong Rails console (rails c):

User.create(full_name: "Sasa J", password: "Test")

Khởi động server (rails s) và chạy http://localhost:3000/users trên trình duyệt, bạn sẽ thấy:

{"data":[
  {"id":"1",
    "type":"users",
    "attributes":{
      "full-name":"Sasa J",
      "description":null,
      "created-at":"2016-06-16T09:55:37.856Z"
    }
  }
]}

Đây chính là dữ liệu đầu ra chúng ta cần. Không những vậy, gem active_model_serializers đã chuẩn hóa dữ liệu bằng cách thêm type, tách id và những dữ liệu còn lại, nó cũng thay thế gạch dưới _ bằng gạch ngang - theo chuẩn dữ liệu JSON.

Loại phương tiện

Mở công cụ phát triển trong trình duyệt web của bạn, kiểm tra Content-Type phản hồi từ máy chủ, bạn sẽ thấy application/json. JSON:API yêu cầu sử dụng application/vnd.api+json. Để thay đổi, mở config/initializers/mime_types.rb và thêm đoạn code sau:

Mime::Type.register "application/vnd.api+json", :json

Đã giải quyết được vấn đề. Nếu dùng Firefox, bạn có thể sẽ nhận được hộp thoại nhắc tải về file users. Để khắc phục, bạn thêm loại phương tiện này vào Firefox hoặc đơn giản là sử dụng trình duyệt hiểu được application/vnd.api+json như Chrome. Tốt nhất, bạn nên dùng extension cho trình duyệt (Postman cho Chrome hoặc RESTED cho Firefox).

Tạo post

$ rails g migration CreatePosts
class CreatePosts < ActiveRecord::Migration[5.0]
  def change
    create_table :posts do |t|
      t.timestamps
      t.string     :title
      t.text       :content
      t.integer    :user_id
      t.string     :category
      t.integer    :rating
    end
  end
end

Chạy rails db:migrate và tạo model app/models/post.rb:

class Post < ApplicationRecord
  belongs_to :user
end

Đừng quên thêm has_many :posts, dependent: :destroy vào user.rb model! Thêm resources :posts vào file config/routes.rb. Tiếp đến, tạo serializer cho post. Tạo file app/serializers/post_serializer.rb:

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :content, :category,
             :rating, :created_at, :updated_at
  belongs_to :user
end

ActiveModel::Serializer dùng chung cách để mô tả mối quan hệ giữa các dữ liệu như ActiveRecord, và nhớ thêm has_many :posts vào UserSerializer!

Nếu bạn tạo dữ liệu cho post và check lại /user/ URL:

{"data":[{
  "id":"1",
  "type":"users",
    "attributes":{
      "full-name":"Sasa J",
      "description":null,
      "created-at":"2016-06-16T09:55:37.856Z"
    },
    "relationships":{
      "posts":{
        "data":[{
          "id":"1",
          "type":"posts"
        }]
      }
    }
  }]
}

Như bạn thấy, quan hệ giữa các dữ liệu là 1 phần của JSON:APIActiveModel::Serializer làm điều này rất tốt.

Liên kết dữ liệu JSON

JSON:API cho phép links đến các JSON object. Phía client có thể dùng để lấy nhiều dữ liệu hơn:

class UserSerializer < ActiveModel::Serializer
  attributes :id, :full_name, :description, :created_at
  has_many :posts
  link(:self) { user_url(object) }
end

Check /users/ URL, bạn sẽ thấy links block.

Tạo dữ liệu

Thông thường, phải viết test trước, tuy nhiên để giải thích một số khái niệm của Rails 5 và JSON:API trước khi đi vào TDD, tôi sẽ làm cho mọi thứ hoạt động trước khi test. Tôi thích sử dụng Minitest hơn RSpec, nó nhanh và đơn giản. Tạo dữ liệu:

# users.yml
<% 6.times do |i| %>
user_<%= i %>:
  full_name: <%= "User Nr#{i}" %>
  password_digest: <%= BCrypt::Password.create('password') %>
  token: <%= SecureRandom.base58(24) %>
<% end %>
# posts.yml
<% 6.times do |i| %>
<% 25.times do |n| %>
article_<%= i %>_<%= n %>:
  title: <%= "Example title #{i}/#{n}" %>
  content: <%= "Example content #{i}/#{n}" %>
  user: <%= "user_#{i}" %>
  rating: <%= 1 + i + rand(3) %>
  category: <%= i == 0 ? 'First' : 'Example' %>
<% end %>
<% end %>

Ta sẽ có 6 user và 150 post, mỗi user sẽ có 25 post. Tôi thêm biến ratingcategory để test chức năng lọc và phân loại.

Thêm test cho action index và show trong users controller

Thêm code vào test/controllers/users_controller_test.rb::

require 'test_helper'
require 'json'

class UsersControllerTest < ActionController::TestCase

  test "Should get valid list of users" do
    get :index
    assert_response :success
    assert_equal response.content_type, 'application/vnd.api+json'
    jdata = JSON.parse response.body
    assert_equal 6, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'users'
  end

  test "Should get valid user data" do
    user = users('user_1')
    get :show, params: { id: user.id }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal user.id.to_s, jdata['data']['id']
    assert_equal user.full_name, jdata['data']['attributes']['full-name']
    assert_equal user_url(user, { host: "localhost", port: 3000 }),
                 jdata['data']['links']['self']
  end

  test "Should get JSON:API error block when requesting user data with invalid ID" do
    get :show, params: { id: "z" }
    assert_response 404
    jdata = JSON.parse response.body
    assert_equal "Wrong ID provided", jdata['errors'][0]['detail']
    assert_equal '/data/attributes/id', jdata['errors'][0]['source']['pointer']
  end
end

Điều quan trọng nhất khi test là phải biết được cần test gì:

  • Header Content-Type được set chung cho tất cả các trả về nên chỉ cần kiểm tra 1 lần là đủ.
  • Khi lấy danh sách thì chỉ cần quan tâm đến số lượng user mà không cần để ý đến chi tiết thông tin từng user.
  • Khi lấy 1 user thì quan tâm đến idfull_name, nếu nó đúng, phần còn lại đương nhiên đúng.
  • Link là phần được thêm vào nên tất nhiên cần test nó. default_url_options trong config/environments/test.rb làm việc với HTTP request mà không phải URL. Vì vậy, cần fix cứng host và port trong phần test. Trong user controller:
class UsersController < ApplicationController
  before_action :set_user, only: [:show, :update, :destroy]

  def index
    users = User.all
    render json: users
  end

  def show
    render json: @user
  end

  private
  def set_user
    begin
      @user = User.find params[:id]
    rescue ActiveRecord::RecordNotFound
      user = User.new
      user.errors.add(:id, "Wrong ID provided")
      render_error(user, 404) and return
    end
  end
end

Render lỗi trong ApplicationController:

class ApplicationController < ActionController::API

  private
  def render_error(resource, status)
    render json: resource, status: status, adapter: :json_api,
           serializer: ActiveModel::Serializer::ErrorSerializer
  end
end

Chi tiết về error xem thêm tại active_model_serializer JSON:API errors documenterrors part of JSON:API spec.

Tạo user

  • Trước khi tạo hay sửa user, cần xác thực. Như đã nói bên trên, tôi dùng token xác thực.
  • Khi gửi dữ liệu JSON đến server, cần set Content-Type header. Nếu dùng HTTP mà không gửi bất kỳ dữ liệu JSON nào (GET, DELETE) thì không cần set Content-Type header.
  • type bắt buộc phải có trong dữ liệu JSON.
test "Creating new user without sending correct content-type should result in error" do
    post :create, params: {}
    assert_response 406
  end

  test "Creating new user without sending X-Api-Key should result in error" do
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    post :create, params: {}
    assert_response 403
  end

  test "Creating new user with incorrect X-Api-Key should result in error" do
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = '0000'
    post :create, params: {}
    assert_response 403
  end

  test "Creating new user with invalid type in JSON data should result in error" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    post :create, params: { data: { type: 'posts' }}
    assert_response 409
  end

  test "Creating new user with invalid data should result in error" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    post :create, params: {
                    data: {
                      type: 'users',
                      attributes: {
                        full_name: nil,
                        password: nil,
                        password_confirmation: nil }}}
    assert_response 422
    jdata = JSON.parse response.body
    pointers = jdata['errors'].collect { |e|
      e['source']['pointer'].split('/').last
    }.sort
    assert_equal ['full-name','password'], pointers
  end

  test "Creating new user with valid data should create new user" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    post :create, params: {
                    data: {
                      type: 'users',
                      attributes: {
                        full_name: 'User Number7',
                        password: 'password',
                        password_confirmation: 'password' }}}
    assert_response 201
    jdata = JSON.parse response.body
    assert_equal 'User Number7',
                 jdata['data']['attributes']['full-name']
  end

Thêm vào UsersController:

	before_action :validate_user, only: [:create, :update, :destroy]
  before_action :validate_type, only: [:create, :update]

  def create
    user = User.new(user_params)
    if user.save
      render json: user, status: :created
    else
      render_error(user, :unprocessable_entity)
    end
  end

  private

  def user_params
    ActiveModelSerializers::Deserialization.jsonapi_parse(params)
  end
end

Trong ApplicationController:

class ApplicationController < ActionController::API
  before_action :check_header

  private
  def check_header
    if ['POST','PUT','PATCH'].include? request.method
      if request.content_type != "application/vnd.api+json"
        head 406 and return
      end
    end
  end

  def validate_type
    if params['data'] && params['data']['type']
      if params['data']['type'] == params[:controller]
        return true
      end
    end
    head 409 and return
  end

  def validate_user
    token = request.headers["X-Api-Key"]
    head 403 and return unless token
    user = User.find_by token: token
    head 403 and return unless user
  end

  def render_error(resource, status)
    render json: resource, status: status, adapter: :json_api, serializer: ActiveModel::Serializer::ErrorSerializer
  end
end

Sửa user

Tương tự như khi tạo user, chỉ cần thêm test khi cập nhật dữ liệu thành công:

  test "Updating an existing user with valid data should update that user" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    patch :update, params: {
                     id: user.id,
                     data: {
                       id: user.id,
                       type: 'users',
                       attributes: { full_name: 'User Number1a' }}}
    assert_response 200
    jdata = JSON.parse response.body
    assert_equal 'User Number1a', jdata['data']['attributes']['full-name']
  end

Trong UsersController:

  def update
    if @user.update_attributes(user_params)
      render json: @user, status: :ok
    else
      render_error(@user, :unprocessable_entity)
    end
  end

Xóa user

Đơn giản, chỉ cần xóa dữ liệu và trả ra trạng thái 204 'Không có dữ liệu':

  test "Should delete user" do
    user = users('user_1')
    ucount = User.count - 1
    @request.headers["X-Api-Key"] = user.token
    delete :destroy, params: { id: users('user_5').id }
    assert_response 204
    assert_equal ucount, User.count
  end

destroy action trong UsersController:

  def destroy
    @user.destroy
    head 204
  end

Post

Tương tự cách làm như với user, trong phần post này tôi chú trọng đến phần index vì đây là nơi thích hợp để thực hiện chức năng lọc, sắp xếp. Trong test/controllers/posts_controller_test.rb:

require 'test_helper'
require 'json'

class PostsControllerTest < ActionController::TestCase

  test "Should get valid list of posts" do
    get :index
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.count, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'posts'
  end
end

app/controllers/posts_controller.rb:

class PostsController < ApplicationController

  def index
    posts = Post.all
    render json: posts
  end
end

Phân trang

Có thể sử dụng gem kaminari hoặc will_paginate để phân trang. Tôi dùng will_paginate. Thêm gem "will_paginate" trong Gemfile và chạy bundle install. Sửa lại Post model:

class Post < ApplicationRecord
  belongs_to :user
  self.per_page = 50
end

Sau đó thêm phân trang vào PostsController:

  def index
    posts = Post.page(params[:page] ? params[:page][:number] : 1)
    render json: posts
  end

Khi đó sẽ có lỗi:

Failure:
PostsControllerTest#test_Should_get_valid_list_of_posts [<filename-removed>:10]:
Expected: 150
  Actual: 50

Sửa lại file test:

  test "Should get valid list of posts" do
    get :index, params: { page: { number: 2 } }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.per_page, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'posts'
  end

Khi phân trang thì có thêm các chức năng như first, prev, nextlast:

  test "Should get valid list of posts" do
    get :index, params: { page: { number: 2 } }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.per_page, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'posts'
    l = jdata['links']
    assert_equal l['first'], l['prev']
    assert_equal l['last'], l['next']
  end

Tôi dùng tham số page và JSON:API không quy định bắt buộc cho việc đặt tên này. Tuy nhiên nó dễ hiểu khi sử dụng page[number]page[size]. Ngoài ra, cần thêm đối tượng meta để thêm các thông tin về phân trang như tổng số trang, số bản ghi:

class PostsController < ApplicationController
  def index
    posts = Post.page(params[:page] ? params[:page][:number] : 1)
    render json: posts, meta: pagination_meta(posts)
  end

  private
  def pagination_meta(object)
    {
      current_page: object.current_page,
      next_page: object.next_page,
      prev_page: object.previous_page,
      total_pages: object.total_pages,
      total_count: object.total_entries
    }
  end
end

Và trong file test, thêm assert:

assert_equal Post.count, jdata['meta']['total-count']

Thêm meta key

Có thể thêm nhiều thông tin hơn cho JSON message sử dụng meta key. Ví dụ như phiên bản API, ngày cập nhật cuối cùng, thông tin bản quyền ... Thêm vào ApplicationController:

  def default_meta
    {
      licence: 'CC-0',
      authors: ['Saša']
    }
  end

Khi sử dụng, thay render json: object bằng render json: object, meta: default_meta. Khi phân trang có thể thêm pagination_meta(posts).merge(default_meta).

Thêm tài nguyên

Đôi khi bạn cần thêm thông tin vào dữ liệu JSON trả ra, ví dụ khi lấy dữ liệu về post bạn cần thêm thông tin về tác giả. Thay vì phải thêm mới dữ liệu, có thể dùng include:

render json: posts, meta: pagination_meta(posts), include: ['user']

Nó sẽ thêm đối tượng included trong JSON, sau đối tượng data.

Sắp xếp

Để sử dụng chức năng sắp xếp với JSON:API, cần dùng tham số sort. Có thể sắp xếp cùng lúc nhiều trường. Mặc định luôn là sắp xếp tăng dần, trừ khi có thêm tùy chọn cụ thể, sử dụng -. Ví dụ, /posts?sort=-rating trả về danh sách bản ghi có lượng rating từ cao xuống thấp. Trong file test:

  test "Should get properly sorted list" do
    post = Post.order('rating DESC').first
    get :index, params: { sort: '-rating' }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal post.title, jdata['data'][0]['attributes']['title']
  end

Trong file controller:

  def index
    posts = Post.all
    if params['sort']
      f = params['sort'].split(',').first
      field = f[0] == '-' ? f[1..-1] : f
      order = f[0] == '-' ? 'DESC' : 'ASC'
      if Post.new.has_attribute?(field)
        posts = posts.order("#{field} #{order}")
      end
    end
    posts = posts.page(params[:page] ? params[:page][:number] : 1)
    render json: posts, meta: pagination_meta(posts), include: ['user']
  end

Lọc

  test "Should get filtered list" do
    get :index, params: { filter: 'First' }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.where(category: 'First').count, jdata['data'].length
  end

Trong PostsController:

  def index
    posts = Post.all
    if params[:filter]
      posts = posts.where(["category = ?", params[:filter]])
    end
    if params['sort']
      f = params['sort'].split(',').first
      field = f[0] == '-' ? f[1..-1] : f
      order = f[0] == '-' ? 'DESC' : 'ASC'
      if Post.new.has_attribute?(field)
        posts = posts.order("#{field} #{order}")
      end
    end
    posts = posts.page(params[:page] ? params[:page][:number] : 1)
    render json: posts, meta: pagination_meta(posts), include: ['user']
  end

Đăng nhập, đăng xuất

Khi cần tạo hay thay đổi tài nguyên, sử dụng token X-Api-Key. Nhưng làm thế nào để Client/SPA nhận biết được nên làm điều gì trước, cần thêm session: test/controllers/sessions_routes_test.rb:

require 'test_helper'

class SessionsRoutesTest < ActionController::TestCase
  test "should route to create session" do
    assert_routing({ method: 'post', path: '/sessions' },
                   { controller: "sessions", action: "create" })
  end
  test "should route to delete session" do
    assert_routing({ method: 'delete', path: '/sessions/something'},
                   { controller: "sessions", action: "destroy", id: "something" })
  end
end

Trong config/routes.rb:

  post    'sessions'     => 'sessions#create'
  delete  'sessions/:id' => 'sessions#destroy'

app/serializers/session_serializer.rb:

class SessionSerializer < ActiveModel::Serializer
  attributes :id, :full_name, :token
end

test/controllers/sessions_controller_test.rb:

require 'test_helper'
require 'json'

class SessionsControllerTest < ActionController::TestCase

  test "Creating new session with valid data should create new session" do
    user = users('user_0')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    post :create, params: {
                    data: {
                      type: 'sessions',
                      attributes: {
                        full_name: user.full_name,
                        password: 'password' }}}
    assert_response 201
    jdata = JSON.parse response.body
    refute_equal user.token, jdata['data']['attributes']['token']
  end

  test "Should delete session" do
    user = users('user_0')
    delete :destroy, params: { id: user.token }
    assert_response 204
  end
end

app/controllers/sessions_controller.rb:

class SessionsController < ApplicationController
  def create
    data = ActiveModelSerializers::Deserialization.jsonapi_parse(params)
    Rails.logger.error params.to_yaml
    user = User.where(full_name: data[:full_name]).first
    head 406 and return unless user
    if user.authenticate(data[:password])
      user.regenerate_token
      render json: user, status: :created, meta: default_meta,
             serializer: ActiveModel::Serializer::SessionSerializer and return
    end
    head 403
  end

  def destroy
    user = User.where(token: params[:id]).first
    head 404 and return unless user
    user.regenerate_token
    head 204
  end
end
  • Sử dụng SessionSerializer để tuần tự khi đăng nhập.
  • Để đảm bảo an toàn mỗi khi đăng nhập đăng xuất, tôi reset token. Đây là 1 phần của Rails 5.
  • Khi dùng render json: user, client sẽ lấy được JSON tuần tự từ UserSerializer. Đó là lý do tôi sử dụng SessionSerializer.
  • Có 1 class trong Rails có tên là SessionSerializer nên có vẻ như sử dụng tên không hợp lý. Tuy nhiên, Session middleware đã bị vô hiệu hóa khi sử dụng --api.

Hiệu lực

Để bảo mật hơn, nên sử dụng timeouts cho mỗi phiên hoạt động. Nếu không truy cập vào bất kỳ API nào trên server thì sẽ tự động đăng xuất. Ngoài ra sẽ dùng khóa meta để thông báo tình trạng đăng nhập cho client. Đầu tiên là thay đổi updated_at:

<% 6.times do |i| %>
user_<%= i %>:
  full_name: <%= "User Nr#{i}" %>
  password_digest: <%= BCrypt::Password.create('password') %>
  token: <%= SecureRandom.base58(24) %>
  updated_at: <%= i == 5 ? 1.hour.ago : Time.now %>
<% end %>

Thay đổi ApplicationController:

  before_action :validate_login

  def validate_login
    token = request.headers["X-Api-Key"]
    return unless token
    user = User.find_by token: token
    return unless user
    if 15.minutes.ago < user.updated_at
      user.touch
      @current_user = user
    end
  end

  def validate_user
    head 403 and return unless @current_user
  end

  def default_meta
    {
      licence: 'CC-0',
      authors: ['Saša'],
      logged_in: (@current_user ? true : false)
    }
  end
  • before_action luôn được gọi trước mỗi yêu cầu và sẽ check hoạt động trong 15 phút gần nhất của user.
  • Đặt biến toàn cục @current_user.
  • validate_user được thay đổi liên tục.
  • default_meta mô tả thông tin đăng nhập. Trong file test:
require 'test_helper'
require 'json'

class SessionFlowTestTest < ActionDispatch::IntegrationTest
  test "login timeout and meta/logged-in key test" do
    user = users('user_5')
    # Not logged in, because of timeout
    get '/users', params: nil,
        headers: { 'X-Api-Key' => user.token }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal false, jdata['meta']['logged-in']
    # Log in
    post '/sessions',
         params: {
           data: {
             type: 'sessions',
             attributes: {
               full_name: user.full_name,
               password: 'password' }}}.to_json,
         headers: { 'Content-Type' => 'application/vnd.api+json' }
    assert_response 201
    jdata = JSON.parse response.body
    token = jdata['data']['attributes']['token']
    refute_equal user.token, token
    # Logged in
    get '/users', params: nil,
        headers: { 'X-Api-Key' => token }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal true, jdata['meta']['logged-in']
  end
end

Kết luận

Tùy chọn --api thực sự đã tạo ra 1 ứng dụng nhẹ với nhiều chức năng. Có chút đặc biệt khi sử dụng --api là đầu tiên Rails sẽ tạo ứng dụng đầy đủ, sau đó loại bỏ các tập tin không cần thiết để còn lại ứng dụng API duy nhất. Thậm chí có thể xóa nhiều tập tin hơn nếu không sử dụng ActionCable (-C). Rails mặc định sử dụng dạng JSON mặc định của nó, không có gì là áp đặt, nhưng tôi thích nhiều thứ hơn là dạng chuẩn đó với các thông tin rõ ràng hơn. JSON:API là lựa chọn tốt nhất, khi đó gem active_model_serializers thực sự hữu dụng. Mã nguồn ứng dụng hiện có trên GitHub.

Nguồn: Creating Rails 5 API only application following JSON:API specification.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí