이 글에서는 JSON API를 제공하는 웹 서비스를 만드는 방법을 단계별로 설명한다. 사용할 URL은 http://api.example.com/blogs 이며 인테그레이션(Integration) 테스팅을 사용해서 CRUD를 구현하는 절차를 기술한다.
라우팅 설정

# config/routes.rb
namespace :api, path: '/', constraints: {subdomain: 'api'} do
  resources :blogs
  # resources :blogs, only: [:index]
end
여기서 path: '/'는 http://api.example.com/api/blogs 와 같이 "api"가 경로 상에 중복해서 나타나는 않도록 하는 역할을 한다.
로컬 환경 서브도메인 사용
OS X의 개발 환경에서 서브 도메인을 사용하기 위해 /etc/hosts 파일에 다음을 추가한다.

127.0.0.1 example.com
127.0.0.1 api.example.com
콘트롤러 생성
rails generate controller api/blogs index --skip-assets --skip-template-engine --skip-helper

# app/controllers/api/blogs_controller.rb
module API
  class BlogsController < ApplicationController
    def index
      blogs = Blog.all
      render json: blogs
    end
  end
end
레일즈의 클래스/모듈명 컨벤션에 의하면 모듈명은 Api로 표현하여야 하지만 여기서는 API를 사용하고 있다. 라우팅이 제대로 동작하기 위해서는 config/initializers/inflections.rb에서 모듈명에 대한 예외 처리 룰을 설정한다.

# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym 'API'
end
시리얼라이저(Serializer)
여기서 사용할 시리얼라이저는 active_model_serializers 이다. Gemfile에 젬을 추가한다.

# Gemfile
gem "active_model_serializers"
# gem "active_model_serializers", github: 'rails-api/active_model_serializers', branch: '0-9-
젬 설치 후 rails g serializer blog 를 실행하면 app/serialisers/blog_serializer.rb 파일이 생성되는데 이 파일을 커스터마이즈한다.

# app/serialisers/blog_serializer.rb
class BlogSerializer < ActiveModel::Serializer
  attributes :id, :title, :sectors

  def sectors
    object.sectors.map do |sector|
      {
        id: sector.id,
        content: sector.content
      }
    end
  end
end
이 예에서는 Blog 모델의 id, title 필드와 Sector 모델의 id, content 필드에 대해 시리얼라이즈가 되도록 설정하고 있다. 콘트롤러의render(json: blogs)는 내부적으로 blogs.to_json을 호출하는데 이때 BlogSerializer가 존재하면 자동으로 이 시리얼라이저를 사용해서 JSON 응답을 만든다.
인테그레이션 테스팅 셋업
rails g integration_test blogs 에 의해 test/integration/blogs_test.rb 파일이 생성된다. 아래와 같이 편집을 한 후 rake test:integration를 실행해서 테스트가 패스하는지 확인한다.

# test/integration/blogs_test.rb
setup do
  host! 'api.example.com'
  Blog.destroy_all
  Blog.create!(title: "Ruby Debugger", released_on: 10.days.ago)
  Blog.create!(title: "Rails Form", released_on: 5.days.ago)
  Blog.create!(title: "Authentication World", released_on: nil)
end

test "the truth" do
  assert 3, Blog.count
end
host!()
test 블럭의 URL 헬퍼가 사용할 호스트 지정
Blog.destroy_all
Fixtures 에서 제공하는 데이타는 사용하지 않음
test "lists all blogs"

# test/integration/blogs_test.rb
test "lists all blogs" do
  get '/blogs', {}, { 'Accept' => Mime::JSON }
  assert_equal 200, response.status
  assert_equal Mime::JSON, response.content_type
  assert_equal Blog.count, JSON.parse(response.body, symbolize_names: true).size
end
'Accept' => Mime::JSON
처리 가능한 응답(Response) 미디어 타입을 지정. 지정 가능한 미디어 타입은 Mime::SET.collect(&:to_s)로 알 수 있음
response.content_type
응답 페이로드의 미디어 타입을 의미
symbolize_name: true
해시의 키(Key)가 문자열(String)일 경우 심볼(Symbol)로 변경
이 테스트가 통과하기 위해서 Blogs#index를 작성한다.

# app/controllers/api/blogs_controller.rb
def index
  blogs = Blog.all
  render json: blogs, root: false, status: 200 # 또는 :ok
end
root: false
JSON 빌드 시 루트 엘리먼트인 "blogs"를 제외
test "lists released blogs"

# test/integration/blogs_test.rb
test "lists released blogs" do
  # get '/blogs?released=true', {}, { 'Accept' => Mime::JSON }
  get '/blogs', {"released" => "true"}, { 'Accept' => Mime::JSON }
  assert_equal 200, response.status
  assert_equal Mime::JSON, response.content_type
  assert_equal Blog.released.count, JSON.parse(response.body, symbolize_names: true).size
end
vi blog.rb

# app/models/blog.rb
scope :released, -> { where("released_on IS NOT NULL") }
vi blogs_controller

# app/controllers/api/blogs_controller.rb
def index
  blogs = Blog.all
  if params[:released]
    blogs = blogs.released
  end
  render json: blogs, root: false, status: 200
end
test "shows a blog by id"

# test/integration/blogs_test.rb
test "shows a blog by id" do
  blog = Blog.first
  get "/blogs/#{blog.id}", {}, { 'Accept' => Mime::JSON }
  assert_equal 200, response.status
  assert_equal Mime::JSON, response.content_type
  assert_equal blog.title, JSON.parse(response.body, symbolize_names: true)[:title]
end
vi blogs_controller

# app/controllers/api/blogs_controller.rb
def show
  blog = Blog.find(params[:id])
  render json: blog, root: false, status: 200
end
test "does not show any blog when no instance found"

# test/integration/blogs_test.rb
test "does not show any blog when no instance found" do
  get "/blogs/0", {}, { 'Accept' => Mime::JSON }
  assert_equal 404, response.status
  assert_equal Mime::JSON, response.content_type
end
vi blogs_controller

# app/controllers/api/blogs_controller.rb
def show
  begin
    blog = Blog.find(params[:id])
  rescue ActiveRecord::RecordNotFound => e
    render json: {error: 'Blog Not Found'}, status: 404 # :not_found
  else
    render json: blog, root: false, status: 200
  end
end
test "creates new blog"

# test/integration/blogs_test.rb
test "creates new blog" do
  post '/blogs', {blog: {title: 'New Blog on the Kids', released_on: Date.current}}.to_json, {'Accept' => Mime::JSON, 'Content-Type' => Mime::JSON.to_s }
  assert_equal 201, response.status
  assert_equal Mime::JSON, response.content_type
  blog = JSON.parse(response.body, symbolize_names: true)
  assert_equal blog_url(blog[:id]), response.location
  assert_equal 'New Blog on the Kids', blog[:title]
end
'Content-Type' => Mime::JSON.to_s
요청(Request) 페이로드의 미디어 타입을 지정
vi blogs_controller

# app/controllers/api/blogs_controller.rb
def create
  blog = Blog.new(blog_params)
  if blog.save
    render json: blog, root: false, status: 201, location: blog
  end

  private
  def blog_params
    params.require(:blog).permit(:id, :title, :released_on)
  end
end
test "does not create episodes with empty title"

# test/integration/blogs_test.rb
test "does not create episodes with empty title" do
  post '/blogs', {blog: {title: nil, released_on: Date.current}}.to_json,
                 {'Accept' => Mime::JSON , 'Content-Type' => Mime::JSON.to_s }
  assert_equal 422, response.status
  assert_equal Mime::JSON, response.content_type
end
vi blogs_controller

# app/controllers/api/blogs_controller.rb
def create
  blog = Blog.new(blog_params)
  if blog.save
    render json: blog, root: false, status: 201, location: blog
  else
    render json: blog.errors, status: 422 # :unprocessable_entity
  end
end
vi blog.rb

# app/models/blog.rb
validates_presence_of :title
test "updates a blog"

# test/integration/blogs_test.rb
test "updates a blog" do
  blog = Blog.first
  patch "/blogs/#{blog.id}", {blog: {title: 'Change the title', released_on: Date.current}}.to_json,
                 {'Accept' => Mime::JSON , 'Content-Type' => Mime::JSON.to_s }
  assert_equal 200, response.status
  assert_equal Mime::JSON, response.content_type
  assert_equal 'Change the title', blog.reload.title
end
vi blogs_controller

# app/controllers/api/blogs_controller.rb
def update
  blog = Blog.find(params[:id])
  if blog.update(blog_params)
    render json: blog, root: false, status: 200
  else
    render json: blog.errors, root: false, status: 422
  end
end
test "deletes existing blog"

# test/integration/blogs_test.rb
test "deletes existing blog" do
  blog = Blog.first
  delete "/blogs/#{blog.id}", {}, {}
  assert_equal 204, response.status
end
vi blogs_controller.rb

# app/controllers/api/blogs_controller.rb
def destroy
  blog = Blog.find(params[:id])
  blog.destroy
  head 204 # no_content
end
curl 사용법

curl -i http://api.example.com:3000/blogs
curl -I http://api.example.com:3000/blogs
curl -I -H "Accept: applicaiton/json" http://api.example.com:3000/blogs
-i
페이로드와 헤더 정보 모두 출력
-I
헤더 정보만 출력
-H
요청(Request) 패킷에 대한 헤더 옵션

curl -i -X POST -d "blog[title]=xxx" http://api.example.com:3000/blogs
-X
HTTP 메쏘드
-d
요청 페이로드 데이타
실제 위 명령을 실행하면 422 에러를 받는데 CSRF를 위반했기 때문이다. 레일즈에서는 POST, PATCH, PUT, DELETE에 대해서 인증 토큰(Authenticity Token)을 요구하는데 curl 명령에서는 이를 보낼 수가 없다. 임시로 콘트롤러에 protect_from_forgery with: :null_session를 추가해서 curl을 동작하게 할 수 있으나 이는 임시 방편이고 궁극적으로는 별도의 인증 절차를 거치는 방안을 강구하여야 한다. 한편, 인테그레이션 테스팅에서 인증 토큰 없이 시험이 진행되었던 이유는 config/environments/test.rb 에서 config.action_controller.allow_forgery_protection = false가 지정되어 있기 때문이다.
curl과 브라우저의 동작 차이점
브라우저에서는 세션 쿠키(Session Cookie)를 저장하고 있다가 요청(Request)때 이를 같이 전송해서 세션을 유지하는 목적으로 사용할 수 있으나, curl은 매 명령마다 새로운 세션(session) 이라고 생각하고 요청이 이루어진다.이 결과로 curl 요청에 대해서 레일즈의 <%= csrf_meta_tags %>에 의해 생성하는 값이 매번 바뀌게 되고 이 이유로 http 캐싱(caching)이 동작 안되는 것으로 보일 수 있음을 주의한다.
CORS(Cross-origin Resource Sharing) 구성
vi config/application.rb

config.action_dispatch.default_headers = {
  'Access-Control-Allow-Origin' => '*',
  # 'Access-Control-Allow-Origin' => Rails.env.production? ? 'http://zoolu.co.kr' : 'http://localhost:3000',
  'Access-Control-Request-Method' => %w{GET POST OPTIONS}.join(",")
}