Build a RESTful JSON API with Rails 5
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 đượ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.
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 |
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
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.
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