Help us understand the problem. What is going on with this article?

Go言語でAPIクライアントライブラリを実装する(Qiitaを例に)

概要

あるサービスを使った開発をするとき、そのサービスが提供している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 の概要(非公式) を読むのががわかりやすいです。

この記事で作成するクライアントライブラリのコードは muiscript/miniqiita にあります。ちなみに、最近趣味でもうちょっとちゃんとしたqiitaのAPIクライアントライブラリを書いていて、こちらは muiscript/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)

// @muiscript の記事一覧を取得
items := qiita.GetUserItems("muiscript")

// 以下、itemsを使って何かやる
// ...

実装

Client構造体の定義

それでは、コードを書いていきましょう。まず、クライアントを表す構造体を作る必要があります。以下のように定義します。

miniqiita.go
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.Printlnlog.Printlnを使うよりも、Clientにロガーを持たせた方がコードの柔軟性やテストのしやすさが高くなります。

上記のClientを初期化する関数も作っておきましょう。

miniqiita.go
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の場合はデフォルトとして適当なロガーを初期化してあげます。

ここまでのコードは、muiscript/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)

を取っていますね。レスポンスボディとしては、記事を表すオブジェクトの配列が返ってくるようです。

以上の仕様を踏まえて、メソッドのシグネチャは以下のようにします。

miniqiita.go
func (c *Client) GetUserItems(ctx context.Context, userID string, page, perPage int) ([]*Item, error) {
    // とりあえず空実装
    return nil, nil
}

使う人がリクエストをキャンセルできるように、Contextを第一引数として取るようにします。第二引数以降はパスパラメータとクエリパラメータに設定する値です。返り値として記事を表す構造体であるItemのスライスを返します。Item構造体は、ドキュメント にあるレスポンスのJSONに対応して作りますが、今回は簡単のため以下の3つのフィールドのみを持つものとします。

miniqiita.go
type Item struct {
    ID         string `json:"id"`
    Title      string `json:"title"`
    LikesCount int    `json:"likes_count"`
}

プロダクションで使われることを想定したAPIクライアントライブラリでは、特に理由がなければ全部のフィールドを実装した方が良いでしょう。その場合は、JSON-to-Go を使って構造体を生成するのが便利です(参考:GolangでAPI Clientを実装する、の続き)。

本項で追加したコードは muiscript/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
}

関数への入力として、inputUserIDinputPageinputPerPageをとります。正常系で期待する出力をexpectedItemsに入れておくことにします。異常系のテストでは、期待するエラーメッセージをexpectedErrMessageに書きます。
その他のフィールドはローカルに立てるテスト用サーバに関わるものです。今回は、実際のAPIレスポンス/ヘッダをファイルとして保存しておいて、テスト用サーバにそのファイルパスを渡して内容をレスポンスとして返してもらう形式にします。mockResponseHeaderFilemockResponseBodyFileがそれぞれサーバに返してもらうヘッダとレスポンスボディのパスです。
APIクライアントに発行してほしいHTTPリクエストの内容をexpectedMethodexpectedRequestPathexpectedRawQueryに入れておきます。テスト用サーバで、受け取ったリクエストを解析してその内容(メソッド、パス、クエリ)が期待されるものだった場合のみ、レスポンスを返すことにします。

少し長いですが、テストメソッドを一気に以下に示してしまいます。

miniqiita_test.go
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)
                }
            }
        })
    }
}

コードは長いですが、やっている内容は単純です。

  1. httptest.NewServerでテスト用サーバを立てる
  2. テスト用サーバに返してほしいレスポンスの内容が入ったファイルを渡し、受け取ったリクエストが期待するものだったらレスポンスを返すように設定しておく
    1. ヘッダファイルからステータスコードを取得する
    2. レスポンスボディはファイルの中身をそのまま返す
  3. テスト用サーバにリクエストを投げるようにクライアントを初期化する
  4. GetUserItemsを呼んでテスト用サーバと通信し、返り値のitemserrを受け取る
  5. 返り値のアサーションをする
    1. expectedErrMessageが空文字の場合(正常系)、itemsが期待値expectedItemsと一致することを確認
    2. expectedErrMessageが空文字でない場合(異常系)、実際に出たエラーメッセージがexpectedErrMessageを含むことを確認

2のテスト用サーバの準備では、mockResponseHeaderFileの1行目の2カラム目を読んでステータスコードを取得しています。これは、レスポンスヘッダが以下のような形式になっているためです。

ステータスコード200のレスポンスヘッダ例
HTTP/1.1 200 OK
Date: Mon, 01 Apr 2019 13:01:53 GMT
Content-Type: application/json; charset=utf-8
...

本来は、テスト用サーバはヘッダのフィールドをすべてレスポンスに書き込むべきですが、今回は簡単のためヘッダはステータスコードを得るためにしか使っていません。

3のクライアントの初期化では、以下のようにBaseURLHTTPClientを設定することで、テスト用サーバにリクエストが飛ぶようにしてあります。

cli := &Client{
    BaseURL:    serverURL,
    HTTPClient: server.Client(),
    Logger:     nil,
}

また、今回はexpectedErrMessageが空文字かどうかで正常系/異常系を分けています。すなわち、正常系ではexpectedErrMessageを空文字にし、異常系ではクライアントに返してほしいエラーメッセージをexpectedErrMessageに設定します。
5のアサーションでは、正常系では取得したItemのスライスを、異常系ではエラーメッセージをアサートします。今回は、異常系のテストでは実際のエラーメッセージがexpectedErrMessageを含んでいればOKとしていますが、厳密にするためにメッセージが完全一致することを確認する方法もあります。

本項で追加したコードは muiscript/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&lt;!-- 何をしようとしてこの問題に遭遇したかを簡単に説明してください --&gt;</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で、以下のようにテストケースが初期化されることになりました。

miniqiita_test.go
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",
    },
}

本項で追加したコードは muiscript/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へのリクエストを作る部分の処理を以下のように書きます。

miniqiita.go
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が設定されていると想定している
  • クエリパラメータとして、pageper_pageをメソッドの引数で渡された値に設定
  • ここまででリクエスト先のURLを構築できたので、これを使ってhttp.NewRequestでリクエストを表すオブジェクトreqを生成
  • 必須ではないが、ヘッダにUser-Agentを書いておく。名前は適当
  • 引数で渡されたコンテクストctxをセット

次に、作ったreqを使って実際にリクエストを送り、レスポンスをパースする部分です。

miniqiita.go
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.ClientDoメソッドでリクエストを送信し、respを取得しています。このrespの結果を元に処理していくのですが、今回リクエストするAPIは、エラー時のレスポンスボディの内容から得られる情報が少ないので、単にステータスコードを見て処理を分岐しています。

前項で追加したテストケースに合わせて処理を見ていきましょう。まず、成功するケースでは、ステータスコードは200になっていたので、resp.StatusCodehttp.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

無事通りました。

本項で追加したコードは muiscript/miniqiita/commit/f5dd92 にあります。

作成したメソッドを使ってみる

ここまで実装してきたライブラリを実際に使ってみましょう。適当なディレクトリを切ってmainパッケージを作り、GetUserItemsメソッドを使ってみます。トークンは今回のAPIには必要ないので、適当な文字列を入れておけばOKです。

$ mkdir main
$ touch main/main.go
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という関数にまとめます。

miniqiita.go
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を呼んでもらうようにします。

miniqiita.go
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

本項で追加したコードは muiscript/miniqiita/commit/229211 にあります。

リクエスト作成・レスポンスパースの共通化

続いて、ライブラリ本体の方でも共通化できる処理を探してみます。GetUserItemsのコードを見直すと、以下の部分はそれぞれ共通のメソッドに切り出しておくと便利そうです。

  • リクエストを表すオブジェクトreqの作成
  • リクエストを送って帰ってきたレスポンスボディのパース

ますは、リクエストの作成を行うnewRequestメソッドを書きます。

miniqiita.go
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を書いていきます。

miniqiita.go
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にアクセスするかを知らないため、どの構造体を使えばいいかわからないからです。

それでは、本項で切り出したヘルパーメソッドのnewRequestdoRequestを使ってGetUserItemsを書き換えてみます。

miniqiita.go
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")
    }
}

処理の流れがかなりわかりやすくなりましたね。そして、newRequestdoRequestは他のAPIにリクエストを送る際にも使い回せるので、今後のメソッドの実装が簡単にできる状態になっています。

本項で追加したコードは muiscript/miniqiita/commit/cc25b9 にあります。

メソッドの実装

それでは、いよいよ2つ目のAPIにリクエストのメソッドを書いていきます...と思ったのですが、これからの作業で行っていくことはGetUserItemsの実装とほぼ同じなので、本記事はここまでで終了することにします。リクエストの作成とレスポンスのパースというAPIリクエストのメインの処理はすでにそれぞれnewRequestdoRequestとして実装済みなので、あとは以下の流れでAPIに対応するメソッドをひたすら追加していくだけです。

  • APIの仕様の把握
  • 仕様を見て必要そうなテストケースの追加
  • テストを通すようにメソッドを実装

まとめ

本記事では、Qiita API v2を例にして、GoでAPIクライアントを実装する方法について見ました。

  • APIクライアントの開発は楽しくて勉強になるし、実益もある
  • 実際のAPIレスポンスを保存しておくとテストが簡単に書ける
  • APIリクエストに共通の処理をヘルパーメソッドとして切り出しておくと、メソッドの追加が簡単になる

好きなサービスのAPIのクライアントを好きな言語で実装していきましょう。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away