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 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 TodoItem 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