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
desc
に is_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 と使用しているため、自分が今やりたいことが何か、それを実現する方法を知るために調べるべきはこれらのうちどれか、を意識しないとあっという間に迷子になる。
重複コードが多数あるが、テストを実装して徐々にリファクタリングしていくとしよう。
- Testing protected controllers · doorkeeper-gem/doorkeeper Wiki
- ruby-grape/grape Writing Tests with Rails
Swagger の JSON 定義ができたのでスマホアプリ向けにクライアントコード生成してスマホアプリ作っていこうかな。