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
  • スペースやエンコードを必要とする文字を使わない
  • 単語をつなげる必要がある場合はハイフンを利用する
    • 絶対という訳ではなく、プロジェクトに準ずる
    • そもそも単語のつなぎ合わせを避ける設計にする

検索とクエリパラメータの設計

たくさんあるデータの一部を取得する際にはページネーションの仕組みを利用する。 page/per_pageoffset/limitのようなクエリパラメータを設定するのが一般的。

例えば、数あるアイテムの中から101アイテム目から取得したい場合は以下のようにする。

  • per_page=50&page=3
  • limit=50&offset=100

offset/limit方が自由度は高いが、要件によってどちらにするかを決める。 ただし、上記のような相対的な取得位置でデータを取得する法にはパフォーマンス上の懸念がある5 また、更新頻度の高いデータにおいてデータの不整合が生じるという問題もあるため慎重に導入を決めなければならない。

絶対位置でデータを取得する場合は相対位置での問題点をカバーできる。 例えば、「このIDよりも前のもの」や「この時刻よりも古いもの」のような指定方法を用いる。

クエリパラメータはその他に、絞り込みのために使用する。 検索するフィールドがほぼ1つに決まる場合はqいうパラメータが使われる場合もある(Googleの検索結果など)。

クエリパラメータとパスの使い分けは一意なリソースを表すのに必要な情報かどうかで判断する。 これはURIがリソースを表すものであるというURIの思想からきている。 また、省略可能な場合はクエリパラメータの方が適している。

自分の情報を取得したい際は自分のユーザIDをいちいち指定するのは煩雑なのでmeselfいったキーワードをパスに入れてエイリアスとして利用する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. https://api.kkhys.me/v1/users.json
  • リクエストヘッダでメディアタイプを指定する

リクエストヘッダを使う場合は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. registrationDateTimeregisteredAt
  • 複数の単語を連結する場合、その連結方法は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番台のステータスコードを返す。

ステータスコード名前説明
400Bad Requestリクエストが正しくない
401Unauthorizedアクセスが禁止されている
403Forbidden認証が必要
404Not Found指定したリソースが見つからない
405Method Not Allowed指定されたメソッドは使うことができない
406Not AcceptableAccept関連のヘッダに受理できない内容が含まれている
408Request Timeoutリクエストが時間以内に完了しなかった
409Conflictリソースが矛盾した
410Gone指定したリソースは消滅した
413Request Entity Too Largeリクエストボディが大きすぎる
414Request-URI Too LongリクエストされたURIが長すぎる
415Unsupported Media typeサポートしていないメディアタイプが指定された
429Too 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-OriginOriginヘッダと同じ生成元を入れて返すことで、アクセスが許可されたことを示す。

仮にアクセスしたリソースがセキュリティ上どこのページから読み込まれても問題ない場合は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-Typeapplication/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
    • imgscript, 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やキャッシュ関係の知識が浅いので改めて見直してみよう。


  1. HTTPプロトコルを利用してネットワーク越しに呼び出すAPI

  2. なぜHTMLのFormではPOSTとGETしか使えないのかについては こちら記事が参考になった。

  3. Railsガイドより

  4. findは探すものを目的語にとり、searchは探す場所を目的語にとるため

  5. RDBではoffsetlimit使って位置を取得する場合、先頭から数を数えるため行数が多くなればなるほど遅くなる参考

  6. この問題は読み込んだJSONファイルがJavaScriptとして正しい文法になっている場合に発生する。 オブジェクトの場合、トップレベルのオブジェクト{}ブロックとして判断されるので構文エラーを起こすが、配列の場合だと、そのままJavaScriptとして解析されてしまう

  7. ツイートIDには Snowflake IDいう識別子を使われている。毎日数多くの人が利用しているのによく衝突しないものだ

  8. TwitterのAPI v1が廃止されたときの対応が参考になる参考

  9. form要素からのPOSTではヘッダを独自に付けて送信できないため