TL;DR
- ベストプラクティスはありません(その理由を語っています)
- でもマイベストプラクティスを作ったので公開します。
- URL設計で困ったらとりあえずこれを参考にしておけ!
目次
1.どんなときにAPIを利用するの
2.REST APIってなんだっけ
3.なぜあまり他社のAPI設計は参考にならないのか
4.チームで決定してドキュメントに残そう
5.【黄金律】できるだけエンドポイントを減らせ
6.【黄金律】定めた例外以外動詞は使うな
7.【黄金律】リソース群を返すときに、複雑な処理をするときだけ形容詞を使え
8.ログインしている自分の〇〇を返すときは、users/me/〇〇s
9.fieldsを実装しよう
10.レスポンスを統一しよう
11.APIのバージョンを変化させるのはいつ
12.参考文献とその理由
1. どんなときにAPIを利用するの
色々な人がそのサービスのリソースを利用して、二次利用するとき
例) Twitter
例) 社内マイクロサービス
フロントエンドとバックエンドをきちんと分けたいとき
例) Rails(バックエンド)×React(フロントエンド)
アプリと連携したいとき
例) iOSの通信先として、PHPサーバーを建てる
上記のケースに該当しない場合は、小さいサービスの可能性があります。参考にならないかもしれません。
例) 利用しているリソースの一部の特別な処理だけ(基本は全部formだけど、ちょっとだけjsでリソース触りたい)
例) サーバー・ジョブに関係する部分だけ
2. REST APIってなんだっけ
- ①統一インターフェース
- ②アドレス可能性
- ③接続性
- ④ステートレス性
このうち、④ステートレス性に悩まされるケースはままあります。
ログインできるシステムの場合、ログインしている時としていない時で返ってくる値が違うケースが頻発するからです。
RESTのアンチパターンではありますが、現実のビジネス上避けて通れないときは、悩みすぎないようにしましょう。
3. なぜあまり他社のAPI設計は参考にならないのか
- どこも探り探り行った上で、エンドポイントばかりが目立ってしまうため、なぜそのエンドポイントの命名を選んだのかの背景が説明されてない
- 同じようにして、レスポンスの形が決まっているため、なぜそのレスポンスの形が良いのかの説明がされてない
- 利用しているライブラリによる制限があるケースもあります。
- 大規模で外部の開発者に広く公開しているAPIの設計ばかりが目立つため、目的が違うため
じゃあどうするの?
チームでルール化してドキュメントに残すのです。
4. チームで決定してドキュメントに残そう
ドキュメントに残すのは、ルールとその適用例、そしてそのルールの作成背景(歴史)です。有名なサイト何個か見るだけで、自分たちのルールとは異なっている設計をしているところを見ると思います。自分たちのプロジェクトで確固たるルールを決めましょう。
そして、URL設計において、このルールを破ったURLは設計してはいけません。どうしても外れたい場合は、新しいルールを作成し、ドキュメントに記載してください。
自プロジェクトのAPI設計のドキュメントに書いてあるルールを以下5~11に記載しました。このルールをあなたのAPI設計のルールに組み込んでも構いません。黄金律と書いているルールは、どんなAPIでもこうした方がいいと私が強く信じているルールです。
5.【黄金律】できるだけエンドポイントを減らせ
6.【黄金律】定めた例外以外動詞は使うな
7.【黄金律】リソース群を返すときに、複雑な処理をするときだけ形容詞を使え
8.ログインしている自分の〇〇を返すときは、users/me/〇〇s
9.fieldsを実装しよう
10.レスポンスを統一しよう
11.APIのバージョンを変化させるのはいつ
5. 【黄金律】リソースはDBにおけるテーブル単位にし、正しい英語を使え
APIのURLの基本は、ドメイン名/api/v1/*
です。(バージョンの話は後述)
サブドメインにapiの文字を入れるのもありです。ここらへんは良し悪し知りません。知っている方教えて下さい。
RESTの基本パターンで命名しよう
例)
GET sample.com/api/v1/users
でユーザーの一覧を取得
POST sample.com/api/v1/users
でユーザーを作成
GET sample.com/api/v1/users/:id
で特定ユーザーの詳細を取得
PUT sample.com/api/v1/users/:id
で特定ユーザーを更新
PATCH sample.com/api/v1/users/:id
で特定ユーザーを更新
DELETE sample.com/api/v1/users/:id
で特定ユーザーを削除
PUT/PATCHの使い分けはあまり厳密には知りません。私はPATCHばかり利用します。
大事なのは、usrs
ではなくusers
です。略称は使いません。わかりにくいからです。
また、user
ではなくusers
です。リソースは複数形にします。理由はこちらです。
例)
ある1人のユーザーを取得したいとき
×GET sample.com/api/v1/user/:id
○GET sample.com/api/v1/users/:id
やりたいことがわかる最低のエンドポイントにする
例) ある1人のユーザーのコメント一覧を取得したいとき
×GET sample.com/api/v1/users/:id/all_comments
×GET sample.com/api/v1/users/:id/get_comments
○GET sample.com/api/v1/users/:id/comments
5. 【黄金律】できるだけエンドポイントを減らせ
androidとiOSとwebでエンドポイントを分けない
似たコードが多いくせに、複数箇所を見なければならなくなるので、メンテナンスコストが上がります。
例えばもし、iOSとwebとであまりに違うコードになるならば、できるだけAPIは統一して、クライアント側の実装を見直す方向性が良いと思います。クライアントはどうせ通信して返ってきた値を処理するので、クライアント側は常にクライアントの数だけコードが必須です。
ある1つのエンドポイントにおいて、クライアントがn個あるならば、そのAPIに関わるコードは n+1(このときの1はAPIのコード)が最低です。n+nにしたいとは私は思いません。
このときに使える方法は、
- 通信時に独自のHeaderを追加したり、ユーザーエージェントを見ることでクライアントを判別する
CRUD以外のエンドポイントを基本的(※)には使わない
これを宣言することで、減らせるでしょう。
例)userとtweetと中間であるfavoriteテーブルがある場合に、ツイートのlikeを押したり消したりする際のURL
×POST sample.com/api/v1/tweets/:id/favorites/toggle
×POST sample.com/api/v1/favorites/:id/switch
○POST sample.com/api/v1/tweets/:id/favorites
×DELETE sample.com/api/v1/tweets/:id/favorites
(間違いやすいので注意。これはあるツイートに関連するfavorite全てを削除するURLである)
○DELETE sample.com/api/v1/favorites/:id
基本的と書いたのは、例外や工夫の具体例はこの後のルールで山程出てきます。
6. 【黄金律】定めた例外以外動詞は使うな
定めるべき例外動詞は
- login周り(login/sign_up/sign_in/refresh)
- search
- 外部サービスを使う系
- transform
- ok/ngを返す系
- validate
- notify
くらいなものです。
これ以外の動詞を使いたくなったら、まずはそれを動詞としてAPIに組み込んでいる他サービスがあるかを調べたりして、名詞(リソース)で表現できないか考えましょう。
注意するリソース名
- likes → likesは好きなものだが、likeは名詞としては好きなものという意味はない。favoriteを使う
7. 【黄金律】リソース群を返すときに、複雑な処理をするときだけ形容詞/過去分詞を使え
できるだけsort,filter,periodで解決できないか考える
例)
人気ユーザー達を取得したいときに
×GET sample.com/api/v1/popular_users
理由:popular_usersというテーブルはないのでbad
△GET sample.com/api/v1/users/popular
△GET sample.com/api/v1/users?filter=popular
filterは全件返さない時
△GET sample.com/api/v1/users?sort=popular
orderは返す順番を指定したい時
と候補が浮かんだ場合は、まず
GET sample.com/api/v1/users?filter=popular
やGET sample.com/api/v1/users?sort=popular
が実装できるかを考えてみてください。
parameterとして与えられた文字列"popular"を利用して
GET sample.com/api/v1/users
のAPIコードに入れ込めるか考えてみてください。
例として、Ruby on Railsのコードを出します。
もともと、GET sample.com/api/v1/users/
は以下のようなコードになるはずです。
...
def index
users = User.all
...
end
...
これを下記のようにできるか試しましょう。
...
def index
users = User.filter_by(params[:filter])
.sort_by(params[:order])
.filter_period_from(params[:period])
...
end
...
...
scope :filter_by, -> (filter_name) {
case filter_name
when "popular"
where(follower_count: 100..) #100人以上フォロワーがいる人を抜き出す
when "active"
...
else
all
end
}
...
end
...
しかし、統一を試みた結果、以下のようになってしまうこともあります。
...
def index
if params[:filter] == "popular"
users = ...
else
users = User.all
...
end
...
end
...
このような場合には、同じエンドポイントを利用しているメリットより、ネストが深くなり、コードが長くなるデメリットが上回ると考えますので、このときにやっと
GET sample.com/api/v1/users/popular
を使いましょう。
なお、このような場合は、GET sample.com/api/v1/users/〇〇
の〇〇に入るのは形容詞/過去分詞であるべきです。
recommended users
,popular users
のように、英語にしたときにusersを修飾している語です。
8. ログインしている自分の〇〇を返すときは、users/me/〇〇s
ここからは黄金律ではないので、参考程度にとどめてもらえると幸いです。
例)
ログインしているユーザーのコメント一覧を取得したいとき
△GET sample.com/api/v1/users/:id/comments
○GET sample.com/api/v1/users/me/comments
メリット
- 自分のリソースと他人のリソースを扱う時には返したい情報が違うことがある。(例:下書きのコメントも見せたいなど)
- 自分のを取得したい場合と他人のを取得したい時は状況が異なる上に、自分のを取得したいタイミングは多いため、アクセス解析時に同じURLにすることで、この状況を判別できるようになる。
- 同じurlでもheader情報(ログイン済み/未ログイン)によって返ってくる情報が違うことを防げる。(冪等性)
デメリット
- 似たような情報を返すのに、別のエンドポイントを用意するため、保守コストが上がっている。
9. fieldsを実装しよう
レスポンスに返す内容を指定するための機能です。
レスポンスに返す内容を必要な情報だけにしたいケースはあると思います。
例)
ケースA:あるユーザーのプロフィール画面を開いたケース
ケースB:コメント一覧ページに乗っている、ユーザーアイコンをタッチしたときに出るポップアップを出すケース
この両方でGET sample.com/api/v1/users/:id
を叩くとします。
この時、ケースAではユーザーの様々な情報を返したいが、ケースBでは名前、アイコン、フォロー数、フォロワー数だけで良いことがあります。
このような場合、以下が考えられます。だめな例から挙げていきます。
返す情報をエンドポイントにくっつける(非推奨)
GET sample.com/api/v1/users/:id/name,icon,follow_count,follower_count
や
GET sample.com/api/v1/users/:id/name&icon&follow_count&follower_count
2点においてこのコードはダメです。
1, こちらにあるような、 ワーストプラクティスである,
を使っている点
2, 実装にワイルドカードを使用する点
返す情報によってエンドポイントを分けてしまう + そのまま分けてしまう(非推奨)
...
def show # GET sample.com/api/v1/users/:id に対応
user = User.find(params[:id])
render json: user
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def simple_show # GET sample.com/api/v1/users/:id/simple に対応
user = User.find(params[:id])
render json: { name: user.name, icon: user.icon, follow_count: user.follow_count, follower_count: user.follower_count }
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
...
作りやすく、こちらに逃げたくなるのですが、こちらは非推奨です。
シンプルで見やすく、わかりやすいように思うのですが、非推奨です。
非推奨の理由は、
1, サービスが育つに連れ、何個も作ることになる(5,6個はざら)。
2, 前提条件が変わったりしたときに全てのエンドポイントに対応しなければならない
例)User.active.find(params[:id])
→なので、抜け漏れが発生しやすいし、テストも全てに対応する必要がある
3, そもそも似たコードなのに分けているため、冗長になる
→なので、テストコードも大量になる
4, CRUDだけで表現していないため、命名ルールが変になる。
例) じゃあ今度はname,icon,ageだけを返すshowが欲しい!…なんて名付けよう…
返す情報によってエンドポイントを分けてしまう + SimpleUserの概念を作る(元が巨大なデータを持つ概念の場合に推奨のケース有り)
GET sample.com/api/v1/simple_users/:id
これは、テーブルには存在しないが、view tableを作っているのに近いです。Ruby on Railsならば、
class SimpleUser < User
self.ignored_columns = ["hoge_column", "fuga_column"]
end
このようなコードを書くと、より丁寧に概念を表現できるでしょう。
その後、こうなります。
...
def show # GET sample.com/api/v1/simple_users/:id に対応
simple_user = SimpleUser.find(params[:id])
render json: simple_user
rescue ActiveRecord::RecordNotFound
render json: { error: 'SimpleUser not found' }, status: :not_found
end
...
通信におけるEntityやSerializerとしてSimpleUserの概念を定義するのもありかもしれません。
1つ上のケースとコード量はあまり変わらないですが、私はまだこちらのほうが好きです。
返す情報をパラメーターにわたす(推奨)
GET sample.com/api/v1/users/:id?fields[]=name&fields[]=icon&fields[]=follow_count&fields[]=follower_count
vue.jsでaxiosを利用して書くときは以下のようになるかと思います。
async hogehoge() {
this.Users = await axios
.$get(`/users/${this.id}`, {
params: {
fields: ["name", "icon", "follow_count", "follower_count"],
limit: 10, //この行は例
},
})
.then((res) => res.data)
.catch(() => {
return [];
});
},
API側はこんな感じ
...
def show # GET sample.com/api/v1/users/:id に対応
user = User.find(params[:id])
if params[:fields].present?
fields = params[:fields].split(',') # カンマで区切られた文字列を配列に変換
attributes = user.attributes.slice(*fields) # 指定された属性のみを取得
# ブログ情報の取得
if fields.include?('blogs')
attributes['blogs'] = user.blogs.map do |blog|
blog_attributes = blog.attributes.slice('id', 'title', 'content', 'created_at', 'updated_at')
blog_attributes['comments'] = blog.comments.map { |comment| comment.attributes.slice('id', 'content', 'created_at', 'updated_at') }
blog_attributes
end
end
# 動画情報の取得
if fields.include?('videos')
...
end
render json: attributes
else
render json: user
end
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
...
メリット
- 1つに収まってエンドポイントがシンプル
- テストも書きやすい
デメリット
- GET時のクエリがとにかく長い
- このようなクエリの実装は言語によって少し異なります。
- post時とかはbodyに入るので汚くは見えません。
- GET時にbodyを使うことについては過去から議論があります。
注意点
- Userのattributesに機密情報等を乗せている際、ハックされないようにバリデーションとかフィルタリングとかしてください。
- 関数内が長い → 後述のgemで解決
(gemを使わないなら、params[:fields]からattributesの作成はmodel内に切り分けて書くと良い)
実際のケースでは、serializerを使うのが良い(推奨)
fast_jsonapiやactive_model_serializersを使うと良いでしょう。
grapeを使っているならば、grape-entityを使うと良いでしょう。
options[:include] = [:blogs, :'blogs.title', :videos]
UserSerializer.new([user, user], options).serialized_json
や
render json: users, include: [:blogs, :videos], status: 200
のようなコードを利用して、
attributes = user.attributes.slice(*fields) # 指定された属性のみを取得
# ブログ情報の取得
if fields.include?('blogs')
attributes['blogs'] = user.blogs.map do |blog|
blog_attributes = blog.attributes.slice('id', 'title', 'content', 'created_at', 'updated_at')
blog_attributes['comments'] = blog.comments.map { |comment| comment.attributes.slice('id', 'content', 'created_at', 'updated_at') }
blog_attributes
end
end
# 動画情報の取得
if fields.include?('videos')
...
end
と長くて読みにくかった部分をgemでシンプルにしましょう。
10. レスポンスを統一しよう
書きかけ
11. APIのバージョンを変化させるのはいつ
12. 参考文献とその理由
書きかけ