Ngày nay việc dựa nhiều vào API (application programming interfaces) là thực tế phổ biến. Không chỉ các dịch vụ lớn như Facebook, Twitter sử dụng chúng—API rất phổ biến do sự lan rộng của các framework client-side như React, Angular và nhiều khác. Ruby on Rails theo xu hướng này, phiên bản mới nhất trình bày tính năng mới cho phép tạo ứng dụng API-only.
Ban đầu chức năng này được đóng gói trong gem riêng tên rails-api, nhưng kể từ Rails 5, nó đã trở thành phần lõi của framework. Tính năng này cùng với ActionCable có lẽ là những thứ được mong đợi nhất, nên hôm nay chúng ta sẽ thảo luận về nó.
Bài viết bao gồm cách tạo ứng dụng Rails API-only, cấu trúc routes và controllers, phản hồi JSON, thêm serializers, thiết lập CORS (Cross-Origin Resource Sharing). Bạn cũng sẽ học về các tùy chọn bảo mật API và bảo vệ khỏi lạm dụng.
Mã nguồn bài viết có sẵn tại GitHub.
Tạo Ứng Dụng API-Only
Để bắt đầu, chạy lệnh sau:
rails new RailsApiDemo --api
Sẽ tạo ứng dụng Rails API-only tên RailsApiDemo. Nhớ rằng hỗ trợ option --api chỉ thêm từ Rails 5, nên đảm bảo bạn cài phiên bản này hoặc mới hơn.
Mở Gemfile và thấy nó nhỏ gọn hơn bình thường: các gem như coffee-rails, turbolinks, sass-rails đã biến mất.
File config/application.rb chứa dòng mới:
config.api_only = true
Nghĩa là Rails sẽ load tập middleware nhỏ hơn: ví dụ không có hỗ trợ cookies và sessions. Hơn nữa, nếu bạn generate scaffold, view và assets không được tạo. Thực tế, nếu kiểm tra thư mục views/layouts, file application.html.erb cũng mất.
Khác biệt quan trọng khác: ApplicationController kế thừa từ ActionController::API, không phải ActionController::Base.
Đó là cơ bản—tóm lại đây là ứng dụng Rails cơ bản bạn đã thấy nhiều lần. Bây giờ thêm vài model để có dữ liệu làm việc:
rails g model User name:string rails g model Post title:string body:text user:belongs_to rails db:migrate
Không phức tạp: bài viết có title, body thuộc về user.
Đảm bảo associations đúng và thêm validation đơn giản:
models/user.rb
has_many :posts validates :name, presence: true
models/post.rb
belongs_to :user validates :title, presence: true validates :body, presence: true
Tuyệt! Bước tiếp theo load vài record mẫu vào bảng mới tạo.
Load Dữ Liệu Demo
Cách dễ nhất load dữ liệu là dùng file seeds.rb trong thư mục db. Tuy nhiên tôi lười (như nhiều lập trình viên) và không muốn nghĩ nội dung mẫu. Vậy tại sao không dùng gem faker tạo dữ liệu ngẫu nhiên: tên, email, từ hipster, văn bản "lorem ipsum", và nhiều hơn nữa.
Gemfile
group :development do gem 'faker' end
Cài gem:
bundle install
Sửa seeds.rb:
db/seeds.rb
5.times do
user = User.create({name: Faker::Name.name})
user.posts.create({title: Faker::Book.title, body: Faker::Lorem.sentence})
end
Cuối cùng, load dữ liệu:
rails db:seed
Phản Hồi Với JSON
Bây giờ cần routes và controllers để xây API. Thông thường nest routes API dưới path api/. Cũng thường cung cấp version API trong path, ví dụ api/v1/. Sau này nếu có breaking changes, chỉ cần tạo namespace mới (v2) và controller riêng.
Routes có thể như sau:
config/routes.rb
namespace 'api' do
namespace 'v1' do
resources :posts
resources :users
end
end
Sinh ra routes như:
api_v1_posts GET /api/v1/posts(.:format) api/v1/posts#index POST /api/v1/posts(.:format) api/v1/posts#create api_v1_post GET /api/v1/posts/:id(.:format) api/v1/posts#show
Bạn có thể dùng scope thay namespace, nhưng mặc định nó sẽ tìm UsersController và PostsController trong thư mục controllers, không phải controllers/api/v1, nên cẩn thận.
Tạo thư mục api với nested v1 trong controllers. Điền controllers:
controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
end
end
end
controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < ApplicationController
end
end
end
Lưu ý không chỉ file controller đặt dưới api/v1, mà class cũng phải namespaced trong module Api và V1.
Câu hỏi tiếp theo: làm sao phản hồi đúng định dạng JSON? Bài viết sẽ thử hai giải pháp: gem jBuilder và active_model_serializers. Trước khi sang phần sau, thêm chúng vào Gemfile:
Gemfile
gem 'jbuilder', '~> 2.5' gem 'active_model_serializers', '~> 0.10.0'
Chạy:
bundle install
Sử Dụng Gem jBuilder
jBuilder là gem phổ biến do đội Rails maintain, cung cấp DSL (domain-specific language) đơn giản cho phép định nghĩa cấu trúc JSON trong view.
Giả sử muốn hiển thị tất cả bài viết khi user hit action index:
controllers/api/v1/posts_controller.rb
def index
@posts = Post.order('created_at DESC')
end
Chỉ cần tạo view tên theo action tương ứng với đuôi .json.jbuilder. View phải đặt dưới path api/v1:
views/api/v1/posts/index.json.jbuilder
json.array! @posts do |post| json.id post.id json.title post.title json.body post.body end
json.array! duyệt mảng @posts. json.id, json.title, json.body sinh key với tên tương ứng, giá trị là tham số. Truy cập https://localhost:3000/api/v1/posts.json sẽ thấy output như:
[
{"id": 1, "title": "Title 1", "body": "Body 1"},
{"id": 2, "title": "Title 2", "body": "Body 2"}
]
Nếu muốn hiển thị tác giả cho mỗi bài viết? Đơn giản:
json.array! @posts do |post|
json.id post.id
json.title post.title
json.body post.body
json.user do
json.id post.user.id
json.name post.user.name
end
end
Output thay đổi thành:
[
{"id": 1, "title": "Title 1", "body": "Body 1", "user": {"id": 1, "name": "Username"}}
]
Nội dung file .jbuilder là code Ruby thuần, có thể dùng các operation cơ bản như bình thường.
Lưu ý jBuilder hỗ trợ partials như view Rails thường, nên có thể dùng:
json.partial! partial: 'posts/post', collection: @posts, as: :post
Rồi tạo file views/api/v1/posts/_post.json.jbuilder với nội dung:
json.id post.id json.title post.title json.body post.body json.user do json.id post.user.id json.name post.user.name end
Như vậy jBuilder dễ dàng và tiện lợi. Tuy nhiên, thay vào đó bạn có thể dùng serializers, hãy thảo luận ở phần sau.
Sử Dụng Serializers
Gem rails_model_serializers do team ban đầu quản lý rails-api tạo ra. Theo tài liệu, rails_model_serializers mang convention over configuration vào JSON generation. Cơ bản, bạn định nghĩa field nào dùng khi serialize (tức generate JSON).
Serializer đầu tiên:
serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :body end
Ở đây nói rằng tất cả field này phải có trong JSON kết quả. Bây giờ các method như to_json và as_json gọi trên post sẽ dùng config này và trả về nội dung đúng.
Để thấy hoạt động, sửa action index:
controllers/api/v1/posts_controller.rb
def index
@posts = Post.order('created_at DESC')
render json: @posts
end
as_json sẽ tự động gọi trên object @posts.
Còn user? Serializers cho phép chỉ định relations, giống như model. Hơn nữa, serializers có thể nest:
serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :body
belongs_to :user
class UserSerializer < ActiveModel::Serializer
attributes :id, :name
end
end
Khi serialize post, sẽ tự động chứa key user lồng với id và name. Nếu sau tạo serializer riêng cho user loại trừ :id:
serializers/post_serializer.rb
class UserSerializer < ActiveModel::Serializer attributes :name end
Thì @user.as_json không trả user id. Nhưng @post.as_json vẫn trả cả name và id của user, hãy nhớ điều này.
Bảo Mật API
Nhiều trường hợp không muốn ai cũng thực hiện bất kỳ action nào qua API. Hãy trình bày kiểm tra bảo mật đơn giản và bắt user gửi token khi tạo/xóa bài viết.
Token có vòng đời không hạn và tạo khi user đăng ký. Trước hết, thêm cột token vào bảng users:
rails g migration add_token_to_users token:string:index
Index này đảm bảo duy nhất vì không thể có hai user cùng token:
db/migrate/xyz_add_token_to_users.rb
add_index :users, :token, unique: true
Áp dụng migration:
rails db:migrate
Thêm callback before_save:
models/user.rb
before_create -> {self.token = generate_token}
Phương thức private generate_token tạo token trong vòng lặp vô tận và kiểm tra xem có unique không. Sớm nhất khi tìm thấy token unique, trả về:
models/user.rb
private
def generate_token
loop do
token = SecureRandom.hex
return token unless User.exists?({token: token})
end
end
Bạn có thể dùng thuật toán khác sinh token, ví dụ dựa trên MD5 hash của tên user và salt.
Đăng Ký User
Cần cho user đăng ký, nếu không họ không lấy được token. Tôi không muốn đưa view HTML vào app, nên thêm API method mới:
controllers/api/v1/users_controller.rb
def create
@user = User.new(user_params)
if @user.save
render status: :created
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name)
end
Ý tưởng tốt là trả HTTP status code có ý nghĩa để developer hiểu chính xác chuyện gì xảy ra. Bây giờ bạn có thể cung cấp serializer mới cho user hoặc dùng file .json.jbuilder. Tôi thích cách sau (đó là lý do không pass option :json cho render), nhưng bạn tự do chọn. Lưu ý token không được serialize luôn, ví dụ khi trả danh sách tất cả user—nó phải giữ an toàn!
views/api/v1/users/create.json.jbuilder
json.id @user.id json.name @user.name json.token @user.token
Bước tiếp theo test xem mọi thứ hoạt động. Có thể dùng lệnh curl hoặc viết code Ruby. Vì bài về Ruby, tôi chọn code.
Test Đăng Ký User
Để thực hiện HTTP request, dùng gem Faraday, cung cấp interface chung qua nhiều adapter (mặc định Net::HTTP). Tạo file Ruby riêng, include Faraday, setup client:
api_client.rb
require 'faraday'
client = Faraday.new(url: 'https://localhost:3000') do |config|
config.adapter Faraday.default_adapter
end
response = client.post do |req|
req.url '/api/v1/users'
req.headers['Content-Type'] = 'application/json'
req.body = '{ "user": {"name": "test user"} }'
end
Các option dễ hiểu: chọn default adapter, set request URL https://localhost:300/api/v1/users, đổi content type application/json, cung cấp body request.
Response server chứa JSON, để parse dùng gem Oj:
api_client.rb
require 'oj' # client here... puts Oj.load(response.body) puts response.status
Ngoài response đã parse, cũng hiển thị status code để debug.
Chạy script:
ruby api_client.rb
Và lưu token nhận được—sẽ dùng ở phần sau.
Xác Thực Với Token
Để bắt buộc xác thực token, dùng phương thức authenticate_or_request_with_http_token. Nó thuộc module ActionController::HttpAuthentication::Token::ControllerMethods, nhớ include:
controllers/api/v1/posts_controller.rb
class PostsController < ApplicationController include ActionController::HttpAuthentication::Token::ControllerMethods # ... end
Thêm before_action và method tương ứng:
controllers/api/v1/posts_controller.rb
before_action :authenticate, only: [:create, :destroy]
# ...
private
# ...
def authenticate
authenticate_or_request_with_http_token do |token, options|
@user = User.find_by(token: token)
end
end
Bây giờ nếu token không set hoặc không tìm thấy user với token đó, lỗi 401 sẽ trả về, ngăn action thực thi.
Lưu ý giao tiếp client-server phải qua HTTPS, vì nếu không token dễ bị giả mạo. Giải pháp này không lý tưởng, nhiều trường hợp nên dùng OAuth 2. Có ít nhất hai gem đơn giản hóa hỗ trợ tính năng này: Doorkeeper và oPRO.
Tạo Bài Viết
Để thấy xác thực hoạt động, thêm action create vào PostsController:
controllers/api/v1/posts_controller.rb
def create
@post = @user.posts.new(post_params)
if @post.save
render json: @post, status: :created
else
render json: @post.errors, status: :unprocessable_entity
end
end
Tận dụng serializer để hiển thị JSON đúng. @user đã set trong before_action.
Test với code đơn giản:
api_client.rb
client = Faraday.new(url: 'https://localhost:3000') do |config|
config.adapter Faraday.default_adapter
config.token_auth('127a74dbec6f156401b236d6cb32db0d')
end
response = client.post do |req|
req.url '/api/v1/posts'
req.headers['Content-Type'] = 'application/json'
req.body = '{ "post": {"title": "Title", "body": "Text"} }'
end
Thay đối số token_auth bằng token nhận khi đăng ký, chạy script.
ruby api_client.rb
Xóa Bài Viết
Xóa bài viết tương tự. Thêm action destroy:
controllers/api/v1/posts_controller.rb
def destroy
@post = @user.posts.find_by(params[:id])
if @post
@post.destroy
else
render json: {post: "not found"}, status: :not_found
end
end
Chỉ cho user xóa bài viết họ sở hữu. Nếu xóa thành công, status 204 (no content) trả về. Hoặc có thể phản hồi với id bài viết vừa xóa vì vẫn còn trong memory.
Code test tính năng mới:
api_client.rb
response = client.delete do |req| req.url '/api/v1/posts/6' req.headers['Content-Type'] = 'application/json' end
Thay id bài viết bằng số phù hợp cho bạn.
Thiết Lập CORS
Nếu muốn cho các web service khác truy cập API (từ client-side), CORS (Cross-Origin Resource Sharing) phải setup đúng. Cơ bản, CORS cho phép web app gửi AJAX request đến third-party services. May mắn là có gem rack-cors giúp setup dễ dàng. Thêm vào Gemfile:
Gemfile
gem 'rack-cors'
Cài đặt:
bundle install
Cung cấp config trong file config/initializers/cors.rb. File này đã tạo sẵn với ví dụ dùng. Cũng tìm thấy tài liệu chi tiết trên trang gem.
Cấu hình sau, ví dụ, cho phép bất kỳ ai truy cập API bằng bất kỳ method nào:
config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '/api/*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
Ngăn Chặn Lạm Dụng
Điều cuối cùng trong hướng dẫn: bảo vệ API khỏi lạm dụng và tấn công denial of service. Có gem hay tên rack-attack (do người Kickstarter tạo) cho phép blacklist/whitelist client, ngăn chặn flood request, và hơn thế.
Thêm gem vào Gemfile:
Gemfile
gem 'rack-attack'
Cài đặt:
bundle install
Cung cấp config trong file initializer rack_attack.rb. Tài liệu gem liệt kê các option và gợi ý use case. Đây là config mẫu giới hạn ai trừ bạn truy cập service và limit tối đa 5 request/giây:
config/initializers/rack_attack.rb
class Rack::Attack
safelist('allow from localhost') do |req|
# Requests are allowed if the return value is truthy
'127.0.0.1' == req.ip || '::1' == req.ip
end
throttle('req/ip', :limit => 5, :period => 1.second) do |req|
req.ip
end
end
Cần thêm include RackAttack làm middleware:
config/application.rb
config.middleware.use Rack::Attack
Kết Luận
Đến cuối bài viết. Hy vọng bạn tự tin hơn khi xây API với Rails! Lưu ý đây không phải lựa chọn duy nhất—giải pháp phổ biến khác là framework Grape, bạn có thể quan tâm kiểm tra.
Đừng ngần ngại đặt câu hỏi nếu chưa rõ. Cảm ơn bạn đã theo dõi, happy coding!