ปัจจุบันการพึ่งพา API (Application Programming Interfaces) เป็นเรื่องปกติ ไม่ใช่แค่บริการใหญ่อย่าง Facebook และ Twitter เท่านั้น API นิยมเพราะเฟรมเวิร์กฝั่งไคลเอนต์อย่าง React, Angular และอื่นๆ เติบโตขึ้น Ruby on Rails ก็ติดตามเทรนด์นี้ Rails เวอร์ชันล่าสุดมีฟีเจอร์สร้าง API-only applications
ฟังก์ชันนี้เดิมอยู่ในเจม rails-api แต่มาตั้งแต่ Rails 5 กลายเป็นส่วนหนึ่งของ core framework พร้อมกับ ActionCable เป็นฟีเจอร์ที่คาดหวังมาก วันนี้เราจะมาเจาะลึก
บทความครอบคลุม: สร้าง API-only Rails app, โครงสร้าง routes และ controllers, ตอบกลับ JSON, เพิ่ม serializers, ตั้งค่า CORS (Cross-Origin Resource Sharing) และตัวเลือกรักษาความปลอดป้องกัน API จากการ濫用
ซอร์สโค้ดอยู่ที่ GitHub
สร้าง API-Only Application
รันคำสั่ง:
rails new RailsApiDemo --api
จะสร้างแอป API-only ชื่อ RailsApiDemo ต้องใช้ Rails 5 ขึ้นไป เพราะ option --api เพิ่งเพิ่มเข้ามา
เปิด Gemfile จะเห็นเล็กลงมาก: เจมอย่าง coffee-rails, turbolinks, sass-rails หายไป
config/application.rb มีบรรทัดใหม่:
config.api_only = true
หมายถึง Rails โหลด middleware ชุดน้อยกว่า: ไม่มี cookies และ sessions support นอกจากนี้ ถ้า generate scaffold จะไม่สร้าง views และ assets โฟลเดอร์ views/layouts ไม่มี application.html.erb อีกต่อไป
ความแตกต่างสำคัญ: ApplicationController สืบทอดจาก ActionController::API แทน ActionController::Base
นั่นคือแอป Rails พื้นฐานที่คุ้นเคย ต่อไปเพิ่มโมเดลเพื่อมีข้อมูลทำงาน:
rails g model User name:string rails g model Post title:string body:text user:belongs_to rails db:migrate
ตั้งค่า associations และ validation:
models/user.rb
has_many :posts validates :name, presence: true
models/post.rb
belongs_to :user validates :title, presence: true validates :body, presence: true
โหลดข้อมูลตัวอย่างด้วย seeds.rb ใช้เจม faker สร้างข้อมูลสุ่ม:
Gemfile
group :development do gem 'faker' end
ติดตั้ง:
bundle install
แก้ 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
โหลดข้อมูล:
rails db:seed
ตอบกลับด้วย JSON
สร้าง routes และ controllers สำหรับ API ปกติ nest routes ใต้ api/ และใส่ version เช่น api/v1/ หากมี breaking changes สร้าง namespace ใหม่ v2
routes ตัวอย่าง:
config/routes.rb
namespace 'api' do
namespace 'v1' do
resources :posts
resources :users
end
end
สร้าง routes แบบนี้:
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
อาจใช้ scope แทน namespace แต่จะมองหา controller ในโฟลเดอร์ controllers ไม่ใช่ controllers/api/v1 ต้องระวัง
สร้างโฟลเดอร์ api/v1 ใน controllers และเพิ่ม 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
ต้อง namespace class ด้วย Api และ V1 modules
ตอบกลับ JSON ยังไงดี? ลอง 2 วิธี: jBuilder และ active_model_serializers เพิ่มใน Gemfile:
Gemfile
gem 'jbuilder', '~> 2.5' gem 'active_model_serializers', '~> 0.10.0'
ติดตั้ง:
bundle install
ใช้ jBuilder Gem
jBuilder เป็นเจมของทีม Rails ให้ DSL เรียบง่ายกำหนดโครงสร้าง JSON ใน views
แสดง posts ทั้งหมดใน action index:
controllers/api/v1/posts_controller.rb
def index
@posts = Post.order('created_at DESC')
end
สร้าง view ชื่อ index.json.jbuilder ใต้ 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! วนลูป @posts json.id, json.title, json.body สร้าง key-value เข้าไป เข้า https://localhost:3000/api/v1/posts.json จะได้ JSON ประมาณนี้:
[
{"id": 1, "title": "Title 1", "body": "Body 1"},
{"id": 2, "title": "Title 2", "body": "Body 2"}
]
อยากแสดง author ด้วย? ง่าย:
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
ผลลัพธ์จะมี nested user object
เนื้อหาใน .jbuilder เป็น Ruby ธรรมดา ใช้ operation ต่างๆ ได้ รองรับ partials เช่นกัน:
json.partial! partial: 'posts/post', collection: @posts, as: :post
สร้าง views/api/v1/posts/_post.json.jbuilder:
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
jBuilder ใช้ง่าย สะดวก แต่มีทางเลือกคือ serializers ไปดูต่อ
ใช้ Serializers
rails_model_serializers สร้างโดยทีม rails-api ให้ convention over configuration สำหรับ JSON generation กำหนด field ที่ควร serialize
Serializer แรก:
serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :body end
ตอนนี้ to_json และ as_json ของ post จะใช้ config นี้
แก้ action index ให้ render json:
controllers/api/v1/posts_controller.rb
def index
@posts = Post.order('created_at DESC')
render json: @posts
end
as_json จะถูกเรียกอัตโนมัติ
Serializers รองรับ relations เหมือน models และ 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
ตอน serialize post จะมี nested user พร้อม id และ name หากสร้าง UserSerializer แยกต่างหาก exclude id:
serializers/post_serializer.rb
class UserSerializer < ActiveModel::Serializer attributes :name end
แต่ @post.as_json ยังคงมี user id และ name ทั้งคู่ จดจำไว้
รักษาความปลอดภัย API
ไม่ควรให้ใครทำอะไรก็ได้ผ่าน API มาเพิ่ม security check บังคับให้ส่ง token เมื่อสร้างและลบโพสต์
Token มีอายุไม่หมด สร้างตอนสมัคร เพิ่มคอลัมน์ token ใน users table:
rails g migration add_token_to_users token:string:index
Index ต้อง unique:
db/migrate/xyz_add_token_to_users.rb
add_index :users, :token, unique: true
รัน migration:
rails db:migrate
เพิ่ม before_create callback:
models/user.rb
before_create -> { self.token = generate_token }
เมธอด generate_token สร้าง token แบบสุ่มตรวจสอบ uniqueness:
models/user.rb
private
def generate_token
loop do
token = SecureRandom.hex
return token unless User.exists?(token: token)
end
end
อาจใช้อัลกอริทึมอื่น เช่น MD5 hash ของ name + salt
สมัครสมาชิกผู้ใช้ (User Registration)
เพิ่ม API method สำหรับ register:
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
ควรส่ง HTTP status codes ที่มีความหมาย ใช้ .json.jbuilder สำหรับ response (ไม่ส่ง option :json ให้ render) token ห้าม serialize ตลอดเวลา เช่นเวลา list users ต้องเก็บไว้ปลอดภัย
views/api/v1/users/create.json.jbuilder
json.id @user.id json.name @user.name json.token @user.token
ทดสอบการสมัครด้วยโค้ด Ruby ใช้เจม Faraday ทำ HTTP request:
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
Parse JSON response ด้วย Oj:
api_client.rb
require 'oj' puts Oj.load(response.body) puts response.status
รันสคริปต์:
ruby api_client.rb
เก็บ token ที่ได้ไว้ใช้ต่อ
ยืนยันตัวตนด้วย Token
ใช้ authenticate_or_request_with_http_token จาก module ActionController::HttpAuthentication::Token::ControllerMethods:
controllers/api/v1/posts_controller.rb
class PostsController < ApplicationController include ActionController::HttpAuthentication::Token::ControllerMethods # ... end
เพิ่ม before_action และเมธอด authenticate:
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
ถ้าไม่มี token หรือหา user ไม่เจอ จะคืน 401 error หยุด action
สำคัญ: ต้องสื่อสารผ่าน HTTPS ไม่เช่นนั้น token อาจถูกปลอม แม้วิธีนี้ไม่ใช่ที่ดีที่สุด ในหลายกรณีควรใช้ OAuth 2 มีเจมช่วยอย่าง Doorkeeper และ oPRO
สร้างโพสต์ (Creating a Post)
เพิ่ม action create ใน 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
ใช้ serializer แสดง JSON @user セットใน before_action แล้ว
ทดสอบ:
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
แทนที่ token ด้วย token ที่ได้จากการสมัคร รันสคริปต์
ลบโพสต์ (Deleting a Post)
เพิ่ม 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
อนุญาตลบเฉพาะโพสต์ของตัวเอง ถ้าลบสำเร็จ คืน 204 (no content) หรือส่ง id ที่ลบกลับไปก็ได้
ทดสอบ:
api_client.rb
response = client.delete do |req| req.url '/api/v1/posts/6' req.headers['Content-Type'] = 'application/json' end
เปลี่ยน post id ให้ตรงกับข้อมูลของคุณ
ตั้งค่า CORS
ต้องการให้เว็บบริการอื่นเข้าถึง API จากฝั่งไคลเอนต์ ต้องตั้งค่า CORS (Cross-Origin Resource Sharing) ให้ถูกต้อง CORS อนุญาตให้ web apps ส่ง AJAX requests ไปยัง third-party services มีเจม rack-cors ช่วยตั้งค่าได้ง่าย:
Gemfile
gem 'rack-cors'
ติดตั้ง:
bundle install
ตั้งค่าใน config/initializers/cors.rb (ไฟล์นี้มีตัวอย่างอยู่แล้ว) ตัวอย่าง config อนุญาตทุกคนเข้าถึง API ด้วยทุก method:
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
ป้องกันการ濫用 (Preventing Abuse)
ป้องกัน API จากการ濫用และ DoS attacks มีเจม rack-attack (จากทีม Kickstarter) ให้ blacklist/whitelist clients, limit request rate และอื่นๆ
เพิ่มใน Gemfile:
Gemfile
gem 'rack-attack'
ติดตั้ง:
bundle install
ตั้งค่าใน initializer rack_attack.rb ตัวอย่าง config จำกัดเฉพาะ localhost และ limit 5 requests/second:
config/initializers/rack_attack.rb
class Rack::Attack
safelist('allow from localhost') do |req|
'127.0.0.1' == req.ip || '::1' == req.ip
end
throttle('req/ip', limit: 5, period: 1.second) do |req|
req.ip
end
end
เพิ่ม RackAttack เป็น middleware:
config/application.rb
config.middleware.use Rack::Attack
สรุป
จบบทความแล้ว หวังว่าคุณมั่นใจสร้าง API ด้วย Rails มากขึ้น ยังมีอีกทางเลือกอย่าง Grape framework ที่นิยมมาแล้ว สนใจลองดูได้ ถามได้เลยถ้าสงสัย ขอบคุณที่อ่าน Happy coding!