techium

このブログは何かに追われないと頑張れない人たちが週一更新をノルマに技術情報を発信するブログです。もし何か調査して欲しい内容がありましたら、@kobashinG or @muchiki0226 までいただけますと気が向いたら調査するかもしれません。

REST API 実装

REST API 実装

Rails チュートリアルで作成したアプリに REST API を実装するシリーズ。

  • Doorkeeper による OAuth2 認可
  • Grape による REST API 実装
  • Swagger(OpneAPI) を用いたドキュメンテーションの追加
  • Grape Entities を使ったドキュメント中のレスポンス定義

と進めてきたので、前回決めた API 設計に沿って実装していく。

ファイル構成

v2 はとりあえず置いといて、最終的に以下のようになった。

$ tree app/api/
app/api/
├── api.rb
├── v1
│   ├── entities.rb
│   ├── feed.rb
│   ├── microposts.rb
│   ├── relationships.rb
│   ├── root.rb
│   └── users.rb
└── v2
    ├── root.rb
    └── users.rb

Mounting

まずは root.rb の中身から。

require 'grape-swagger'

module V1
  class Root < Grape::API
    version 'v1', using: :path
    format :json
    prefix :api

    mount V1::Users
    mount V1::Microposts
    mount V1::Feed
    mount V1::Relationships
・
・
・

Feed ってわざわざ独立させる必要あったかな、とか思わなくもないけどまぁこうなりました。

Grape::Entities

続いて entities.rb の中身。

module V1
  module Entities
    class User < Grape::Entity
      expose :id, documentation: { type: 'integer', desc: 'User ID.', required: true }
      expose :name, documentation: { type: 'string', desc: 'User name.', required: true }
      expose :email, documentation: { type: 'string', desc: 'User email address', required: true }
    end
    
    class Picture < Grape::Entity
      expose :url, documentation: { type: 'string', desc: 'URL of picture.(It will be `null` if there are no picture.)', required: true }
    end

    class Micropost < Grape::Entity
      expose :id, documentation: { type: 'integer', desc: 'Micropost ID.', required: true }
      expose :content, documentation: { type: 'string', desc: 'Micropost content.', required: true }
      expose :user_id, documentation: { type: 'integer', desc: 'User ID of the micropost.', required: true }
      expose :picture, using: Entities::Picture, documentation: { type: Entities::Picture, desc: 'Picture info added to the micropost.', param_type: 'body', required: true }
    end
    
  end
end

基本的に ここ を参考に実装。
特筆すべき点は特にないはず。

これらの Entities を使用して、users.rb は以下のようになった。

require 'doorkeeper/grape/helpers'

module V1

  class Users < Grape::API

    helpers Doorkeeper::Grape::Helpers

    before do
      doorkeeper_authorize!
    end

    resource :users do

      desc 'Return all users.', is_array: true,
        entity: Entities::User
      get do
        users = User.all
        present users, with: Entities::User
      end

      desc 'Return user with id.',
        entity: Entities::User
      params do
        requires :id, type: Integer, desc: 'User id.'
      end
      route_param :id do
        get do
          user = User.find(params[:id])
          present user, with: Entities::User
        end
        
        resource :following do
          desc 'Return following users of user with id.', is_array: true,
            entity: Entities::User
          get do
            following = User.find(params[:id]).following
            present following, with: Entities::User
          end
        end
        
        resource :followers do
          desc 'Return followers of user with id.', is_array: true,
            entity: Entities::User
          get do
            followers = User.find(params[:id]).followers
            present followers, with: Entities::User
          end
        end
        
        resource :microposts do
          desc 'Return microposts of user with id.', is_array: true,
            entity: Entities::Micropost
          get do
            microposts = User.find(params[:id]).microposts
            present microposts, with: Entities::Micropost
          end
        end
      end
    end
  end
end

GET /api/v1/users では、User モデルを複数、配列で返却する。
これを Swagger UI 上で表現するには以下のようにする。

    resource :users do

      desc 'Return all users.', is_array: true,
        entity: Entities::User
      get do
        users = User.all
        present users, with: Entities::User
      end

descis_array: true を指定する。

Defining an endpoint as an array

Access Token 付きリクエストの発行者を識別する

例えばマイクロポストの投稿など、

  • POST /api/v1/users/{id}/microposts

とかすれば、パラメーターからユーザーを特定可能だが、

  • そもそも他人のマイクロポストを他者が投稿することは無い(というかさせない)
  • 自身のマイクロポストを投稿するのにわざわざ自分のユーザーIDをパラメーターとして与える?

みたいに思うところがあり、やはり下記のようにするのが自然。

  • POST /api/v1/microposts

こうなると、POST してきたのが誰かを知る必要がある。
鍵は OAuth 認可で使用している Access Token だ。
ここからユーザーを特定する術はないかと調べたらあったあった。

Create a OmniAuth strategy for your provider · doorkeeper-gem/doorkeeper Wiki

Doorkeeper さんさすがやで。

ということで microposts.rb は以下のようになった。

require 'doorkeeper/grape/helpers'

module V1

  class Microposts < Grape::API

    helpers Doorkeeper::Grape::Helpers

    before do
      doorkeeper_authorize!
    end

    resource :microposts do
      desc 'Create a new micropost.', consumes: [ "application/x-www-form-urlencoded" ]
      params do
        requires :content, type: String, desc: 'Your micropost.'
      end
      post do
        user = User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
        if user
          micropost = user.microposts.build(content: params[:content])
          micropost.save
        end
      end
      
      route_param :id do
        desc 'Destroy micropost with id.'
        delete do
          user = User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
          if user
            micropost = user.microposts.find(params[:id])
            micropost.destroy
          end
        end
      end
    end
  end
end

(画像アップロードはしれっとomittした。バージョン分けしてるし次バージョンで対応ということで

Swagger 定義ファイル

ここまでできると、grape-swagger で Open API 準拠の JSON 定義ファイルが出来上がる。

{
    "info": {
        "title": "SAMPLE APP",
        "description": "This is the sample application for the tutorial.",
        "contact": {
            "name": "kfurue",
            "email": "contact@example.com",
            "url": "http://example.com/contact"
        },
        "license": {
            "name": "the MIT License",
            "url": "https://bitbucket.org/railstutorial/sample_app_4th_ed/src/2e80e463c7900e329555116119060f2a05f693cd/LICENSE.md?fileviewer=file-view-default"
        },
        "version": "0.1.0"
    },
    "swagger": "2.0",
    "produces": [
        "application/json"
    ],
    "securityDefinitions": {
        "oauthAccessCode": {
            "type": "oauth2",
            "authorizationUrl": "https://hogehoge/oauth/authorize",
            "tokenUrl": "https://hogehoge/oauth/token",
            "flow": "accessCode",
            "scopes": {
                "user": "User scope"
            }
        },
        "oauthImplicit": {
            "type": "oauth2",
            "authorizationUrl": "https://hogehoge/oauth/authorize",
            "flow": "implicit",
            "scopes": {
                "user": "User scope"
            }
        }
    },
    "host": "hogehoge",
    "tags": [
        {
            "name": "users",
            "description": "Operations about users"
        },
        {
            "name": "microposts",
            "description": "Operations about microposts"
        },
        {
            "name": "feed",
            "description": "Operations about feeds"
        },
        {
            "name": "relationships",
            "description": "Operations about relationships"
        }
    ],
    "paths": {
        "/api/v1/users": {
            "get": {
                "summary": "Return all users.",
                "description": "Return all users.",
                "produces": [
                    "application/json"
                ],
                "responses": {
                    "200": {
                        "description": "Return all users.",
                        "schema": {
                            "type": "array",
                            "items": {
                                "$ref": "#/definitions/User"
                            }
                        }
                    }
                },
                "tags": [
                    "users"
                ],
                "operationId": "getApiV1Users"
            }
        },
        "/api/v1/users/{id}": {
            "get": {
                "summary": "Return user with id.",
                "description": "Return user with id.",
                "produces": [
                    "application/json"
                ],
                "parameters": [
                    {
                        "in": "path",
                        "name": "id",
                        "description": "User id.",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Return user with id.",
                        "schema": {
                            "$ref": "#/definitions/User"
                        }
                    }
                },
                "tags": [
                    "users"
                ],
                "operationId": "getApiV1UsersId"
            }
        },
        "/api/v1/users/{id}/following": {
            "get": {
                "summary": "Return following users of user with id.",
                "description": "Return following users of user with id.",
                "produces": [
                    "application/json"
                ],
                "parameters": [
                    {
                        "in": "path",
                        "name": "id",
                        "description": "User id.",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Return following users of user with id.",
                        "schema": {
                            "type": "array",
                            "items": {
                                "$ref": "#/definitions/User"
                            }
                        }
                    }
                },
                "tags": [
                    "users"
                ],
                "operationId": "getApiV1UsersIdFollowing"
            }
        },
        "/api/v1/users/{id}/followers": {
            "get": {
                "summary": "Return followers of user with id.",
                "description": "Return followers of user with id.",
                "produces": [
                    "application/json"
                ],
                "parameters": [
                    {
                        "in": "path",
                        "name": "id",
                        "description": "User id.",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Return followers of user with id.",
                        "schema": {
                            "type": "array",
                            "items": {
                                "$ref": "#/definitions/User"
                            }
                        }
                    }
                },
                "tags": [
                    "users"
                ],
                "operationId": "getApiV1UsersIdFollowers"
            }
        },
        "/api/v1/users/{id}/microposts": {
            "get": {
                "summary": "Return microposts of user with id.",
                "description": "Return microposts of user with id.",
                "produces": [
                    "application/json"
                ],
                "parameters": [
                    {
                        "in": "path",
                        "name": "id",
                        "description": "User id.",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Return microposts of user with id.",
                        "schema": {
                            "type": "array",
                            "items": {
                                "$ref": "#/definitions/Micropost"
                            }
                        }
                    }
                },
                "tags": [
                    "users"
                ],
                "operationId": "getApiV1UsersIdMicroposts"
            }
        },
        "/api/v1/microposts": {
            "post": {
                "summary": "Create a new micropost.",
                "description": "Create a new micropost.",
                "produces": [
                    "application/json"
                ],
                "consumes": [
                    "application/x-www-form-urlencoded"
                ],
                "parameters": [
                    {
                        "in": "formData",
                        "name": "content",
                        "description": "Your micropost.",
                        "type": "string",
                        "required": true
                    }
                ],
                "responses": {
                    "201": {
                        "description": "Create a new micropost.",
                        "schema": {
                            "$ref": "#/definitions/Micropost"
                        }
                    }
                },
                "tags": [
                    "microposts"
                ],
                "operationId": "postApiV1Microposts"
            }
        },
        "/api/v1/microposts/{id}": {
            "delete": {
                "summary": "Destroy micropost with id.",
                "description": "Destroy micropost with id.",
                "produces": [
                    "application/json"
                ],
                "parameters": [
                    {
                        "in": "path",
                        "name": "id",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "204": {
                        "description": "Destroy micropost with id."
                    }
                },
                "tags": [
                    "microposts"
                ],
                "operationId": "deleteApiV1MicropostsId"
            }
        },
        "/api/v1/feed": {
            "get": {
                "summary": "Return all users.",
                "description": "Return all users.",
                "produces": [
                    "application/json"
                ],
                "responses": {
                    "200": {
                        "description": "Return all users.",
                        "schema": {
                            "type": "array",
                            "items": {
                                "$ref": "#/definitions/Micropost"
                            }
                        }
                    }
                },
                "tags": [
                    "feed"
                ],
                "operationId": "getApiV1Feed"
            }
        },
        "/api/v1/relationships": {
            "post": {
                "summary": "Create relationship between user with followed_id.",
                "description": "Create relationship between user with followed_id.",
                "produces": [
                    "application/json"
                ],
                "consumes": [
                    "application/x-www-form-urlencoded"
                ],
                "parameters": [
                    {
                        "in": "formData",
                        "name": "followed_id",
                        "description": "The ID of the user for whom to be followed.",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "201": {
                        "description": "Create relationship between user with followed_id."
                    }
                },
                "tags": [
                    "relationships"
                ],
                "operationId": "postApiV1Relationships"
            },
            "delete": {
                "summary": "Destroy relationship between user with followed_id.",
                "description": "Destroy relationship between user with followed_id.",
                "produces": [
                    "application/json"
                ],
                "parameters": [
                    {
                        "in": "query",
                        "name": "followed_id",
                        "description": "The ID of the user for whom to be unfollowed.",
                        "type": "integer",
                        "format": "int32",
                        "required": true
                    }
                ],
                "responses": {
                    "204": {
                        "description": "Destroy relationship between user with followed_id."
                    }
                },
                "tags": [
                    "relationships"
                ],
                "operationId": "deleteApiV1Relationships"
            }
        }
    },
    "definitions": {
        "User": {
            "type": "object",
            "properties": {
                "id": {
                    "type": "integer",
                    "format": "int32",
                    "description": "User ID."
                },
                "name": {
                    "type": "string",
                    "description": "User name."
                },
                "email": {
                    "type": "string",
                    "description": "User email address"
                }
            },
            "required": [
                "id",
                "name",
                "email"
            ],
            "description": "Return followers of user with id."
        },
        "Micropost": {
            "type": "object",
            "properties": {
                "id": {
                    "type": "integer",
                    "format": "int32",
                    "description": "Micropost ID."
                },
                "content": {
                    "type": "string",
                    "description": "Micropost content."
                },
                "user_id": {
                    "type": "integer",
                    "format": "int32",
                    "description": "User ID of the micropost."
                },
                "picture": {
                    "$ref": "#/definitions/Picture",
                    "description": "Picture info added to the micropost."
                }
            },
            "required": [
                "id",
                "content",
                "user_id",
                "picture"
            ],
            "description": "Return all users."
        },
        "Picture": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "URL of picture.(It will be `null` if there are no picture.)"
                }
            },
            "required": [
                "url"
            ]
        }
    }
}

これがあれば、Swagger UI を生成(GrapeSwaggerRails で実装済み) したり、スタブサーバーを生成したり、クライアント実装を生成したり、なんでもござれ。

その他

Doorkeeper, grape, grape-swagger, Grape::Entities, GrapeSwaggerRails と使用しているため、自分が今やりたいことが何か、それを実現する方法を知るために調べるべきはこれらのうちどれか、を意識しないとあっという間に迷子になる。

重複コードが多数あるが、テストを実装して徐々にリファクタリングしていくとしよう。

Swagger の JSON 定義ができたのでスマホアプリ向けにクライアントコード生成してスマホアプリ作っていこうかな。