Web API の開発時に最低限覚えておくべきこと
最近、仕事ではバックエンド側で API(当ページの文脈では Web API1 を指す)を実装し、それをフロントエンド側で利用するという行為をひたすら続けている。
基本的によく使う API のリファレンス(Stripe API や Shopify Admin API など)や本を参考に API を組み立てている。 しかし、毎回参照するのは非効率的なので自分用に備忘録としてまとめることにした。
今回ベースとなるのはオライリーから出版されている「Web API: The Good Parts」である。 発売日は 2014 年と古いが Web API の性質上、古くなって使えないという箇所はほぼなく、特に違和感なく読み進められる。 基本が網羅されており、名著だと思う。
API は単にインタフェースに過ぎないため、その設計にあまり時間をかけるべきではない、可能な限りビジネスロジックの実装に時間を費やしたいと思っていた。 しかし、最初の設計を誤まると後々その負債が増大していくことを体験したので軽視できない。 このページでは今後そういったことがないように、自戒を込めて書き留めてゆく。
1. エンドポイントの設計とリクエストの形式
この章は API を扱う上で基本中の基本となる部分である。 何らかの開発に携わっている人であれば自然に身に付いている知識だと思う。 特に悩まずにこのとおりに実装すれば大抵は問題ない。
誰に向けた API を作るのか
- LSUDs(Large Set of Unknown Developers)
- 未知の多数の開発者向けの API
- SSKDs(Small Set of Known Developers)
- 既知の少数の開発者向けの API
上記は API がターゲットにするのはどんな開発者なのかということを表すときに使われている用語である。 API を設計する上ではどちらを対象にするかを意識しなければならない。
このページでは基本的には LSUDs を対象に書いているが、SSKDs 向けの設計方法についても記載している。 SSKDs 向けの場合はその旨を記してある。
必要な機能を抽出する
API を設計するにあたり、クライアントアプリケーションの画面とその遷移をまずは考える。 例えば、図にして書き出すと整理しやすい。その際は MECE(漏れなく、ダブりなく)になるようにする。 その図を元に必要な機能を列挙していく。これがエンドポイントの元になる。
エンドポイントの基本的な設計
エンドポイントを設計するときには覚えやすく、どんな機能をもつ URI なのかがひと目でわかるようにする。 具体的には以下の事項を考慮する。
- 短く入力しやすい URI
- 人間が呼んで理解できる URI
- 省略形を含まない
- 国コードなど標準化されたものは例外
- 大文字小文字が混在していない URI
- 小文字に統一する
- 改造しやすい URI
- ドキュメントを見なくても推測可能
- サーバ側のアーキテクチャが反映されていない URI
- ルールが統一された URI
- ex. ID をパスに入れるのかクエリパラメータに入れるのか
HTTP メソッド
URI とメソッドの関係は、操作するものと操作方法の関係である。 1 つの URI のエンドポイントに異なるメソッドでアクセスすることで、リソースをどう扱うかをきちんと分離できる。 HTML は Form において POST と GET しか使用できない2。 その名残のせいなのか API においても更新・削除で POST を使っているケースがたまに見受けられるが、可能であればそれ以外(以下を参照)も積極的に使用したい。
メソッド名 | 説明 |
---|---|
GET | リソースの取得 |
POST | リソースの新規登録 |
PUT | 既存リソースの更新 |
DELETE | リソースの削除 |
PATCH | リソースの一部変更 |
HEAD | リソースのメタ情報の取得 |
※ PUT は送信したデータで元々のリソースを置き換えるものであるのに対し、PATCH ではその一部だけを更新したい場合に使用する。
HTML の Form では GET と POST しか使えないが、他にもクライアントによっては HTTP メソッドの利用が制限される場合がある。 その場合は API 側で GET/POST 以外のメソッドを POST を使って表現することを許可できる。
具体的には 2 つの方法がある。
まず 1 つ目は X-HTTP-Method-Override
という HTTP リクエストヘッダを使う方法、2 つ目は _method
というパラメータを利用する方法である。
_method
を使う場合は application/x-www-form-urlencoded
という Content Type で表されるデータの一部として送信する。
これは Ruby on Rails などが採用している方法である3。
しかし、上記の形式(application/x-www-form-urlencoded
)以外ではデータを送信できない(使い方を明確に定義できない)ため、できれば X-HTTP-Method-Override
を使用する方が良い。
また、リクエストデータの中にデータ以外のメタ情報が入ってしまうのは送信データを分類するという意味においてあまり好ましくない。
X-HTTP-Method-Override
を使う場合は以下のように HTTP リクエストヘッダに利用したいメソッドを指定するだけ。
POST /posts HTTP/1.1
Host: example.com
X-HTTP-Method-Override: PUT
サーバ側のフレームワークやミドルウェアによっては上記のヘッダを自動的に解釈する場合もあるので有効に使える方法を選択する。 当然、フレームワークが対応していない場合は自前で実装する。
API のエンドポイント設計
リソースにアクセスするためのエンドポイントを設計する際は以下の点に注意する。
- 複数形の名詞を利用する
- 利用する単語に気をつける
- ex.
find
ではなくsearch
を使う4
- ex.
- スペースやエンコードを必要とする文字を使わない
- パーセントエンコーディング された文字が入らないようにする
- 単語をつなげる必要がある場合はハイフンを利用する
- 絶対という訳ではなく、プロジェクトに準ずる
- そもそも単語のつなぎ合わせを避ける設計にする
検索とクエリパラメータの設計
たくさんあるデータの一部を取得する際にはページネーションの仕組みを利用する。
page/per_page
や offset/limit
のようなクエリパラメータを設定するのが一般的。
例えば、数あるアイテムの中から 101 アイテム目から取得したい場合は以下のようにする。
per_page=50&page=3
limit=50&offset=100
offset/limit
の方が自由度は高いが、要件によってどちらにするかを決める。
ただし、上記のような相対的な取得位置でデータを取得する方法にはパフォーマンス上の懸念がある5。
また、更新頻度の高いデータにおいてデータの不整合が生じるという問題もあるため慎重に導入を決めなければならない。
絶対位置でデータを取得する場合は相対位置での問題点をカバーできる。 例えば、「この ID よりも前のもの」や「この時刻よりも古いもの」のような指定方法を用いる。
クエリパラメータはその他に、絞り込みのために使用する。
検索するフィールドがほぼ 1 つに決まる場合は q
というパラメータが使われる場合もある(Google の検索結果など)。
クエリパラメータとパスの使い分けは一意なリソースを表すのに必要な情報かどうかで判断する。 これは URI がリソースを表すものであるという URI の思想からきている。 また、省略可能な場合はクエリパラメータの方が適している。
自分の情報を取得したい際は自分のユーザ ID をいちいち指定するのは煩雑なので me
や self
といったキーワードをパスに入れてエイリアスとして利用する(users/me
)。
OAuth 2.0 -> 別記事でまとめる。
ホスト名とエンドポイントの共通部分
example.com
というサービスで API を提供する際のホスト名は特に制約がなければ api.example.com
にするのが適切である。
URI はシンプルで短ければ短いほど良い。
SSKDs 向けの API デザイン
LSUDs 向けの API ではなるべく汎用的でわかりやすく使いやすい API の設計が最も重要である。 SSKDs 向けの場合でも基本原則は同じであるが、エンドユーザにとってのユーザ体験も考える必要がある。
例えば、トップページに新着商品や人気の商品、ユーザ情報などのデータを使う場合にそれぞれ API を投げるのは非効率である。 これは良いユーザ体験とは言えない。したがって、こういった場合ではトップページ表示用 API を作ってそれに 1 回アクセスするだけで全ての情報を取得できるようにする方が利便性は高くなる。
なお複数のクライアントアプリケーションに API を提供する場合はユースケースが多すぎて管理が大変になってしまうことは予測できる。 その場合は後述するオーケストレーション層を挟む方法がある。
HATEOAS
Martin Fowler 氏による「Richardson Maturity Model」という記事によれば、素晴らしい REST API に至るための設計レベルには以下のようなものがある。
- REST LEVEL0 - HTTP を使っている
- REST LEVEL1 - リソースの概念の導入
- REST LEVEL2 - HTTP の動詞(
GET/POST/PUT/DELETE
など)の導入 - REST LEVEL3 - HATEOAS の概念の導入
LEVEL2 までは理解できるが、LEVEL3 の HATEOAS(hypermedia as the engine of application state)とはあまり聞き馴染みのない用語である。 HATEOAS とは設計方法のことで、 API の返すデータの中に、次に行う行動、取得するデータ等の URI をリンクとして含めるようにする。 そうすることで、そのデータを見れば次にどのエンドポイントにアクセスすれば良いかがわかる。
面白い概念であるが HATEOAS を使った API に今まで出会ったことがない。 HATEOAS を使うことの最大のメリットはクライアントがあらかじめ URI を知る必要がないため、URI の変更がしやすくなる点があげられる。 まだ、エンドポイントを人間が理解できる形式にする必要はないためセキュリティなどの観点から URI を想像しにくい形にできる。 LSUDs 向けの API では HATEOAS の概念が全然広まっていないことを考えるとこの先使われることはほぼないと思うが、SSKDs 向けの API ではニーズ次第で採用が可能かもしれない。
2. レスポンスデータの設計
エンドポイントの設計が終わったので続いてリクエストの結果返されるレスポンスデータをどのように設定するかについて書き留めてゆく。
データフォーマット
データフォーマットに関しては JSON にデフォルトとして対応して、需要や必要があれば XML に対応するというのが最も理に適っている。 XML の方が名前空間やスキーマ定義の仕様がきちんと決まっていたりと JSON に比べて表現力は豊かであるが大抵は JSON の構文でシンプルに表現できるので XML を使わなければならない理由はあまり存在しない。
その他に検討すべきデータフォーマットとしては MessagePack がある。 MessagePack は効率の良いバイナリ形式のオブジェクト・シリアライズフォーマットであり、JSON の置き換えとして利用できる。 SSKDs 向けの API であればパフォーマンスを重視して採用するのもありかもしれない。
複数のデータフォーマットをサポートする場合はクライアント側からデータフォーマットの形式を指定させる必要がある。 その場合は以下の方法が一般的に使われている。
- クエリパラメータを使う方法
- ex.
https://api.kkhys.me/v1/users?format=xml
- ex.
- 拡張子を使う方法
- ex.
https://api.kkhys.me/v1/users.json
- ex.
- リクエストヘッダでメディアタイプを指定する方法
リクエストヘッダを使う場合は Accept
を使用する。
GET /v1/users
Host: api.kkhys.me
Accept: application/json
3 つのうちどれを使うかだが、メディアタイプを指定する方法が HTTP の仕様を最大限活用しており、最もお行儀は良い。 ただ、LSUDs 向け API の場合は多少ハードルが高いのでクエリパラメータの指定方法もサポートすると良い。
JSONP の取り扱い
JSONP(JSON with Padding)とは JSON にそれをラップする JavaScript を付け加えたものを指す(そのため、そもそも JSON ではなく JavaScript)。 例えば以下のようなデータ。
callback({"id": 123,"name": "kkhys"})
この JavaScript を script 要素で読み込むと、データが読み込まれた際に callback
という関数が呼び出され、引数のデータが渡される。
callback
関数は JSONP を呼び出した script 要素の存在するページにあらかじめ用意しておく必要がある。
現在はクロスオリジンで通信が可能な環境が整ったため使われることはほぼないし、サポートする必要もないと思うが、なぜ JSONP が流行ったのかについては知っておく必要がある。
その理由として XMLHttpRequest では 同一オリジンポリシー の制限によって、同じオリジンへのアクセスしか行うことができないからである。 しかし、script 要素は同一オリジンポリシーの規制の対象外のため、JSON を script 要素を使って JavaScript として読み込めば、ドメインを超えたアクセスが可能になる。 いわば、規制を回避するために苦肉の策で生み出されたテクニックである。
データの内部構造の考え方
API で返すレスポンスデータを決定する際にまず考えるべきことは、API のアクセス回数がなるべく減るようにすることである(→ Chatty API を避ける)。 API のアクセス回数が増えると HTTP のオーバーヘッドが上がり、アプリケーションの速度が低下する。 まだ、サーバ側の負荷も増加してしまうなどメリットは何もない。
API のバックエンドのデータ構造から考えるとテーブルの内容をただそのまま返すだけというのは何らかの問題がある可能性があるため設計の見直しを行う。 Web API は単なるデータベースのアクセスインタフェースではなく、アプリケーションのインタフェースであるということを意識する。
ただ、API のアクセス回数を減らすためにできる限り多くのデータを返すようにすると、今度は必要以上に大量のデータをクライアントが受け取らなければならなくなってしまう。 その場合は取得する項目をユーザが選択可能にする方法を採用する。
例えば以下のようなクエリパラメータをフィールドとして設定可能にする。
https://api.kkhys.me/v1/users/1?fields=name,age
エンベロープについて
エンベロープとは API のデータ構造の文脈で言うと、全てのデータを同じ構造で包むことを言う。 例えば、API レスポンスにステータスなどのメタデータをエンベロープとして含むような API は実際にあるかもしれないが、これは冗長なのでやるべきではない。 なぜなら HTTP 自体がエンベロープの役割を果たしているからである。 メタデータをレスポンスに含みたければ HTTP ヘッダに入れて返せば良い。
メタデータ以外のデータでも可能な限り階層構造を使って表さずにフラットな状態として返すほうが JSON のデータサイズは小さくなるため不要な階層化はするべきではない。
ただし、例外として API で配列を返したい場合はレスポンス全体をオブジェクトにしてその中に配列を入れる。 そうすることでレスポンスデータが何を示しているものかがわかりやすくなるし、レスポンスデータをオブジェクトに統一できる。 また、トップレベルが配列である JSON は JSON インジェクション6のリスクが大きくなることを考えると常にオブジェクトで返す方が良い。
配列の件数、あるいは続きがあるかどうかの情報
配列を返すデータでは絶対位置での指定を行う設定にする場合がある。 その場合にデータが全体で何件あるかという情報を使う場合も多いが、処理が重くなりがちなので本当に必要かどうかは検討しなければならない。
そして、全件数を取得しない場合はページネーションで取得することになるが、その場合は今取得したデータには続きがあるのかという情報が必要になる。
よくある手法では hasNext
と言うパラメータを返すパターンがある。
また、nextPageToken
(Google の API でよく見る)のように次のページを取得するためのトークンを実装する方法もある。
各データのフォーマット
各データ項目の名前は以下の考え方に基づいて命名する。
- 多くの API で同じ意味に利用されている一般的な単語を用いる
- なるべく少ない単語数で表現する
- ex.
registrationDateTime
→registeredAt
- ex.
- 複数の単語を連結する場合、その連結方法は API 全体を通して統一する
- キャメルケース or スネークケース
- JavaScript で利用することを考えるとキャメルケースの方が適切
- 変な省略形は極力利用しない
- 単数形/複数形に気を付ける
性別のデータをどう表すか
sex
→ 生物学的な性別gender
→ 社会的・文化的性別
上記の定義からもわかるように、医療形のサービスのような生物学的な性別が必要な場合は sex
を使い、それ以外のサービスは gender
を使う。
日付のフォーマット
基本的には LSUDs をターゲットにした API を作成する場合は RFC 3339 を使えば良い。 RFC3339 は事実上、ISO 8601 のサブセットである。いまいち違いが分かりづらい場合は以下のサイトが参考になる。 私たちが ISO 8601 として使っているものは大抵 RFC 3339 にも含まれている。
SSKDs 向けの API を作成する場合は比較や保持が非常に容易でサイズも小さい Unix タイムスタンプ(epoch 秒)を使うという手もある。
大きな整数
SQL における通常の整数(int/integer)では 32 ビットの大きさを扱える。 つまり、integer で数を表す場合は最大 42 億の数字を表せるが、Facebook や X など億単位のユーザを抱えるサービスで連番を降っていくと 32 ビット整数では扱えなくなってしまう。 ちなみに X は 2013 年の段階で 64 ビット整数に移行している7(参考)
JavaScript では数値を全て IEEE 754 規格に基づいた 64 ビット浮動小数として扱う。
そのため、大きな整数を扱うと誤差が出るため注意が必要。
したがって、そのような巨大な数を扱う場合は文字列として返すことで問題を回避できる。
例えば、X の場合は id
のほかに id_str
という文字列を返すようになっている。
エラーの表現
エラーを返す際に色々考えるべきことはあるが必ずしなければならないのは適切なステータスコードを返すことである。
ステータスコード | 意味 |
---|---|
100 番台 | 情報 |
200 番台 | 成功 |
300 番台 | リダイレクト |
400 番台 | クライアント最後に起因するエラー |
500 番台 | サーバサイドに起因するエラー |
データとしてエラー情報が返ってくるが 200 番台のステータスコードが返ってくるということはありえない。 とはいえ、ステータスコードだけ返ってきても何が原因でエラーが発生したのかがユーザはわからないためデバッグの手助けになるような情報を提供する。
エラーの内容を返す方法には 2 つあり、1 つは HTTP レスポンスヘッダに入れて返す方法、2 つ目はレスポンスボディに入れて返す方法である。 クライアント側の利便性を考えるとレスポンスボディにデータを入れる方法が良いかもしれない(実際、大抵の API はレスポンスボディにエラーデータが入っている)。
エラー詳細情報には API 提供側で独自のステータスコードを定義すると利用者側で分かりやすい。 また、エラー原因を開発者が調べられるようなメッセージや URL があるとさらに親切。
極力避けるべきだがどうしてもメンテナンスを実施しなければならず、サービスを停止する場合があるかもしれない。 その場合はステータスコードとして 503 を返し、HTTP ヘッダには Retry-After を使っていつメンテナンスが終わるのかを示しておく。 これを知っておけばクライアントの実装をするときもメンテナンスが終わってから API をリクエストするように実装できる。
基本的にエラー内容はなるべく具体的に正確に返すべきであるが、ログイン処理などセキュリティが絡む箇所では曖昧な方が良い。
3. HTTP の仕様を最大限利用する
Web API は HTTP 上で通信を行うので、HTTP の仕様を理解してそれを活用した方がより使い勝手の良いものとなる。 ある意味、このページの章で最も大切な部分かもしれない。
HTTP ステータスコードを正しく活用する
200 番台のステータスコードを返す際はただ 200 を返すだけではなくメソッドによって使い分ける。 例えば、GET や PUT, PATCH の場合は 200 とともに操作したデータを返し、POST の場合は 201 を返す。 DELETE した場合は特にデータを返す必要はないため、No Content を表す 204 を返す。 ただ、どのデータやステータスを返すかには諸説あるため API 全体で統一することが重要になる。
300 番台のステータスコードはリダイレクトを伝えるために利用するステータスコードである。 API の場合はウェブサイトのように URI の変更やサイトの移転に伴うリダイレクトを行うことはあまり好ましくない。 なぜなら、リダイレクトをどのように行うかはクライアントの実装次第であり、将来起こるか起こらないかわからないリダイレクトをクライアント側が実装している可能性はあまりないからである。
クライアントのリクエストに問題があった場合は 400 番台のステータスコードを返す。
ステータスコード | 名前 | 説明 |
---|---|---|
400 | Bad Request | リクエストが正しくない |
401 | Unauthorized | アクセスが禁止されている |
403 | Forbidden | 認証が必要 |
404 | Not Found | 指定したリソースが見つからない |
405 | Method Not Allowed | 指定されたメソッドは使うことができない |
406 | Not Acceptable | Accept 関連のヘッダに受理できない内容が含まれている |
408 | Request Timeout | リクエストが時間以内に完了しなかった |
409 | Conflict | リソースが矛盾した |
410 | Gone | 指定したリソースは消滅した |
413 | Request Entity Too Large | リクエストボディが大きすぎる |
414 | Request-URI Too Long | リクエストされた URI が長すぎる |
415 | Unsupported Media type | サポートしていないメディアタイプが指定された |
429 | Too Many Requests | リクエスト回数が多すぎる |
API 開発を行う際は最低でも上記の不正なリクエストに対応するステータスコードとエラーメッセージを返すようにしておきたい(大抵は時間がなくて 400 エラーとしてまとめてしまうけれども……)。
500 番台のステータスコードはサーバ側に問題があった際に返される。 発生しないに越したことはないが、どうしてもバグが混入してしまう場合もあるため、その場合はログと管理者への通知を行い再発を防止する。
キャッシュを正しく活用する
HTTP のキャッシュには以下の 2 つのタイプがある。
- Expiration Model(期限切れモデル)
- あらかじめレスポンスデータに保存期限を決めておき、期限が切れたら再度アクセスをして取得を行う
- Validation Model(検証モデル)
- 今保持しているキャッシュが最新であるかを問い合わせて、データが更新されていた場合にのみ取得を行う
キャッシュが利用可能な状態を HTTP では fresh
、そうでない場合は stale
と呼ぶ。
期限切れモデルの場合は保存期限をレスポンスに返すが、その際に 2 つの方法がある。 1 つは Cache-Control レスポンスヘッダを使う方法、もう 1 つは Expires レスポンスヘッダを使う方法である。 このどちらかを使うかは、返すデータの性質による。 例えば、天気予報のような毎日同じ時間に更新される場合は Expires でその日時を指定できる。 また、今後更新される可能性がないデータや静的データの場合は遠い将来の日時を指定することで、一度取得したキャッシュをずっと保存しておける。
一方、Cache-Control は定期更新ではないものの更新頻度がある程度限られているものや、リアルタイム性がそれほど重要でない情報を返す場合に用いられる。 max-age の計算には Date ヘッダを利用する。 Date ヘッダは HTTP の仕様により、いくつかの例外を除いて必ず付けなければならない。 その際は RFC 1123 を使用する。 その他の Cache-Control のディレクティブは こちら を参照。
期限切れモデルがレスポンスを受け取った時の情報だけを元にキャッシュの保持時間を決めていたのに対して、検証モデルは今持っているキャッシュが有効かどうかをサーバに問い合わせる。 期限切れモデルは期限が切れるまでネットワークアクセスが発生しないのに対して、検証モデルはキャッシュチェックの際にもネットワークアクセスが発生してしまう。 ただ、大きなデータであれば期限切れモデルと比べた際に検証モデルの方が優位性がある。
検証モデルを行うには、条件付きリクエストに対応する必要がある。
その際には Last-Modified
(最終更新日時)と ETag
(任意の文字列)というレスポンスヘッダを使ってレスポンスを返す。
サーバ側では送られてきた情報と現在の情報をチェックし、変更がなければ 304 ステータスと空のレスポンスボディを返し、変更があれば通常通り 200 ステータスと更新されたレスポンスボディを返す。
サーバ側が明示的な期限を与えなかった場合はクライアント(ブラウザ)が有効期限を推測・算出する。 このことを Heuristic Expiration(発見的期限切れ)と言う。 キャッシュコントロールについて一番理解しているのはサーバ側であるのは間違いないのでどれくらいキャッシュをすべきかという情報はレスポンスに含むようにする。
キャッシュをさせたくない場合は Cache-Control: no-cache
を指定する。
なお、no-cache
は厳密にはキャッシュをしないという意味ではなく、最低限「検証モデルを用いて必ず検証を行う」必要があることを意味している点に注意が必要。
機密情報などを含むデータを中継するプロキシサーバに保存してほしくない場合は no-store
を指定する。
また、キャッシュを行う際は場合によっては Vary ヘッダを使用する。 HTTP には、Accept で始まる一連のリクエストヘッダの値によってレスポンスの内容を変更する仕組みがある。 この仕組みは コンテンツネゴシエーション と呼ばれている。
コンテンツネゴシエーションを使う場合は同じ URI でもヘッダの値によっては内容が同一ではなくなるため、URI だけを見てキャッシュすると本来取得すべきデータを取得できなくなってしまう。 そういったときに Vary ヘッダを使えばどのリクエストヘッダで生成された内容をキャッシュするかどうかを指定できる。
メディアタイプの指定
HTTP のリクエスト、レスポンスでは送信するデータ本体の形式を表すためにメディアタイプを指定する必要がある。
例えば、レスポンスデータが JSON である場合は、application/json
を指定する。
その他の MIME タイプは こちら を参照。
CORS の基本的なやり取り
CORS を行うには、まずクライアント側から Origin というリクエストヘッダを送る必要がある。 このヘッダにはアクセス元となる生成元を指定する。 例えば以下のようなリクエストを行う場合。
# クライアント
https://kkhys.me
↓
# サーバ
https://api.kkhys.me
Origin: https://kkhys.me
をリクエストヘッダに追加する。
サーバ側ではあらかじめアクセスを許可する生成元の一覧を保持しておいて、Origin
ヘッダで送られてきた生成元がその一覧に含まれているかどうかをチェックする。
もし、そこに含まれていない場合は 403 エラーを返す。
もし、一覧に含まれている場合は、Access-Control-Allow-Origin に Origin
ヘッダと同じ生成元を入れて返すことで、アクセスが許可されたことを示す。
仮にアクセスしたリソースがセキュリティ上どこのページから読み込まれても問題ない場合は Access-Control-Allow-Origin
ヘッダに *
を指定する。
CORS には プリフライトリクエスト という特別なサーバへの問い合わせ方法が定義されている。 プリフライトリクエストを行うことで生成元をまたいだリクエストを行う前にそのリクエストが受け入れられるかどうかを事前にチェックできる。 CORS に対応したブラウザでは 単純リクエスト ではない場合、プリフライトリクエストを自動的に行う。
また、CORS ではユーザ認証情報を送信する際は Access-Control-Allow-Credentials ヘッダをレスポンスに含まなければならない点に注意が必要である。 これがなかった場合はブラウザが受け取ったレスポンスを拒否してしまう。
4. 変更のしやすい API 設計
Web API は通常のサービスと同様に一度公開されたらずっと同じという訳にはいかず、様々な状況に応じて変化していくものである。 その際にデータの形式そのものが変わる場合も考えられる。 こうした API の変更は非常に大変なため、あらかじめ API を設計する段階から考えておかなければならない。
例えば、API の仕様が突然変わるとその API を使っているサービスは途端にエラーを吐いて動かなくなってしまう。 自分たちのサービスで使っている API であってもブラウザのキャッシュの問題などがあるため、いきなり破壊的変更を実施することは危険だ。
API をバージョンで管理する
最も良い方法は一度公開した API をできる限り変更しないことである。 つまり、新しい API を別のエンドポイントで公開すれば良い。 そうすることで古い形式でアクセスしてきているクライアントに対してはそれまでと変わらないデータを送り、新しい形式でのアクセスには新しい形式のデータを返せる。
バージョン番号はクエリパラメータに設定したり、メディアタイプで指定する方法など色々あるが、最もよく利用されている URI のパスにバージョンを入れる方法が良さそう。 以下が例。
https://api.kkhys.me/v1/users
v1
がバージョン番号である。
バージョンを変える際の指針
バージョンを増やすほどメンテナンスコストがかかるため無闇に増やしてはいけない。 後方互換性を保つことが可能な変更は可能な限り同じバージョンでのマイナーバージョンアップで対応する。
どうしてもメジャーバージョンをアップグレードしたい場合は継続的な告知や Blackout Test(一時的に API をアクセス不可にするテスト)を行う8。
あらかじめ提供終了時の仕様を盛り込む
API が公開終了した際にはステータスコード 410(Gone)を返すようにしておく。 さらに公開終了した旨を知らせるエラーメッセージとそれらについての説明を API ドキュメントに書いておくとユーザからすればありがたい。 そうすることでクライアント側で 410 が返ってきたときの処理をあらかじめ書いておける(スマートフォンの実装であれば強制アップデートさせるなど)。
また、利用規約にはサポート期限を明記しておくとより信頼性は高まる。
オーケストレーション層
LSUDs 向けの API はなるべく汎用性のある設計にすることが求められる。 そのため、1 つのアクションを行うのに複数の API にアクセスしなければならなかったり、不要なデータも受け取らなければならなかったりするが、これはある程度仕方ないことである。 このような API を洋服のフリーサイズになぞらえて one-size-fits-all(OSFA)アプローチと呼ぶ人もいる。
一方で SSKDs 向けの API はそういった汎用性に縛られることはない。 その利用者のユースケースに合わせた使いやすい API を提供できる。 ただ、使い方が 1 つではなくいくつにも別れている場合はそれぞれに合わせて API を用意したり、維持することは大変になってくる。
その場合はサーバ側の汎用的な API とクライアントの間にオーケストレーション層を挟むことで様々なユースケースに対応できる。 BFF(Backend For Frontend)アーキテクチャで検索すると色々と事例が出てくるので参考にする。
5. 堅牢な Web API を作る
Web API は HTTP を通じて公開されるサービスであるので、通常のアプリケーションと同様に安定性やセキュリティが要求される。 また、Web アプリケーションとは異なり、機械的なアクセスを受け入れることを前提としているため、通常のアプリケーションとは異なる対策が必要になる。
HTTPS による HTTP 通信の暗号化
当然のことだが HTTP による通信は TLS によって暗号化しなければならない。 HTTPS を使うことで、API のやり取りの内容だけではなく、エンドポイント、ヘッダに含められて送られるセッション情報など全て暗号化されるためセッションハイジャックなどのハッキングが行われる危険性を大幅に減らせる。
ただし、HTTPS を使えば必ずしも安全というわけではない。 HTTPS による通信を行う場合はサーバが送ってきた SSL サーバ証明証を正しいものかどうか検証する必要がある。 それを確かめていない場合、中間者攻撃(MitM)による盗聴などが行われる危険性がある。
とはいえ、これらはクライアント側の問題であるからサーバ側でいくら対策をしようが対応が難しい問題である。 しかし、HTTPS への対応によるデメリットはほぼないため(ハンドシェイクに時間がかかるなどはある)必ず対応しておけば問題ない。
XSS への対策
XSS(Cross Site Scripting)とはユーザの入力を受け取ってそれをページの HTML に埋め込んで表示する際に、攻撃者から送られてきた悪意のある JavaScript などを実行させてしまう攻撃のことである。 XSS は Web アプリケーションだけではなく API でも実行され得るため対策が必要になる。
したがってユーザからの入力はどのような使われ方をするにしてもチェックが必要であるし¸、レスポンスの際にもデータの内容をチェックして、異常な値は削除しなければならない。 これらは Web アプリケーションを構築する場合は当然考慮しなければならないことだが、JSON などの形式でデータを返す場合はブラウザの挙動によって発生してしまう XSS が存在するため注意する。
例えば、JSON を返す API の Content-Type
の値が text/html
だった場合、この JSON を返す URI に直接ブラウザでアクセスすると、このデータは HTML として解釈されるため script
要素に含まれる JavaScript が実行されてしまう。
これを防ぐためには Content-Type
に application/json
を指定すれば良い。
ただ、これだけでは十分ではない。
なぜなら、IE(今は対応は不要だと思うが)には Content-Type
を無視して、データの内容からデータ形式を推測する Content Sniffing という機能があるからである。
この機能を無効化するためには以下のレスポンスヘッダを付与する。
X-Content-Type-Options: nosniff
このヘッダを付けることで Content Sniffing が行われなくなり、Content-Type
で指定されたメディアタイプとして解釈されるようになる。
JSON インジェクションの危険性を減らすことにも繋がるため JSON をレスポンスで返す際は必ず指定する。
X-Content-Type-Options
に対応していないブラウザでは追加のリクエストヘッダのチェックと JSON 文字列のエスケープが必要であるが、そのようなブラウザは現在ないため割愛する。
XSRF への対策
XSRF(Cross Site Request Forgery)とはユーザが悪意のあるページにアクセスした際に、その中に埋め込まれたリンクなどを経由して、全く別のサイトへのリクエストが行われ、それによってそのユーザの意図しない処理が行われることである。 API において XSRF を避けるための方法は Web アプリケーションと大きくは変わらない。
まず 1 つ目はサーバ側のデータが変化するようなアクセスに関しては GET メソッドを利用しないということである。 これにより img 要素などに攻撃用のコードを埋め込むことができなくなる。
2 つ目は、これが一般的な方法だが、 XSRF トークンを使う方法である。 送信元となる正規のフォームに、そのサイトが発行したトークンを埋め込んでおき、そのトークンがないアクセスは拒否するというものだ。
API に関しても同様の対策を適用し、アクセスの際に XSRF トークンを渡してそれをパラメータに含まれていない場合はアクセスを拒否できる。
ただし、この方法はやや煩雑なので X-Requested-With
のような特別なリクエストヘッダを付与して、そのヘッダがなければアクセスを拒否する方法が手軽かもしれない9。
悪意のあるアクセスへの対策
広く一般に公開している API であればユーザは API が行う HTTP 通信を見ることができる。 ユーザの中には、サーバに対して本来の使われ方とは違うアクセスを行うことでサーバの脆弱性を突いて自分に有利な情報や状況を作り出そうと企てる者がいてもおかしくはない(ここではハッカーなどの第三者ではなく正常に登録している利用者を指す)。
最もシンプルな不正アクセスの方法はパラメータの改ざんである。 パラメータを改ざんすることで本来見ることのできないデータを取得できたり、サーバ側のデータを本来ならあり得ない値に変更できたりもする。 こうしたことを避けるには当たり前のことだが、クライアント側だけではなく、サーバ側できちんとバリデーションを行うしかない。 テスト漏れなどで抜け穴があるにもかかわらず公開されているサービスは脆弱性が見つかっていないだけで実際は多い(と思っている)。 そうなったときのダメージは計り知れないため注意をきちんとしておく。
次に気をつかなければならないのはリクエストの再送信である。 一度送信したリクエストを再送信することで、同じ処理をサーバ側にもう一度させてしまうと場合によっては大問題になることもある。
対策としては API ごとに繰り返しアクセスされることによって問題が発生するのかを判断して、発生する可能性があるものに関しては状態を管理して同じアクセスがあった場合はエラーにするなどの処理が必要になる。
セキュリティ対策に有用な HTTP ヘッダ
- X-Content-Type-Options
Content-Type
を変更されないようにする
- X-XSS-Protection
- 現代のブラウザでは不要。むしろ有害になり得るため注意
- X-Frame-Options
- フレーム内で読み込まれるかどうかを制御できる
- クリックジャッキング対策
- Content-Security-Policy
img
やscript
,link
要素などの読み込み先としてどこを許可するのか指定できる
- Strict-Transport-Security
- あるサイトへのブラウザからのアクセスを HTTPS のみに限定させられる
- HTTPS からのリクエストのみ有効
- Public-Key-Pins
- SSL 証明書が偽造されたものでないかをチェックする
大量アクセスへの対策
大量のアクセスを受けると、サーバのリソースはそのアクセスを捌くために力を注がざるを得なくなり、やがてその不可に耐えられなくなってしまう場合もある。 そうすると、大量のアクセスを行ったアクセス元だけではなく、関係ないユーザも全くサーバに接続できなくなってしまう(いわゆる Dos 攻撃)。
一般に公開している API であれば Dos 攻撃のような悪意を持ったハッカーからだけではなく、善良だが未熟な開発者が不注意で無限ループを書いてしまい結果的に Dos 攻撃になってしまうケースも考えられる。 そうならないための最も現実的な方法はユーザごとのアクセス数を制限することである。 その際は以下のようなことを決める必要がある。
- 何を使ってユーザを識別するか
- ex. ユーザ / IP / アプリケーション
- リミット値をいくつにするか
- ex. 15 回 / 100 回 / 10000 回
- どういう単位でリミット値を設定するか
- ex. リクエスト回数
- リミットのリセットをどういうタイミングで行うか
- アクセス回数を制限する期間の開始時間を毎時 0 分のように決まった時間にするか、最初に API にアクセスしたタイミングにするか
仮にレートリミットを超えてしまった場合はレートリミットの情報と共にステータスコード 429 を返せば良い。
そして、そのときには Retry-After
ヘッダを使って次のリクエストをするまでにどれくらい待てば良いかを指定すると親切。
ちゃんとした大規模なサービス(Google など)であればレートリミットだけを返す専用の API があったりする。 余裕であればそういった便利な API を実装してみても良いかもしれない。
5. Web API を公開するにあたって
Web API そのものを構築する以外に、その API を利用者がより便利に利用できるようにやっておいた方が良いことがいくつかある。
- API ドキュメントの提供
- サンドボックス API の提供
- いわば実験用の環境。金銭が絡む場合はあった方が良い
- API コンソール
- ブラウザ上で API を操作できるツールのこと
- SDK の提供
- メンテナンスコストが大きく上がるため導入する際は注意
さいごに
こうやってまとめてみると結局は HTTP の仕様についてもっと知る必要があると感じた。 CDN やキャッシュ関係の知識が浅いので改めて見直してみよう。
Footnotes
-
HTTP プロトコルを利用してネットワーク越しに呼び出す API ↩
-
なぜ HTML の Form では POST と GET しか使えないのかについては こちら の記事が参考になった。 ↩
-
find は探すものを目的語にとり、search は探す場所を目的語にとるため ↩
-
RDB では
offset
とlimit
を使って位置を取得する場合、先頭から数を数えるため行数が多くなればなるほど遅くなる(参考) ↩ -
この問題は読み込んだ JSON ファイルが JavaScript として正しい文法になっている場合に発生する。 オブジェクトの場合、トップレベルのオブジェクト
{}
はブロックとして判断されるので構文エラーを起こすが、配列の場合だと、そのまま JavaScript として解析されてしまう ↩ -
ツイート ID には Snowflake ID という識別子を使われている。毎日数多くの人が利用しているのによく衝突しないものだ ↩
-
form 要素からの POST ではヘッダを独自に付けて送信できないため ↩