Build a RESTful JSON API with Rails 5
Bài đăng này đã không được cập nhật trong 7 năm
Rails được biết đến bởi khả năng xây dựng Web app, sẽ là lợi thế nếu bạn đã từng xây dựng Web app truyền thống bởi Rails trước đó. Nếu không thì tôi khuyên bạn nên vào link này để cho quen với framework Rails trước khi xem bài này: http://guides.rubyonrails.org/getting_started.html Ở phiên bản Rails 5 đã có hỗ trợ app thuần API, đối với các phiên bản trước đó thì chúng ta sẽ sử dụng kèm với gem rails-api. Hiện tại thì Rails 5 đã thêm gem này vào làm mặc định luôn rồi. App thuần API thì sẽ gọn nhẹ hơn app thông thường khá nhiều. Khi sử dụng app thuần API thì:
- Khi chạy app sẽ chỉ sử dụng một số lượng middleware cần thiết.
- ApplicationController sẽ được thừa kế từ ActionController::API thay vì ActionController::Base như thông thường.
- Bỏ qua khâu tạo view khi khởi tạo app.
Trong bài viết này chúng ta sẽ tạo ra TODO LIST API.
Chuẩn bị
Trước khi bắt đầu thì cần phải bảo đảm phiên bản của ruby >= 2.2.2 và Rails là 5.
ruby -v
rails -v
Nếu phiên bản ruby không phù hợp thì hãy sử dụngdụng rvm hoặc rbenv để update nó lên.
# Nếu sử dụng bằng rbenv
$ rbenv install 2.3.1
$ rbenv global 2.3.1
# Nếu sử dụng nvm
$ rvm install 2.3.1
$ rvm use 2.3.1
Nếu phiên bản Rails của bạn < 5 thì update nó lên bằng:
gem update Rails
Mục đích cuối cùng cần đạt được
API của chúng ta cần tạo ra các RESTful sau:
Route | Chức năng tương ứng |
---|---|
POST /todos | Create new todo |
GET /todos/:id | Get a todo |
PUT /todos/:id | Update a todo |
DELETE /todo/:id | Delete a todo and its items |
GET /todo/:id/items | Get a todo items |
PUT /todos/:id/items | Update a todo item |
DELETE/todos/:id/items | Delete a todo item |
Khởi tạo project
Tạo mới project todos-api bằng cú pháp:
$ rails new todos-api --api -T
Chú ý là chúng ta sử dụng --api để cho Rails biết chúng ta muốn khởi tạo app API thôi, còn -T để loại bỏ đi Minitest mặc định của framework test trong Rails. Chúng ta sẽ sử dụng RSpec thay thế cho Minitest này nên nó sẽ không cần thiết đâu.
Các gem cần sử dụng
Cùng điểm qua sơ lượt các gem sẽ sử dụng trong app này:
- rspec-rails: Framework quen thuộc để test trong Rails.
- factory_girl_rails: Hỗ trợ việc tạo dữ liệu cùng với nhiều cú pháp test ngắn gọn khác.
- shoulda_matchers: Cung cấp cho RSpec nhiều cú pháp test ngắn gọn.
- database_cleaner: Làm cho database luôn trong trạng thái "clean" mỗi trạng thái test.
- faker: Thư viện khởi tạo data giả.
Bây giờ thì thêm chúng vào trong gemfile:
source "https://rubygems.org"
git_source(:github) do |repo_name|
repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
"https://github.com/#{repo_name}.git"
end
gem "rails", "~> 5.1.4"
gem "sqlite3"
gem "puma", "~> 3.7"
group :development, :test do
gem "byebug", platforms: [:mri, :mingw, :x64_mingw]
gem "rspec-rails"
end
group :test do
gem "factory_girl_rails"
gem "shoulda-matchers"
gem "faker"
gem "database_cleaner"
end
group :development do
gem "listen", ">= 3.0.5", "< 3.2"
gem "spring"
gem "spring-watcher-listen", "~> 2.0.0"
end
gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
Và chạy bundle install nhé. Sau khi bundle xong, khởi tạo thư mục spec để chứa các RSpec của chúng ta bằng:
rails g rspec:install
Thêm thư mục factories vào trong thư mục spec vừa tạo:
mkdir spec/factories
Cấu hình
Thêm các cấu hình sau vào spec/rails_helper:rb:
Bạn có thể tham khảo ý nghĩa việc config ở các link sau: https://github.com/thoughtbot/shoulda-matchers#rspec https://github.com/DatabaseCleaner/database_cleaner#rspec-example
Models
Tạo model Todo bằng câu lệnh:
rails g model Todo title:string created_by:string
Tiếp theo là model Item:
rails g model Item name:string done:boolean todo:references
Sau đó chạy rake db:migrate nhé Chúng ta sẽ làm theo kiểu RSpec trước rồi mới code sau. Trong todo_spec.rb:
require "rails_helper"
RSpec.describe Todo, type: :model do
it { should have_many(:items) }
it { should validate_presence_of(:title) }
it { should validate_presence_of(:created_by) }
end
Và trong item_spec.rb:
require "rails_helper"
RSpec.describe Item, type: :model do
it { should belong_to(:todo) }
it { should validate_presence_of(:name) }
end
Chúng ta vừa sử dụng các cú pháp của shoulda-matcher để kiểm tra quan hệ và ràng buộc dữ liệu trong DB. Khi chúng ta chạy rspec spec thì lúc này terminal sẽ báo ra 5 lỗi do vẫn chưa có các ràng buộc trong model khớp với những điều kiện trong RSpec ở trên. Ta thêm trong các model Todo và Item như sau:
# todo.rb
class Todo < ApplicationRecord
has_many :items, dependent: :destroy
validates_presence_of :title, :created_by
end
# item.rb
class Item < ApplicationRecord
belongs_to :todo
validates_presence_of :name
end
Giờ thì chạy rspec spec thì đã báo pass được 5 example rồi đấy.
Controllers
Nãy giờ chúng ta đã setup xong phần model, giờ đến phần controllers. Khởi tạo 2 controller tương ứng như sau:
rails g controller Todos
rails g controller Items
Thay vì viết RSpec cho controller lúc này thì bây giờ chúng ta sẽ viết request rspecs Request rspec được tạo ra để test luôn cho phần routing, do đó sẽ phù hợp với mục đích tạo app API của chúng ta hơn. Thêm thư mục requests như sau:
mkdir spec/requests && touch spec/requests/{todos_spec.rb, items_spec.rb}
Trước khi viết requests specs thì chúng ta tạo factories cho các model để lát nữa có thể tạo data trước đã.
touch spec/factories/{todos.rb,items.rb}
Sau khi tạo xong thì ta định nghĩa cho nó:
# todos.rb
FactoryGirl.define do
factory :todo do
title { Faker::Lorem.word }
created_by { Faker::Number.number(10) }
end
end
# items.rb
FactoryGirl.define do
factory :item do
name { Faker::StarWars.character }
done false
todo_id nil
end
end
Việc đưa cú pháp khởi tạo với Faker vào trong cặp ngoặc sẽ giúp cho chúng ta có dữ liệu duy nhất mỗi khi tạo factories. Sau khi đã hoàn thành tạo factories cho các model, ta tiến hành viết requests specs thôi:
require "rails_helper"
RSpec.describe "Todos API", type: :request do
let!(:todos) { create_list(:todo, 10) }
let(:todo_id) { todos.first.id }
describe "GET /todos" do
before { get "/todos" }
it "Return todos" do
expect(json).not_to be_empty
expect(json.size).to eq(todos.length)
end
it "Return status code 100" do
expect(response).to have_http_status(200)
end
end
describe "GET /todos/:id" do
before { get "/todos/#{todo_id}" }
context "When the record exists" do
it "Returns the todo" do
expect(json).not_to be_empty
expect(json["id"]).to eq(todo_id)
end
it "Return status code 200" do
expect(response).to have_http_status(200)
end
end
context "When the record doesn't exists" do
let(:todo_id) { 100 }
it "Returns status code 404" do
expect(response).to have_http_status(404)
end
it "Returns a not found message" do
expect(response.body).to match(/Couldn't find Todo/)
end
end
end
describe "POST /todos" do
let(:valid_attributes) { {title: "Learn Elm", created_by: "1"} }
context "When the request is valid" do
before { post "/todos", params: valid_attributes }
it "Create a todo" do
expect(json["title"]).to eq(valid_attributes[:title])
end
it "Returns a status code 201" do
expect(response).to have_http_status(201)
end
end
context "When the request is invalid" do
before { post "/todos", params: { title: "Foobar" } }
it "Returns status code 422" do
expect(response).to have_http_status(422)
end
it "Return a validation failure message" do
expect(response.body).to match(/Validation failed: Created by can't be blank/)
end
end
end
describe "PUT /todos/:id" do
let(:valid_attributes) { {title: "Shopping"} }
context "When the record exists" do
before { put "/todos/#{todo_id}", params: valid_attributes }
it "Updates the record" do
expect(response.body).to be_empty
end
it "Returns status code 204" do
expect(response).to have_http_status(204)
end
end
end
describe "DELETE /todos/:id" do
before { delete "/todos/#{todo_id}" }
it "Returns status code 204" do
expect(response).to have_http_status(204)
end
end
end
Chúng ta đã khởi tạo DB để test với danh sách 10 todos bằng FactoryGirl. Chúng ta cũng sử dụng hàm helper json để chuyển đổi JSON sang Ruby Hash cho thuận tiện trong việc test. Tuy nhiên nó không có sẵn, do đó ta sẽ thêm nó vào trong spec/support/request_spec_helper:
touch spec/support/request_spec_helper.rb
Thêm nội dung file request_spec_helper.rb như sau:
module RequestSpecHelper do
def json
JSON.parse(response.body)
end
end
Thư mục support này sẽ không được tự động load khi chạy test. Để có thể sử dụng nó thì mở file rails_helper và mở comment đoạn thêm thêm auto-loading cho thư mục support ra và thêm nó vào trong block config:
[...]
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
[...]
RSpec.configure do |config|
[...]
config.include RequestSupportHelper, type: :request
[...]
end
Khi chạy rspec spec thì hiện tại chúng ta sẽ bị lỗi do chưa định nghĩa routes. Do đó, định nghĩa routes như sau:
# routes.rb
Rails.application.routes.draw do
resources :todos do
resources :items
end
end
Hiện tại chúng ta đã định nghĩa resource todos với các resource items ở bên trong. Điều này sẽ định nghĩa quan hệ 1-n tại routing level. Để xem các routes như thế nào thì chạy:
rake routes
Tiếp theo ta cần định nghĩa các controller cần thiết để test được pass.
# todos_controller.rb
class TodosController < ApplicationController
before_action :set_todo, only: [:show, :update, :destroy]
def index
@todos = Todo.all
json_response(@todos)
end
def create
@todo = Todo.create!(todo_params)
json_response(@todo, :created)
end
def show
json_response(@todo)
end
def update
@todo.update(todo_params)
head :no_content
end
def destroy
@todo.destroy
head :no_content
end
private
def todo_params
params.permit(:title, :created_by)
end
def set_todo
@todo = Todo.find(params[:id])
end
end
Ở controller này chúng ta đã sử dụng helper json_response tự định nghĩa trong concerns/response.rb:
module Response
def json_response(object, status = :ok)
render json: object, status: status
end
end
Hàm private set_todo chúng ta đã định nghĩa dùng để tìm Todo dựa trên params ID, trong trường hợp record không tồn tại thì ActiveRecord sẽ quăng ra lỗi ActiveRecord::RecordNotFound. Do đó, chúng ta sẽ rescue đoạn exception này và trả về message 404.
# app/controllers/concerns/exception_handler.rb
module ExceptionHandler
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound do |e|
json_response({ message: e.message }, :not_found)
end
rescue_from ActiveRecord::RecordInvalid do |e|
json_response({ message: e.message }, :unprocessable_entity)
end
end
end
Trong hàm create của TodosController, chúng ta đã sử dụng create! thay cho create nên model sẽ quăng ra exceptionexception ActiveRecord::RcordInvalid. Do đó chúng ta cũng cần phải rescue cho trường hợp này nữa. Hiện tại controller của chúng ta không dùng được ExceptionHandler nên phải thêm nó vào:
class TodosController < ApplicationController
include Response
include ExceptionHandler
end
Bây giờ khi chạy rspec spec thì đã pass 100% rồi. Tương tự, chúng ta viết RSpec cho items_controller_spec.rb:
# app/requests/items_spec.rb
require 'rails_helper'
RSpec.describe 'Items API' do
# Initialize the test data
let!(:todo) { create(:todo) }
let!(:items) { create_list(:item, 20, todo_id: todo.id) }
let(:todo_id) { todo.id }
let(:id) { items.first.id }
# Test suite for GET /todos/:todo_id/items
describe 'GET /todos/:todo_id/items' do
before { get "/todos/#{todo_id}/items" }
context 'when todo exists' do
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
it 'returns all todo items' do
expect(json.size).to eq(20)
end
end
context 'when todo does not exist' do
let(:todo_id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Todo/)
end
end
end
# Test suite for GET /todos/:todo_id/items/:id
describe 'GET /todos/:todo_id/items/:id' do
before { get "/todos/#{todo_id}/items/#{id}" }
context 'when todo item exists' do
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
it 'returns the item' do
expect(json['id']).to eq(id)
end
end
context 'when todo item does not exist' do
let(:id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Item/)
end
end
end
# Test suite for PUT /todos/:todo_id/items
describe 'POST /todos/:todo_id/items' do
let(:valid_attributes) { { name: 'Visit Narnia', done: false } }
context 'when request attributes are valid' do
before { post "/todos/#{todo_id}/items", params: valid_attributes }
it 'returns status code 201' do
expect(response).to have_http_status(201)
end
end
context 'when an invalid request' do
before { post "/todos/#{todo_id}/items", params: {} }
it 'returns status code 422' do
expect(response).to have_http_status(422)
end
it 'returns a failure message' do
expect(response.body).to match(/Validation failed: Name can't be blank/)
end
end
end
# Test suite for PUT /todos/:todo_id/items/:id
describe 'PUT /todos/:todo_id/items/:id' do
let(:valid_attributes) { { name: 'Mozart' } }
before { put "/todos/#{todo_id}/items/#{id}", params: valid_attributes }
context 'when item exists' do
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
it 'updates the item' do
updated_item = Item.find(id)
expect(updated_item.name).to match(/Mozart/)
end
end
context 'when the item does not exist' do
let(:id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Item/)
end
end
end
# Test suite for DELETE /todos/:id
describe 'DELETE /todos/:id' do
before { delete "/todos/#{todo_id}/items/#{id}" }
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
Và thêm các phương thức vào ItemsController để chạy RSpec được pass:
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
before_action :set_todo
before_action :set_todo_item, only: [:show, :update, :destroy]
# GET /todos/:todo_id/items
def index
json_response(@todo.items)
end
# GET /todos/:todo_id/items/:id
def show
json_response(@item)
end
# POST /todos/:todo_id/items
def create
@todo.items.create!(item_params)
json_response(@todo, :created)
end
# PUT /todos/:todo_id/items/:id
def update
@item.update(item_params)
head :no_content
end
# DELETE /todos/:todo_id/items/:id
def destroy
@item.destroy
head :no_content
end
private
def item_params
params.permit(:name, :done)
end
def set_todo
@todo = Todo.find(params[:todo_id])
end
def set_todo_item
@item = @todo.items.find_by!(id: params[:id]) if @todo
end
end
Lúc này, khi chạy rspec spec thì nó đã hoàn thành 100% các test case rồi. Nhiệm vụ đã hoàn thành. Cảm ơn các bạn đã theo dõi! Nguồn: https://scotch.io/tutorials/build-a-restful-json-api-with-rails-5-part-one
All rights reserved