概要
あるサービスを使った開発をするとき、そのサービスが提供しているAPIを使いますよね。例えば、githubのissuesを監視するサービスを作りたいとすると、何らかの形で githubレポジトリのissues一覧を取得するAPI を利用しそうです。当然ですが、このようなAPIはcurlで叩くことができます。例えばrailsのissues一覧を取りたければ、以下のコマンドを打てばOKですね。
$ curl https://api.github.com/repos/rails/rails/issues
とは言っても、実際にAPI使った開発をするときは、外部コマンドとしてcurlを使うのではなく、開発に用いている言語のAPIクライアントライブラリを使うでしょう。例えば、上記のgithubのissuesを監視するサービスをgoで開発するならば、goのgithubクライアントライブラリ を使うことになると思います。きちんと作られたクライアントライブラリを使うことで、APIリクエストの組み立て・APIレスポンスのパースの手間が減ったり、認証の処理を任せることができたり、複数のエンドポイントへのリクエストの共通処理が一回で済んだり、処理が抽象化されるのでコードがわかりやすくなったりといった様々なメリットが得られます。
この記事では、APIクライアントライブラリを自分で実装してみます。今回は、例としてQiita API v2のクライアントをGoで実装してみましょう。
クライアントライブラリをわざわざ自分で実装する必要はないと思われるかもしれませんが、マイナーなサービスのAPI/マイナーな言語では既存のクライアントライブラリがないことが多々あります。その時に自分でライブラリを作れるようになっておくと便利です。また、利便性は置いておいたとしても、APIへのリクエストはHTTPリクエストやJSONのパース、ログ出力など言語の基本的な機能を触る上に、作るものが明確なので勉強の題材としても優れていると思います。
本記事で、このやり方はおかしいとかもっと良いやり方があると思われた点については、ぜひコメントで教えてください。
参考リンク
この記事は、全体的にdeeeetさんの GolangでAPI Clientを実装する を参考に書かれています。今回APIクライアントを作ってみようと思ったきっかけもdeeeetさんの記事ですし、実装の方針もほとんどコピーしています。
Qiita API v2の仕様については 公式ドキュメント を参照していますが、全体像を把握するためには Qiita API v2 の概要(非公式) を読むのががわかりやすいです。
この記事で作成するクライアントライブラリのコードは maiyama18/miniqiita にあります。ちなみに、最近趣味でもうちょっとちゃんとしたqiitaのAPIクライアントライブラリを書いていて、こちらは maiyama18/qiita にあります(未完成)。
その他の細々した参考リンクは、本文中で適宜貼ることにします。
準備
Qiita API v2
当然ですが、APIクライアントを開発するためにはAPIの仕様を把握する必要があります。qiitaのAPI v2について知るために、Qiita API v2 の概要(非公式) を眺めておくと良いです。本記事を読む上では特にqiita APIに詳しくなる必要はないので、どういうエンドポイントがあるのかなんとなく見ておけば大丈夫です。
トークンを取得
一部のAPIを使うためには、リクエストと一緒にトークンをAPIに送る必要があります。トークンとは何で、なぜ必要なのかについては、一番分かりやすい OAuth の説明 が一番わかりやすいと思うのでおすすめです。OAuthについてより詳しい話が知りたい場合は、OAuth徹底入門 の6章まで読めば認証の流れが掴めると思います。
Qiitaのトークンの取得の方法は、例えば QiitaのAPIv2を使用して自身の記事取得 にありますので適宜取得してください。とは言っても、本記事の範囲内ではトークンを使うことはないので、面倒な場合はこの作業はスキップしても大丈夫です。
使い方を想像する
コードを書く前に、これから作るライブラリの使われ方を想像してみます。まず、パッケージ名ですが、qiitaクライアントの小さいサブセットを作るので、miniqiita
としましょう。例えば、ユーザの記事一覧を取得するAPI を呼ぶ場合、だいたい以下のような流れになるはずです。
ひとまず、クライアントを初期化するメソッドが必要で、トークンを渡す必要がありそうなことがわかります。クライアントを初期化したあとの利用の流れとしては、クライアントに生えていくメソッドを呼んでいくだけになりそうですね。
// qiitaクライアントを初期化。トークンが必要そうなので渡しておく
qiita := miniqiita.New(token)
// @maiyama18 の記事一覧を取得
items := qiita.GetUserItems("maiyama18")
// 以下、itemsを使って何かやる
// ...
実装
Client構造体の定義
それでは、コードを書いていきましょう。まず、クライアントを表す構造体を作る必要があります。以下のように定義します。
type Client struct {
BaseURL *url.URL
HTTPClient *http.Client
Token string
Logger *log.Logger
}
GolangでAPI Clientを実装する でも強調されていますが、ここで構造体の名前をQiitaClient
などとすると、パッケージを使う側ではminiqiita.QiitaClient
と書く必要があり完全に悲しくなってしまいます。ここは単にClient
と命名し、miniqiita.Client
と参照できるようにします。このような、goを書く上でのベストプラクティスがまとまっている文章に、Practical Go: Real world advice for writing maintainable Go programs があり、非常におすすめです。
さて、BaseURL
は、APIのURLで、例えばhttps://qiita.com/api/v2
に対応します。わざわざClient
に持たせなくても直書きしておけばいいような感じもしますが、APIのURLが変わる場合(例えば、qiita.comとqiita:Team)、バージョンが変わる場合(例えば、v2 -> v3)、テストでモックサーバにリクエストを送りたい場合などはAPIのURLを変えたいのでClient
に持たせておきます。
HTTPClient
はgoのhttpクライアント型で、実際のAPIへのリクエストを担います。本記事では、何も考えずhttp.DefaultClient
を使う場合が多いです。
Token
はqiitaのアクセストークンで、一部のAPI(記事の投稿など)にリクエストを送る際に必要になります。
Logger
はロガーで、APIへのリクエストのログを落とすために使います。好みの問題でもある気がしますが、単にメソッド中からfmt.Println
やlog.Println
を使うよりも、Client
にロガーを持たせた方がコードの柔軟性やテストのしやすさが高くなります。
上記のClient
を初期化する関数も作っておきましょう。
func New(rawBaseURL, token string, logger *log.Logger) (*Client, error) {
baseURL, err := url.Parse(rawBaseURL)
if err != nil {
return nil, err
}
if logger == nil {
logger = log.New(os.Stderr, "[LOG]", log.LstdFlags)
}
return &Client{
BaseURL: baseURL,
HTTPClient: http.DefaultClient,
Token: token,
Logger: logger,
}, nil
}
ロガーについてはnil
の入力も許し、nil
の場合はデフォルトとして適当なロガーを初期化してあげます。
ここまでのコードは、maiyama18/miniqiita/commit/c64996 にあります。
1つ目のAPIの実装(GET /api/v2/users/:user_id/items
)
メソッドのシグネチャを決める
qiitaのAPIへのリクエストを、定義したClient
構造体のメソッドとして実装していきます。1つ目として、ユーザの記事一覧を取得するAPI(GET /api/v2/users/:user_id/items
)を実装することにします。
ひとまず、ドキュメント を読んでAPIの仕様を把握します。パスパラメータとして、ユーザのIDuser_id
があります。また、paginationのためのクエリパラメータとして、
-
page
: ページ番号 (1-100) -
per_page
: 1ページあたりの要素数 (1-100)
を取っていますね。レスポンスボディとしては、記事を表すオブジェクトの配列が返ってくるようです。
以上の仕様を踏まえて、メソッドのシグネチャは以下のようにします。
func (c *Client) GetUserItems(ctx context.Context, userID string, page, perPage int) ([]*Item, error) {
// とりあえず空実装
return nil, nil
}
使う人がリクエストをキャンセルできるように、Context
を第一引数として取るようにします。第二引数以降はパスパラメータとクエリパラメータに設定する値です。返り値として記事を表す構造体であるItem
のスライスを返します。Item
構造体は、ドキュメント にあるレスポンスのJSONに対応して作りますが、今回は簡単のため以下の3つのフィールドのみを持つものとします。
type Item struct {
ID string `json:"id"`
Title string `json:"title"`
LikesCount int `json:"likes_count"`
}
プロダクションで使われることを想定したAPIクライアントライブラリでは、特に理由がなければ全部のフィールドを実装した方が良いでしょう。その場合は、JSON-to-Go を使って構造体を生成するのが便利です(参考:GolangでAPI Clientを実装する、の続き)。
本項で追加したコードは maiyama18/miniqiita/commit/fd1b32 にあります。
テスト追加
メソッドを実装する前に、テストを書きましょう。APIクライアントのテストを書くためには、クライアントのリクエストに応答するサーバが必要です。いくつか方法があります(参考:Unit Testing http client in Go)が、今回はhttptest.Server
を使って、テストごとにローカルにサーバを立てることにします。
テストは table driven tests で書いていきます。テストケースを表す構造体は、以下のようなフィールドを持つものします。
{
name string
inputUserID string
inputPage int
inputPerPage int
mockResponseHeaderFile string
mockResponseBodyFile string
expectedMethod string
expectedRequestPath string
expectedRawQuery string
expectedItems []*Item
expectedErrMessage string
}
関数への入力として、inputUserID
、inputPage
、inputPerPage
をとります。正常系で期待する出力をexpectedItems
に入れておくことにします。異常系のテストでは、期待するエラーメッセージをexpectedErrMessage
に書きます。
その他のフィールドはローカルに立てるテスト用サーバに関わるものです。今回は、実際のAPIレスポンス/ヘッダをファイルとして保存しておいて、テスト用サーバにそのファイルパスを渡して内容をレスポンスとして返してもらう形式にします。mockResponseHeaderFile
とmockResponseBodyFile
がそれぞれサーバに返してもらうヘッダとレスポンスボディのパスです。
APIクライアントに発行してほしいHTTPリクエストの内容をexpectedMethod
、expectedRequestPath
、expectedRawQuery
に入れておきます。テスト用サーバで、受け取ったリクエストを解析してその内容(メソッド、パス、クエリ)が期待されるものだった場合のみ、レスポンスを返すことにします。
少し長いですが、テストメソッドを一気に以下に示してしまいます。
func TestClient_GetUserItems(t *testing.T) {
tt := []struct {
name string
inputUserID string
inputPage int
inputPerPage int
mockResponseHeaderFile string
mockResponseBodyFile string
expectedMethod string
expectedRequestPath string
expectedRawQuery string
expectedItems []*Item
expectedErrMessage string
}{}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != tc.expectedMethod {
t.Fatalf("request method wrong. want=%s, got=%s", tc.expectedMethod, req.Method)
}
if req.URL.Path != tc.expectedRequestPath {
t.Fatalf("request path wrong. want=%s, got=%s", tc.expectedRequestPath, req.URL.Path)
}
if req.URL.RawQuery != tc.expectedRawQuery {
t.Fatalf("request query wrong. want=%s, got=%s", tc.expectedRawQuery, req.URL.RawQuery)
}
headerBytes, err := ioutil.ReadFile(tc.mockResponseHeaderFile)
if err != nil {
t.Fatalf("failed to read header '%s': %s", tc.mockResponseHeaderFile, err.Error())
}
firstLine := strings.Split(string(headerBytes), "\n")[0]
statusCode, err := strconv.Atoi(strings.Fields(firstLine)[1])
if err != nil {
t.Fatalf("failed to extract status code from header: %s", err.Error())
}
w.WriteHeader(statusCode)
bodyBytes, err := ioutil.ReadFile(tc.mockResponseBodyFile)
if err != nil {
t.Fatalf("failed to read body '%s': %s", tc.mockResponseBodyFile, err.Error())
}
w.Write(bodyBytes)
}))
defer server.Close()
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("failed to get mock server URL: %s", err.Error())
}
cli := &Client{
BaseURL: serverURL,
HTTPClient: server.Client(),
Logger: nil,
}
items, err := cli.GetUserItems(context.Background(), tc.inputUserID, tc.inputPage, tc.inputPerPage)
if tc.expectedErrMessage == "" {
if err != nil {
t.Fatalf("response error should be nil. got=%s", err.Error())
}
if len(items) != len(tc.expectedItems) {
t.Fatalf("response items wrong. want=%+v, got=%+v", tc.expectedItems, items)
}
for i, expected := range tc.expectedItems {
actual := items[i]
if actual.ID != expected.ID || actual.Title != expected.Title || actual.LikesCount != actual.LikesCount {
t.Fatalf("response items wrong. want=%+v, got=%+v", tc.expectedItems, items)
}
}
} else {
if err == nil {
t.Fatalf("response error should not be non-nil. got=nil")
}
if !strings.Contains(err.Error(), tc.expectedErrMessage) {
t.Fatalf("response error message wrong. '%s' is expected to contain '%s'", err.Error(), tc.expectedErrMessage)
}
}
})
}
}
コードは長いですが、やっている内容は単純です。
-
httptest.NewServer
でテスト用サーバを立てる - テスト用サーバに返してほしいレスポンスの内容が入ったファイルを渡し、受け取ったリクエストが期待するものだったらレスポンスを返すように設定しておく
- ヘッダファイルからステータスコードを取得する
- レスポンスボディはファイルの中身をそのまま返す
- テスト用サーバにリクエストを投げるようにクライアントを初期化する
-
GetUserItems
を呼んでテスト用サーバと通信し、返り値のitems
とerr
を受け取る - 返り値のアサーションをする
-
expectedErrMessage
が空文字の場合(正常系)、items
が期待値expectedItems
と一致することを確認 -
expectedErrMessage
が空文字でない場合(異常系)、実際に出たエラーメッセージがexpectedErrMessage
を含むことを確認
2のテスト用サーバの準備では、mockResponseHeaderFile
の1行目の2カラム目を読んでステータスコードを取得しています。これは、レスポンスヘッダが以下のような形式になっているためです。
HTTP/1.1 200 OK
Date: Mon, 01 Apr 2019 13:01:53 GMT
Content-Type: application/json; charset=utf-8
...
本来は、テスト用サーバはヘッダのフィールドをすべてレスポンスに書き込むべきですが、今回は簡単のためヘッダはステータスコードを得るためにしか使っていません。
3のクライアントの初期化では、以下のようにBaseURL
とHTTPClient
を設定することで、テスト用サーバにリクエストが飛ぶようにしてあります。
cli := &Client{
BaseURL: serverURL,
HTTPClient: server.Client(),
Logger: nil,
}
また、今回はexpectedErrMessage
が空文字かどうかで正常系/異常系を分けています。すなわち、正常系ではexpectedErrMessage
を空文字にし、異常系ではクライアントに返してほしいエラーメッセージをexpectedErrMessage
に設定します。
5のアサーションでは、正常系では取得したItem
のスライスを、異常系ではエラーメッセージをアサートします。今回は、異常系のテストでは実際のエラーメッセージがexpectedErrMessage
を含んでいればOKとしていますが、厳密にするためにメッセージが完全一致することを確認する方法もあります。
本項で追加したコードは maiyama18/miniqiita/commit/6443d9 にあります。
テストケース/テストデータを追加
テストのロジックは出来たので、テストケースを追加していきましょう。今回実装したいAPIの仕様ドキュメント を見ると、とりあえず以下のケースをテストした方が良さそうです。それぞれのケースについて、レスポンスのパースやエラー処理がうまくいっているかを確認します。
- 成功(リクエストURL:
/api/v2/users/yaotti/items?page=2&per_page=3
) - 失敗。
page
が不正な値(リクエストURL:/api/v2/users/yaotti/items?page=101&per_page=3
) - 失敗。ユーザが存在しない(リクエストURL:
/api/v2/users/nonexistent/items?page=2&per_page=3
)
それぞれのテストケースについて、実際にリクエストするURLを併記しています。例として、本記事ではyaottiさんのデータを使わせていただきます(今考えると自分のデータを使えばよかった...すみません)。
以上のテストを行うために、それぞれのケースについて、APIの実際のレスポンスを保存しておきましょう。まず、レスポンスを保存するためのディレクトリを切っておきます。
$ mkdir -p testdata/GetUserItems
成功ケースのレスポンスは、以下のようにcurlを使って保存しておきます。
# レスポンスボディを保存
$ curl -X GET 'https://qiita.com/api/v2/users/yaotti/items?page=2&per_page=3' -s -o testdata/GetUserItems/success-body
# レスポンスヘッダを保存
$ curl -X GET 'https://qiita.com/api/v2/users/yaotti/items?page=2&per_page=3' -s -D testdata/GetUserItems/success-header
保存した内容を確認すると、以下のように、ステータス200で、記事を表すJSONの配列が返ってきていることがわかります。
$ head testdata/GetUserItems/success-header
HTTP/2 200
date: Wed, 03 Apr 2019 03:22:16 GMT
content-type: application/json; charset=utf-8
server: nginx
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
x-download-options: noopen
x-permitted-cross-domain-policies: none
referrer-policy: strict-origin-when-cross-origin
$ cat testdata/GetUserItems/success-body | jq | head
[
{
"rendered_body": "\n<h1>\n<span id=\"背景\" class=\"fragment\"></span><a href=\"#%E8%83%8C%E6%99%AF\"><i class=\"fa fa-link\"></i></a>背景</h1>\n\n<p>一部のSQL実行時に<code>Mysql2::Error: Incorrect key file for table '/rdsdbdata/tmp/#sql_69c6_2.MYI'; try to repair it</code>というエラーが発生するようになった.全てのSQLにおいて起きるわけではない.<br>\n<!-- 何をしようとしてこの問題に遭遇したかを簡単に説明してください --></p>\n\n<h1>\n<span id=\"問題\" class=\"fragment\"></span><a href=\"#%E5%95%8F%E9%A1%8C\"><i class=\"fa fa-link\"></i></a>問題</h1>\n\n<p><code>check table xxx</code>しても問題なかったため,テーブルが壊れているわけではない.<br>\n原因はストレージ容量(tmpdirの容量)が不足していることだった.(tmpdirはSQL実行時の一時バッファ?)</p>\n\n<h1>\n<span id=\"解法\" class=\"fragment\"></span><a href=\"#%E8%A7%A3%E6%B3%95\"><i class=\"fa fa-link\"></i></a>解法</h1>\n\n<p>RDSのAllocated Storageを増やす.これはRDSを稼動させたまま変更することもできる.<br>\nRDSでないならば,tmpdirの余計なファイルを削除すればよさそう.</p>\n\n<h1>\n<span id=\"参考リンク\" class=\"fragment\"></span><a href=\"#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF\"><i class=\"fa fa-link\"></i></a>参考リンク</h1>\n\n<p><a href=\"http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ModifyInstance.MySQL.html\" rel=\"nofollow noopener\" target=\"_blank\">Modifying a DB Instance Running the MySQL Database Engine - Amazon Relational Database Service</a></p>\n",
"body": "# 背景\n一部のSQL実行時に`Mysql2::Error: Incorrect key file for table '/rdsdbdata/tmp/#sql_69c6_2.MYI'; try to repair it`というエラーが発生するようになった.全てのSQLにおいて起きるわけではない.\n<!-- 何をしようとしてこの問題に遭遇したかを簡単に説明してください -->\n\n# 問題\n`check table xxx`しても問題なかったため,テーブルが壊れているわけではない.\n原因はストレージ容量(tmpdirの容量)が不足していることだった.(tmpdirはSQL実行時の一時バッファ?)\n\n# 解法\nRDSのAllocated Storageを増やす.これはRDSを稼動させたまま変更することもできる.\nRDSでないならば,tmpdirの余計なファイルを削除すればよさそう.\n\n# 参考リンク\n[Modifying a DB Instance Running the MySQL Database Engine - Amazon Relational Database Service](http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ModifyInstance.MySQL.html)\n",
"coediting": false,
"comments_count": 0,
"created_at": "2014-02-04T09:17:15+09:00",
"group": null,
"id": "f6c78c01ee8c988a9f7a",
"likes_count": 9,
保存したレスポンスを元に、テストケースを追加します。以下のようになると思います。expectedItems
の中身は、レスポンスを実際に見て記入しています。
{
name: "success",
inputUserID: "yaotti",
inputPage: 2,
inputPerPage: 3,
mockResponseHeaderFile: "testdata/GetUserItems/success-header",
mockResponseBodyFile: "testdata/GetUserItems/success-body",
expectedMethod: http.MethodGet,
expectedRequestPath: "/users/yaotti/items",
expectedRawQuery: "page=2&per_page=3",
expectedItems: []*Item{
{ID: "f6c78c01ee8c988a9f7a", Title: "RDSで`Mysql2::Error: Incorrect key file for table '/rdsdbdata/tmp/...'; try to repair it`というエラーに対応する", LikesCount: 9},
{ID: "157ff0a46736ec793a91", Title: "ディレクトリ移動を手軽にするauto cdとcdpath", LikesCount: 73},
{ID: "5b70c9f9d882f6f10023", Title: "ある程度Gitを操作できるようになってから当たると良いマニュアル/情報源", LikesCount: 302},
},
},
テストのロジックを見れば、それぞれの変数の意味やどのような値を入れるべきかは明らかだと思いますが、念のため確認しておきます。
input*
変数にはメソッドの入力となるべき値をいれておきます。mockResponse*
には保存したレスポンスファイルのパスを渡します。expectedMethod
/expectedRequestPath
/expectedRawQuery
にはメソッドに発行してほしいリクエストの内容を書いておきます。expectedItems
には実際にリクエストした結果を見て、メソッドに返してほしいItem
構造体を書きます。
次に、page
パラメータが不正な値の場合についても同様にテストケースを追加します。ドキュメントによるとpage
の値は1-100の範囲に収まるべきなようなので、101に設定してリクエストして、その際のレスポンスを保存します。
$ curl -X GET 'https://qiita.com/api/v2/users/yaotti/items?page=101&per_page=3' -s -o testdata/GetUserItems/page_out_of_range-body
$ curl -X GET 'https://qiita.com/api/v2/users/yaotti/items?page=101&per_page=3' -s -D testdata/GetUserItems/page_out_of_range-header
内容を確認します。
$ head testdata/GetUserItems/page_out_of_range-header
HTTP/2 400
date: Wed, 03 Apr 2019 03:42:40 GMT
content-type: application/json
server: nginx
rate-limit: 60
rate-remaining: 55
rate-reset: 1554265331
vary: Origin
x-runtime: 0.159350
strict-transport-security: max-age=2592000
$ cat testdata/GetUserItems/page_out_of_range-body
{"message":"Bad request","type":"bad_request"}
このようなレスポンスを受け取ったとき、メソッドにどのようなエラーを返してほしいかを考えて、テストケースを追加します。
{
name: "failure-page_out_of_range",
inputUserID: "yaotti",
inputPage: 101,
inputPerPage: 3,
mockResponseHeaderFile: "testdata/GetUserItems/page_out_of_range-header",
mockResponseBodyFile: "testdata/GetUserItems/page_out_of_range-body",
expectedMethod: http.MethodGet,
expectedRequestPath: "/users/yaotti/items",
expectedRawQuery: "page=101&per_page=3",
expectedErrMessage: "bad request",
},
今回のリクエストに対しては、メソッドにはエラーを返してほしいので、expectedErrMessage
フィールドを設定しました。エラーメッセージに何らかの形でbad request
と入れてほしい感じがするので、expectedErrMessage
の値をbad request
としています。
最後に、存在しないユーザの記事を取得しようとしてしまった場合についても同様にテストケースを追加します。方法は今までと同じなので詳しくは省略しますが、以下のようにnonexistent
という名前の存在しないユーザの記事を取得しようとするリクエストを投げた際のレスポンスを保存します。
$ curl -X GET 'https://qiita.com/api/v2/users/nonexistent/items?page=2&per_page=3' -s -o testdata/GetUserItems/page_out_of_range-body
$ curl -X GET 'https://qiita.com/api/v2/users/nonexistent/items?page=2&per_page=3' -s -D testdata/GetUserItems/page_out_of_range-header
レスポンスの内容を見て、テストケースを追加しておきましょう。
以上3つのテストケースを追加したことで、テストメソッドTestClient_GetUserItems
で、以下のようにテストケースが初期化されることになりました。
tt := []struct {
name string
inputUserID string
inputPage int
inputPerPage int
mockResponseHeaderFile string
mockResponseBodyFile string
expectedMethod string
expectedRequestPath string
expectedRawQuery string
expectedItems []*Item
expectedErrMessage string
}{
{
name: "success",
inputUserID: "yaotti",
inputPage: 2,
inputPerPage: 3,
mockResponseHeaderFile: "testdata/GetUserItems/success-header",
mockResponseBodyFile: "testdata/GetUserItems/success-body",
expectedMethod: http.MethodGet,
expectedRequestPath: "/users/yaotti/items",
expectedRawQuery: "page=2&per_page=3",
expectedItems: []*Item{
{ID: "f6c78c01ee8c988a9f7a", Title: "RDSで`Mysql2::Error: Incorrect key file for table '/rdsdbdata/tmp/...'; try to repair it`というエラーに対応する", LikesCount: 9},
{ID: "157ff0a46736ec793a91", Title: "ディレクトリ移動を手軽にするauto cdとcdpath", LikesCount: 73},
{ID: "5b70c9f9d882f6f10023", Title: "ある程度Gitを操作できるようになってから当たると良いマニュアル/情報源", LikesCount: 302},
},
},
{
name: "failure-page_out_of_range",
inputUserID: "yaotti",
inputPage: 101,
inputPerPage: 3,
mockResponseHeaderFile: "testdata/GetUserItems/page_out_of_range-header",
mockResponseBodyFile: "testdata/GetUserItems/page_out_of_range-body",
expectedMethod: http.MethodGet,
expectedRequestPath: "/users/yaotti/items",
expectedRawQuery: "page=101&per_page=3",
expectedErrMessage: "bad request",
},
{
name: "failure-user_not_exist",
inputUserID: "nonexistent",
inputPage: 2,
inputPerPage: 3,
mockResponseHeaderFile: "testdata/GetUserItems/user_not_exist-header",
mockResponseBodyFile: "testdata/GetUserItems/user_not_exist-body",
expectedMethod: http.MethodGet,
expectedRequestPath: "/users/nonexistent/items",
expectedRawQuery: "page=2&per_page=3",
expectedErrMessage: "not found",
},
}
本項で追加したコードは maiyama18/miniqiita/commit/a4aa10 にあります。
メソッドの実装
前項でテストを追加したので、実行してみましょう。
$ go test [13:01:32]
--- FAIL: TestClient_GetUserItems (0.00s)
--- FAIL: TestClient_GetUserItems/success (0.00s)
miniqiita_test.go:132: response items wrong. want=[0xc0000b0c60 0xc0000b0c90 0xc0000b0cc0], got=[]
--- FAIL: TestClient_GetUserItems/failure-page_out_of_range (0.00s)
miniqiita_test.go:142: response error should not be non-nil. got=nil
--- FAIL: TestClient_GetUserItems/failure-user_not_exist (0.00s)
miniqiita_test.go:142: response error should not be non-nil. got=nil
FAIL
exit status 1
FAIL miniqiita 1.422s
全て失敗しています。これは当然で、いまのGetUserItems
は単にnil
を返しているだけの空実装だからです。これから、テストが成功するようにメソッド実装していきましょう。
まず、渡された引数から、APIへのリクエストを作る部分の処理を以下のように書きます。
func (c *Client) GetUserItems(ctx context.Context, userID string, page, perPage int) ([]*Item, error) {
reqURL := *c.BaseURL
// set path
reqURL.Path = path.Join(reqURL.Path, "users", userID, "items")
// set query
q := reqURL.Query()
q.Add("page", strconv.Itoa(page))
q.Add("per_page", strconv.Itoa(perPage))
reqURL.RawQuery = q.Encode()
// instantiate request
req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, err
}
// set header
req.Header.Set("User-Agent", "qiita-go-client")
// set context
req = req.WithContext(ctx)
return nil, nil
}
コード中のコメントで書いてあるように、以下のような流れでリクエストを表す*http.Request
オブジェクトを作っていきます。
- パスとして、
/users/:user_id/items
を設定。ここで、c.BaseURL
にはhttps://qiita.com/api/v2
が設定されていると想定している - クエリパラメータとして、
page
とper_page
をメソッドの引数で渡された値に設定 - ここまででリクエスト先のURLを構築できたので、これを使って
http.NewRequest
でリクエストを表すオブジェクトreq
を生成 - 必須ではないが、ヘッダに
User-Agent
を書いておく。名前は適当 - 引数で渡されたコンテクスト
ctx
をセット
次に、作ったreq
を使って実際にリクエストを送り、レスポンスをパースする部分です。
func (c *Client) GetUserItems(ctx context.Context, userID string, page, perPage int) ([]*Item, error) {
// ...
// ...
// set context
req = req.WithContext(ctx)
// send request
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var items []*Item
if err := json.Unmarshal(bodyBytes, &items); err != nil {
return nil, err
}
return items, nil
case http.StatusBadRequest:
return nil, errors.New("bad request. some parameters may be invalid")
case http.StatusNotFound:
return nil, fmt.Errorf("not found. user with id '%s' may not exist", userID)
default:
return nil, errors.New("unexpected error")
}
まず、*http.Client
のDo
メソッドでリクエストを送信し、resp
を取得しています。このresp
の結果を元に処理していくのですが、今回リクエストするAPIは、エラー時のレスポンスボディの内容から得られる情報が少ないので、単にステータスコードを見て処理を分岐しています。
前項で追加したテストケースに合わせて処理を見ていきましょう。まず、成功するケースでは、ステータスコードは200になっていたので、resp.StatusCode
がhttp.StatusOK
(= 200)の分岐処理に入ります。ここでは、ioutil.ReadAll
でレスポンスボディを全部読んだ後、その内容をjson.Unmarshal
に渡した結果得られるItem
のスライスをreturn
します。
次に、page
パラメータの値が不正な場合です。curlの結果、ステータスコードは400(= http.StatusBadRequest
)になっていました。この分岐では、第2引数でエラーをreturn
します。テストコードを書いたときに、エラーメッセージにbad request
という文字列が含まれていてほしいと思ってそのアサーションを追加したので、その通りにメッセージを書いておきます。
ユーザが存在しない場合(ステータスコードはhttp.StatusNotFound
= 404)も同様の方針で実装しています。
以上でGetUserItems
メソッドの実装は完了です。テストが通っているか確認しておきましょう。
$ go test
PASS
ok miniqiita 1.381s
無事通りました。
本項で追加したコードは maiyama18/miniqiita/commit/f5dd92 にあります。
作成したメソッドを使ってみる
ここまで実装してきたライブラリを実際に使ってみましょう。適当なディレクトリを切ってmainパッケージを作り、GetUserItems
メソッドを使ってみます。トークンは今回のAPIには必要ないので、適当な文字列を入れておけばOKです。
$ mkdir main
$ touch main/main.go
func main() {
qiita, err := miniqiita.New("https://qiita.com/api/v2", "TOKEN", nil)
if err != nil {
panic(err)
}
items, err := qiita.GetUserItems(context.Background(), "yaotti", 2, 3)
if err != nil {
panic(err)
}
for _, item := range items {
fmt.Printf("%+v\n", item)
}
}
実行すると、以下のような出力が得られます。ちゃんと記事の情報が取れていますね!
$ go run cmd/main/main.go
&{ID:f6c78c01ee8c988a9f7a Title:RDSで`Mysql2::Error: Incorrect key file for table '/rdsdbdata/tmp/...'; try to repair it`というエラーに対応する LikesCount:9}
&{ID:157ff0a46736ec793a91 Title:ディレクトリ移動を手軽にするauto cdとcdpath LikesCount:74}
&{ID:5b70c9f9d882f6f10023 Title:ある程度Gitを操作できるようになってから当たると良いマニュアル/情報源 LikesCount:302}
2つ目以降のAPIの実装
1つメソッドを実装しました。あとは、全てのAPIに対応するメソッドを順番に実装していくだけです。実際に実装を始める前に、複数のメソッドで行う共通の操作をヘルパーメソッド/関数として切り出しておきましょう。これにより、今後の実装が楽になります。
テストの準備の共通化
まずはテストから。現状のコードを見ると、
- テスト用サーバの初期化
- テスト用サーバにアクセスするクライアントの初期化
は、全てのテストで行いそうな感じがするので、共通化しておいたほうが良さそうです。この操作を以下のようにsetup
という関数にまとめます。
func setup(t *testing.T, mockResponseHeaderFile, mockResponseBodyFile string, expectedMethod, expectedRequestPath, expectedRawQuery string) (*Client, func()) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != expectedMethod {
t.Fatalf("request method wrong. want=%s, got=%s", expectedMethod, req.Method)
}
if req.URL.Path != expectedRequestPath {
t.Fatalf("request path wrong. want=%s, got=%s", expectedRequestPath, req.URL.Path)
}
if req.URL.RawQuery != expectedRawQuery {
t.Fatalf("request query wrong. want=%s, got=%s", expectedRawQuery, req.URL.RawQuery)
}
headerBytes, err := ioutil.ReadFile(mockResponseHeaderFile)
if err != nil {
t.Fatalf("failed to read header '%s': %s", mockResponseHeaderFile, err.Error())
}
firstLine := strings.Split(string(headerBytes), "\n")[0]
statusCode, err := strconv.Atoi(strings.Fields(firstLine)[1])
if err != nil {
t.Fatalf("failed to extract status code from header: %s", err.Error())
}
w.WriteHeader(statusCode)
bodyBytes, err := ioutil.ReadFile(mockResponseBodyFile)
if err != nil {
t.Fatalf("failed to read body '%s': %s", mockResponseBodyFile, err.Error())
}
w.Write(bodyBytes)
}))
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("failed to get mock server URL: %s", err.Error())
}
cli := &Client{
BaseURL: serverURL,
HTTPClient: server.Client(),
Logger: nil,
}
teardown := func() {
server.Close()
}
return cli, teardown
}
返り値は、初期化したクライアントcli
とテストサーバの後処理を行う関数teardown
にしておきます。以下のように、テストロジックの方で、cli
を使ってもらう+teardown
を呼んでもらうようにします。
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
cli, teardown := setup(t, tc.mockResponseHeaderFile, tc.mockResponseBodyFile, tc.expectedMethod, tc.expectedRequestPath, tc.expectedRawQuery)
defer teardown()
items, err := cli.GetUserItems(context.Background(), tc.inputUserID, tc.inputPage, tc.inputPerPage)
// ...
// ...
これで、今後のテストの追加が楽になりますね。念のため、テストメソッドを使うことでテストが壊れていないことを確認しておきます。
$ go test
PASS
ok miniqiita 1.361s
本項で追加したコードは maiyama18/miniqiita/commit/229211 にあります。
リクエスト作成・レスポンスパースの共通化
続いて、ライブラリ本体の方でも共通化できる処理を探してみます。GetUserItems
のコードを見直すと、以下の部分はそれぞれ共通のメソッドに切り出しておくと便利そうです。
- リクエストを表すオブジェクト
req
の作成 - リクエストを送って帰ってきたレスポンスボディのパース
ますは、リクエストの作成を行うnewRequest
メソッドを書きます。
func (c *Client) newRequest(ctx context.Context, method, relativePath string, queries, headers map[string]string, reqBody io.Reader) (*http.Request, error) {
reqURL := *c.BaseURL
// set path
reqURL.Path = path.Join(reqURL.Path, relativePath)
// set query
if queries != nil {
q := reqURL.Query()
for k, v := range queries {
q.Add(k, v)
}
reqURL.RawQuery = q.Encode()
}
// instantiate request
req, err := http.NewRequest(method, reqURL.String(), reqBody)
if err != nil {
return nil, err
}
// set header
req.Header.Set("User-Agent", "qiita-go-client")
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
if headers != nil {
for k, v := range headers {
req.Header.Set(k, v)
}
}
// set context
req = req.WithContext(ctx)
return req, nil
}
もともとのGetUserItems
ではパラメータなどを直書きしていましたが、newRequest
メソッドは一般的に使えるヘルパーメソッドにしたいので、一般化しておきます。具体的には、ヘッダに書き込む情報やクエリパラメータをmap[string]string
として渡してリクエストにセットするようにします。また、GetUserItems
は認証が必要ないAPIを呼んでいたのでトークンを必要としていなかったのですが、ユーザをフォローしたり記事を投稿したりといったAPIではトークンをヘッダで送る必要があります。いちいち各メソッドからトークンをセットするのは面倒なので、newRequest
の中で行うようにします。同様に、Content-Type
ヘッダも追加しておきました。
次に、newRequest
で作ったリクエストを送信するメソッドdoRequest
を書いていきます。
func (c *Client) doRequest(req *http.Request, respBody interface{}) (int, error) {
resp, err := c.HTTPClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || 300 <= resp.StatusCode {
return resp.StatusCode, nil
}
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return 0, err
}
if err := json.Unmarshal(bodyBytes, respBody); err != nil {
return 0, err
}
return resp.StatusCode, nil
}
呼び出し側でステータスコードを見て処理の分岐をしたいので、ステータスコードを返り値にすることにします。また、レスポンスボディについては、パースした結果を入れて欲しい構造体を引数respBody
として渡すことで、間接的に返すようにします。直接返り値で返した方が単純な気がしますが、今回のようにdoRequest
メソッドの中でボディのパースまでしたい場合はそれはできません。返り値で返すためにはメソッド内でレスポンスボディを入れる構造体を初期化しなければいけませんが、doRequest
メソッドはどのAPIにアクセスするかを知らないため、どの構造体を使えばいいかわからないからです。
それでは、本項で切り出したヘルパーメソッドのnewRequest
とdoRequest
を使ってGetUserItems
を書き換えてみます。
func (c *Client) GetUserItems(ctx context.Context, userID string, page, perPage int) ([]*Item, error) {
relativePath := path.Join("users", userID, "items")
queries := map[string]string{
"page": strconv.Itoa(page),
"per_page": strconv.Itoa(perPage),
}
req, err := c.newRequest(ctx, http.MethodGet, relativePath, queries, nil, nil)
if err != nil {
return nil, err
}
// send request
var items []*Item
code, err := c.doRequest(req, &items)
switch code {
case http.StatusOK:
return items, nil
case http.StatusBadRequest:
return nil, errors.New("bad request. some parameters may be invalid")
case http.StatusNotFound:
return nil, fmt.Errorf("not found. user with id '%s' may not exist", userID)
default:
return nil, errors.New("unexpected error")
}
}
処理の流れがかなりわかりやすくなりましたね。そして、newRequest
とdoRequest
は他のAPIにリクエストを送る際にも使い回せるので、今後のメソッドの実装が簡単にできる状態になっています。
本項で追加したコードは maiyama18/miniqiita/commit/cc25b9 にあります。
メソッドの実装
それでは、いよいよ2つ目のAPIにリクエストのメソッドを書いていきます...と思ったのですが、これからの作業で行っていくことはGetUserItems
の実装とほぼ同じなので、本記事はここまでで終了することにします。リクエストの作成とレスポンスのパースというAPIリクエストのメインの処理はすでにそれぞれnewRequest
とdoRequest
として実装済みなので、あとは以下の流れでAPIに対応するメソッドをひたすら追加していくだけです。
- APIの仕様の把握
- 仕様を見て必要そうなテストケースの追加
- テストを通すようにメソッドを実装
まとめ
本記事では、Qiita API v2を例にして、GoでAPIクライアントを実装する方法について見ました。
- APIクライアントの開発は楽しくて勉強になるし、実益もある
- 実際のAPIレスポンスを保存しておくとテストが簡単に書ける
- APIリクエストに共通の処理をヘルパーメソッドとして切り出しておくと、メソッドの追加が簡単になる
好きなサービスのAPIのクライアントを好きな言語で実装していきましょう。