blog.syfm

徒然なるままに考えていることなどを書いていくブログ

curl から Go の API クライアントを自動生成する

これは Aizu Advent Calendar 2020 の 10 日目の記事です。9 日目は虚無さん、11 日目は虚無さんです。


curl を使って API クライアントやリクエスト・レスポンス型を自動生成したいと思ったことはありませんか?わたしはあります。ということでつくりました。

github.com

使い方

apigenCLI ツールではなく、apigen をインポートしてジェネレータを書き、実行することでコード生成を行います。CLI ツールにしていない理由は、API 定義を与える方法が難しく、逆に複雑さが増してしまうからです。Go のコードを自動生成したいユーザであれば間違いなく Go の実行環境があるのでジェネレータを Go で実装させるような方式としています。

まず、API サーバが持つサービス (複数のメソッドを包括したものの名称) やそのサービスが持つメソッドを表す *apigen.Definition を定義する必要があります。以下の例では Dummy サービスに CreatePostListPostsGetPost メソッドを定義しています。Request フィールドは実行環境を示しており、コード生成時にこの環境で実際に各メソッドを叩いていきます。この例であればいずれのメソッドも curl を使ってリクエストを行うことになります。Request フィールドの型 RequestFunc を満たすものであればどのような実装でも良いため、curl が好きではない人は Wget を使ったり、Go の *http.Client を使うということもできます。

ParamHint はパスパラメータ (/posts/11 のようなリソースによって変化する部分) を解釈するためのものです。これはパスパラメータを利用しているメソッドでは必須となります。

def := &apigen.Definition{
    Services: map[string][]*apigen.Method{
        "Dummy": {
            {
                Name:    "CreatePost",
                Request: curl.ParseCommand(`
                  curl 'https://jsonplaceholder.typicode.com/posts' 
                      --data-binary '{"title":"foo","body":"bar","userId":1}'`,
                ),
            },
            {
                Name:    "ListPosts",
                Request: curl.ParseCommand(`curl https://jsonplaceholder.typicode.com/posts`),
            },
            {
                Name:    "GetPost",
                Request: curl.ParseCommand(`curl https://jsonplaceholder.typicode.com/posts?id=1`),
            },
            {
                Name:      "UpdatePost",
                Request:   curl.ParseCommand(`
                  curl 'https://jsonplaceholder.typicode.com/posts/1' 
                      -X 'PUT' --data-binary '{"title":"foo","body":"bar","userId":1}'`,
                ),
                ParamHint: "/posts/{postID}",
            },
            {
                Name:      "DeletePost",
                Request:   curl.ParseCommand(`
                  curl 'https://jsonplaceholder.typicode.com/posts/1' -X 'DELETE'`,
                ),
                ParamHint: "/posts/{postID}",
            },
        },
    },
}

この定義を使って実際にコード生成を行います。コード生成を行うには apigen.Generate を呼び出します。apigen.WithWriter オプションを使うと出力先を変更することができます。デフォルトは標準出力です。

f, err := os.Create("client_gen.go")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

if err := apigen.Generate(context.Background(), def, apigen.WithWriter(f)); err != nil {
    log.Fatal(err)
}

生成した API クライアントを使って実際にリクエストを投げてみます。例では client.ConvertStatusCodeToErrorInterceptor() を使って 2XX 系以外のステータスコードをエラーに変換していますが、より現実のアプリケーションでは独自にエラー変換用の interceptor を定義するのが良いでしょう。

client := NewDummyClient(client.WithInterceptors(client.ConvertStatusCodeToErrorInterceptor()))

res, err := client.GetPost(context.Background(), &GetPostRequest{ID: "10"})
if err != nil {
    log.Fatal(err)
}

b, err := json.MarshalIndent(&res, "", "  ")
if err != nil {
        log.Fatal(err)
}

fmt.Println(string(b))

以下のように正しくレスポンスが返り、デコードすることができました。

[
  {
    "body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error",
    "id": 10,
    "title": "optio molestias id quia eum",
    "userId": 1
  }
]

もうちょっと複雑な例を試してみましょう。GitHub APIリポジトリ取得 API を使ってみます。

var def = &apigen.Definition{
    Services: map[string][]*apigen.Method{
        "GitHub": {
            {
                Name:      "GetRepository",
                Request:   curl.ParseCommand(`curl https://api.github.com/repos/ktr0731/go-fuzzyfinder`),
                ParamHint: "/repos/{owner}/{repo}",
            },
        },
    },
}

func main() {
    if err := apigen.Generate(context.Background(), def, apigen.WithPackage("main")); err != nil {
        log.Fatal(err)
    }
}

実行結果 (レスポンスフィールドが多いので折りたたみ)

// Code generated by apigen; DO NOT EDIT.
// github.com/ktr0731/apigen

package main

import (
    "context"
    "fmt"
    "net/url"

    "github.com/ktr0731/apigen/client"
)

type GitHubClient interface {
    GetRepository(ctx context.Context, req *GetRepositoryRequest) (*GetRepositoryResponse, error)
}

type gitHubClient struct {
    *client.Client
}

func NewGitHubClient(opts ...client.Option) GitHubClient {
    return &gitHubClient{Client: client.New(opts...)}
}

func (c *gitHubClient) GetRepository(ctx context.Context, req *GetRepositoryRequest) (*GetRepositoryResponse, error) {
    u, err := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s", req.Owner, req.Repo))
    if err != nil {
        return nil, err
    }

    var res GetRepositoryResponse
    err = c.Do(ctx, "GET", u, nil, &res)
    return &res, err
}

type GetRepositoryRequest struct {
    Owner string
    Repo  string
}

type GetRepositoryResponse struct {
    ArchiveURL       string  `json:"archive_url,omitempty"`
    Archived         bool    `json:"archived,omitempty"`
    AssigneesURL     string  `json:"assignees_url,omitempty"`
    BlobsURL         string  `json:"blobs_url,omitempty"`
    BranchesURL      string  `json:"branches_url,omitempty"`
    CloneURL         string  `json:"clone_url,omitempty"`
    CollaboratorsURL string  `json:"collaborators_url,omitempty"`
    CommentsURL      string  `json:"comments_url,omitempty"`
    CommitsURL       string  `json:"commits_url,omitempty"`
    CompareURL       string  `json:"compare_url,omitempty"`
    ContentsURL      string  `json:"contents_url,omitempty"`
    ContributorsURL  string  `json:"contributors_url,omitempty"`
    CreatedAt        string  `json:"created_at,omitempty"`
    DefaultBranch    string  `json:"default_branch,omitempty"`
    DeploymentsURL   string  `json:"deployments_url,omitempty"`
    Description      string  `json:"description,omitempty"`
    Disabled         bool    `json:"disabled,omitempty"`
    DownloadsURL     string  `json:"downloads_url,omitempty"`
    EventsURL        string  `json:"events_url,omitempty"`
    Fork             bool    `json:"fork,omitempty"`
    Forks            float64 `json:"forks,omitempty"`
    ForksCount       float64 `json:"forks_count,omitempty"`
    ForksURL         string  `json:"forks_url,omitempty"`
    FullName         string  `json:"full_name,omitempty"`
    GitCommitsURL    string  `json:"git_commits_url,omitempty"`
    GitRefsURL       string  `json:"git_refs_url,omitempty"`
    GitTagsURL       string  `json:"git_tags_url,omitempty"`
    GitURL           string  `json:"git_url,omitempty"`
    HTMLURL          string  `json:"html_url,omitempty"`
    HasDownloads     bool    `json:"has_downloads,omitempty"`
    HasIssues        bool    `json:"has_issues,omitempty"`
    HasPages         bool    `json:"has_pages,omitempty"`
    HasProjects      bool    `json:"has_projects,omitempty"`
    HasWiki          bool    `json:"has_wiki,omitempty"`
    Homepage         string  `json:"homepage,omitempty"`
    HooksURL         string  `json:"hooks_url,omitempty"`
    ID               float64 `json:"id,omitempty"`
    IssueCommentURL  string  `json:"issue_comment_url,omitempty"`
    IssueEventsURL   string  `json:"issue_events_url,omitempty"`
    IssuesURL        string  `json:"issues_url,omitempty"`
    KeysURL          string  `json:"keys_url,omitempty"`
    LabelsURL        string  `json:"labels_url,omitempty"`
    Language         string  `json:"language,omitempty"`
    LanguagesURL     string  `json:"languages_url,omitempty"`
    License          struct {
        Key    string `json:"key,omitempty"`
        Name   string `json:"name,omitempty"`
        NodeID string `json:"node_id,omitempty"`
        SpdxID string `json:"spdx_id,omitempty"`
        URL    string `json:"url,omitempty"`
    } `json:"license,omitempty"`
    MergesURL        string      `json:"merges_url,omitempty"`
    MilestonesURL    string      `json:"milestones_url,omitempty"`
    MirrorURL        interface{} `json:"mirror_url,omitempty"`
    Name             string      `json:"name,omitempty"`
    NetworkCount     float64     `json:"network_count,omitempty"`
    NodeID           string      `json:"node_id,omitempty"`
    NotificationsURL string      `json:"notifications_url,omitempty"`
    OpenIssues       float64     `json:"open_issues,omitempty"`
    OpenIssuesCount  float64     `json:"open_issues_count,omitempty"`
    Owner            struct {
        AvatarURL         string  `json:"avatar_url,omitempty"`
        EventsURL         string  `json:"events_url,omitempty"`
        FollowersURL      string  `json:"followers_url,omitempty"`
        FollowingURL      string  `json:"following_url,omitempty"`
        GistsURL          string  `json:"gists_url,omitempty"`
        GravatarID        string  `json:"gravatar_id,omitempty"`
        HTMLURL           string  `json:"html_url,omitempty"`
        ID                float64 `json:"id,omitempty"`
        Login             string  `json:"login,omitempty"`
        NodeID            string  `json:"node_id,omitempty"`
        OrganizationsURL  string  `json:"organizations_url,omitempty"`
        ReceivedEventsURL string  `json:"received_events_url,omitempty"`
        ReposURL          string  `json:"repos_url,omitempty"`
        SiteAdmin         bool    `json:"site_admin,omitempty"`
        StarredURL        string  `json:"starred_url,omitempty"`
        SubscriptionsURL  string  `json:"subscriptions_url,omitempty"`
        Type              string  `json:"type,omitempty"`
        URL               string  `json:"url,omitempty"`
    } `json:"owner,omitempty"`
    Private          bool        `json:"private,omitempty"`
    PullsURL         string      `json:"pulls_url,omitempty"`
    PushedAt         string      `json:"pushed_at,omitempty"`
    ReleasesURL      string      `json:"releases_url,omitempty"`
    SSHURL           string      `json:"ssh_url,omitempty"`
    Size             float64     `json:"size,omitempty"`
    StargazersCount  float64     `json:"stargazers_count,omitempty"`
    StargazersURL    string      `json:"stargazers_url,omitempty"`
    StatusesURL      string      `json:"statuses_url,omitempty"`
    SubscribersCount float64     `json:"subscribers_count,omitempty"`
    SubscribersURL   string      `json:"subscribers_url,omitempty"`
    SubscriptionURL  string      `json:"subscription_url,omitempty"`
    SvnURL           string      `json:"svn_url,omitempty"`
    TagsURL          string      `json:"tags_url,omitempty"`
    TeamsURL         string      `json:"teams_url,omitempty"`
    TempCloneToken   interface{} `json:"temp_clone_token,omitempty"`
    TreesURL         string      `json:"trees_url,omitempty"`
    URL              string      `json:"url,omitempty"`
    UpdatedAt        string      `json:"updated_at,omitempty"`
    Watchers         float64     `json:"watchers,omitempty"`
    WatchersCount    float64     `json:"watchers_count,omitempty"`
}

実際に叩いてみます。

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"

    "github.com/ktr0731/apigen/client"
)

func main() {
    client := NewGitHubClient(client.WithInterceptors(client.ConvertStatusCodeToErrorInterceptor()))

    res, err := client.GetRepository(context.Background(), &GetRepositoryRequest{
        Owner: "ktr0731",
        Repo:  "go-fuzzyfinder",
    })
    if err != nil {
        log.Fatal(err)
    }

    b, err := json.MarshalIndent(&res, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(b))
}

実行結果

{
  "archive_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/{archive_format}{/ref}",
  "assignees_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/assignees{/user}",
  "blobs_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/blobs{/sha}",
  "branches_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/branches{/branch}",
  "clone_url": "https://github.com/ktr0731/go-fuzzyfinder.git",
  "collaborators_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/collaborators{/collaborator}",
  "comments_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/comments{/number}",
  "commits_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/commits{/sha}",
  "compare_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/compare/{base}...{head}",
  "contents_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/contents/{+path}",
  "contributors_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/contributors",
  "created_at": "2019-01-24T08:04:06Z",
  "default_branch": "master",
  "deployments_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/deployments",
  "description": "fzf-like fuzzy-finder as a Go library",
  "downloads_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/downloads",
  "events_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/events",
  "forks": 18,
  "forks_count": 18,
  "forks_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/forks",
  "full_name": "ktr0731/go-fuzzyfinder",
  "git_commits_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/commits{/sha}",
  "git_refs_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/refs{/sha}",
  "git_tags_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/tags{/sha}",
  "git_url": "git://github.com/ktr0731/go-fuzzyfinder.git",
  "html_url": "https://github.com/ktr0731/go-fuzzyfinder",
  "has_downloads": true,
  "has_issues": true,
  "has_projects": true,
  "has_wiki": true,
  "hooks_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/hooks",
  "id": 167327805,
  "issue_comment_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/issues/comments{/number}",
  "issue_events_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/issues/events{/number}",
  "issues_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/issues{/number}",
  "keys_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/keys{/key_id}",
  "labels_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/labels{/name}",
  "language": "Go",
  "languages_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/languages",
  "license": {
    "key": "mit",
    "name": "MIT License",
    "node_id": "MDc6TGljZW5zZTEz",
    "spdx_id": "MIT",
    "url": "https://api.github.com/licenses/mit"
  },
  "merges_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/merges",
  "milestones_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/milestones{/number}",
  "name": "go-fuzzyfinder",
  "network_count": 18,
  "node_id": "MDEwOlJlcG9zaXRvcnkxNjczMjc4MDU=",
  "notifications_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/notifications{?since,all,participating}",
  "open_issues": 3,
  "open_issues_count": 3,
  "owner": {
    "avatar_url": "https://avatars1.githubusercontent.com/u/12953836?v=4",
    "events_url": "https://api.github.com/users/ktr0731/events{/privacy}",
    "followers_url": "https://api.github.com/users/ktr0731/followers",
    "following_url": "https://api.github.com/users/ktr0731/following{/other_user}",
    "gists_url": "https://api.github.com/users/ktr0731/gists{/gist_id}",
    "html_url": "https://github.com/ktr0731",
    "id": 12953836,
    "login": "ktr0731",
    "node_id": "MDQ6VXNlcjEyOTUzODM2",
    "organizations_url": "https://api.github.com/users/ktr0731/orgs",
    "received_events_url": "https://api.github.com/users/ktr0731/received_events",
    "repos_url": "https://api.github.com/users/ktr0731/repos",
    "starred_url": "https://api.github.com/users/ktr0731/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/ktr0731/subscriptions",
    "type": "User",
    "url": "https://api.github.com/users/ktr0731"
  },
  "pulls_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/pulls{/number}",
  "pushed_at": "2020-12-03T18:19:49Z",
  "releases_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/releases{/id}",
  "ssh_url": "git@github.com:ktr0731/go-fuzzyfinder.git",
  "size": 135,
  "stargazers_count": 199,
  "stargazers_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/stargazers",
  "statuses_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/statuses/{sha}",
  "subscribers_count": 5,
  "subscribers_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/subscribers",
  "subscription_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/subscription",
  "svn_url": "https://github.com/ktr0731/go-fuzzyfinder",
  "tags_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/tags",
  "teams_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/teams",
  "trees_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/trees{/sha}",
  "url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder",
  "updated_at": "2020-12-09T13:01:20Z",
  "watchers": 199,
  "watchers_count": 199
}

ちゃんとすべてのフィールドに値が入っています。このように、apigen を使うと簡単に API クライアントを生成することができます。

実装

apigen は以下のようなプロセスでコード生成を行っています。

  1. API 定義を読み込む
  2. API 定義からリクエストを作成
  3. メソッド呼び出し
  4. API 定義とリクエストからリクエスト型を作成
  5. メソッドのレスポンスからレスポンス型を作成
  6. API 定義、リクエスト型、レスポンス型からコード生成

このうち、特徴的な部分をいくつか紹介します。

API 定義からリクエストを作成

curl を使ったコマンドからリクエストを作成しているこの処理では、実際に curl を叩いているわけではなく、curl が備えているフラグをパースしたあとに *http.Request を作成しています。渡されたコマンド文字列を *pflag.FlagSet に食わせることで HTTP メソッドやリクエストボディ、ヘッダなどを取り出しています。

args, err := shellwords.Parse(cmd)
if err != nil {
    return nil, fmt.Errorf("failed to parse command '%s', err = '%s': %w", cmd, err, apigen.ErrInvalidDefinition)
}

for i := range args {
    args[i] = strings.TrimSpace(args[i])
}

var flags flags
fs := pflag.NewFlagSet("curl", pflag.ContinueOnError)
fs.StringArrayVarP(&flags.headers, "header", "H", nil, "")
fs.StringVarP(&flags.request, "request", "X", http.MethodGet, "")
fs.StringVar(&flags.data, "data-binary", "", "")
fs.StringVarP(&flags.data, "data", "d", "", "")
fs.BoolVar(&flags.compressed, "compressed", false, "")

if err := fs.Parse(args); err != nil {
    return nil, fmt.Errorf("failed to parse curl flags, err = '%s': %w", err, apigen.ErrInvalidDefinition)
}

すべてのフラグを網羅しているわけではないので登録されていないフラグを渡すとエラーになりますが、必要に応じて追加していけば良いと思い、今の段階では最低限のフラグのみ定義されています。

メソッドのレスポンスからレスポンス型を作成

レスポンスボディのスキーマをあらかじめ知ることはできないため、map[string]interface{} もしくは []interface{} へレスポンスボディをデコードしています。今の所コーデックは JSON のみをサポートしています。 encoding/json において、map[string]interface{}JSON オブジェクト、[]interface{} は配列のための型です。まずこの動的な型へデコードしてからオブジェクトのフィールドや配列の要素の型を特定していっています。

API 定義、リクエスト型、レスポンス型からコード生成

コード生成は text/template を一切使わずに 筋肉によってコード生成 しています。この方法は protoc-gen-go などでも採用されています。
text/template を使わない理由は牧さんのスライドで触れられている内容とほとんど同じです。

生成したコードは goimports が使っているパッケージである imports パッケージでフォーマットしています。

課題

レスポンスボディの JSON をソースにしてコード生成を行うという性質上、以下のようなことが課題として挙げられます。

  • null が入っているフィールドの型情報が取得できない
  • 動的に値が変わるレスポンスで適切なレスポンス型を生成できない

null フィールドの問題は JSON が静的型付けのスキーマを持っていないためどうしようもありません。null フィールドを少しでも減らすために同じメソッドに対する複数のリクエストのレスポンスを合成できるような機能があると良いかもしれませんが要検討です。
動的に値が変わるレスポンスへの対応も同じような方針で生成できないケースを減らすことができますが完璧ではありません。とはいえ、そういったレスポンスはアンチパターンである場合がほとんどなのでパブリックな Web API でそういったスキーマに出会うことは稀かと思います。

まとめ

apigen を使うと curl などの結果を元に Go の API クライアントのコードを自動生成することができます。一部うまく動作しない箇所もありますが、ほとんどのケースでうまく動作します。
自分は API クライアントの作成自体は結構好きなのですが、何度も同じことをやっていると虚無になるのでそういったときは自動化するのが良いですね。