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'