LoginSignup
12
4

中くらいのWEBサービスにおけるREST APIのURL設計のマイベストプラクティス

Last updated at Posted at 2023-05-25

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
    • print
  • 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=popularfilterは全件返さない時
GET sample.com/api/v1/users?sort=popularorderは返す順番を指定したい時
と候補が浮かんだ場合は、まず
GET sample.com/api/v1/users?filter=popularGET 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/は以下のようなコードになるはずです。

controllers/users_controller.rb
...
def index
    users = User.all
    ...
end
...

これを下記のようにできるか試しましょう。

controllers/users_controller.rb
...
def index
    users = User.filter_by(params[:filter])
                .sort_by(params[:order])
                .filter_period_from(params[:period])
    ...
end
...
models/user.rb
...
scope :filter_by, -> (filter_name) {
    case filter_name
    when "popular"
      where(follower_count: 100..) #100人以上フォロワーがいる人を抜き出す
    when "active"
      ...
    else
      all
    end
}
    ...
end
...

しかし、統一を試みた結果、以下のようになってしまうこともあります。

controllers/users_controller.rb
...
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, 実装にワイルドカードを使用する点

返す情報によってエンドポイントを分けてしまう + そのまま分けてしまう(非推奨)

controllers/users_controller.rb
...
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ならば、

models/simple_user.rb
class SimpleUser < User
    self.ignored_columns = ["hoge_column", "fuga_column"]
end

このようなコードを書くと、より丁寧に概念を表現できるでしょう。
その後、こうなります。

controllers/simple_users_controller.rb
...
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を利用して書くときは以下のようになるかと思います。

サンプル.js
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側はこんな感じ

controllers/users_controller.rb
...
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_jsonapiactive_model_serializersを使うと良いでしょう。
grapeを使っているならば、grape-entityを使うと良いでしょう。

fast_jsonapiでの例.rb
options[:include] = [:blogs, :'blogs.title', :videos]
UserSerializer.new([user, user], options).serialized_json

active_model_serializersでの例.rb
render json: users, include: [:blogs, :videos], status: 200

のようなコードを利用して、

長くて読みにくかったコード.rb
    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. 参考文献とその理由

書きかけ

12
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4