Tạo JSON API với Rails 5
Bài đăng này đã không được cập nhật trong 8 năm
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
idvàfull_name, nếu nó đúng, phần còn lại đương nhiên đúng. Linklà phần được thêm vào nên tất nhiên cần test nó.default_url_optionstrongconfig/environments/test.rblà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-Typeheader. 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 setContent-Typeheader. typebắ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]['type'], 'posts'
end
Khi phân trang thì có thêm các chức năng như first, prev, next và last:
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] và 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
Và 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ụngSessionSerializer. - Có 1 class trong Rails có tên là
SessionSerializernê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_actionluô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_metamô 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