12/08/2018, 14:13

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 ...

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.rb và test.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 new và edit 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_at và description 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:API và ActiveModel::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 rating và category để 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 id và full_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 document và errors 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

Và 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