curl から Go の API クライアントを自動生成する
これは Aizu Advent Calendar 2020 の 10 日目の記事です。9 日目は虚無さん、11 日目は虚無さんです。
curl
を使って API クライアントやリクエスト・レスポンス型を自動生成したいと思ったことはありませんか?わたしはあります。ということでつくりました。
使い方
apigen
は CLI ツールではなく、apigen
をインポートしてジェネレータを書き、実行することでコード生成を行います。CLI ツールにしていない理由は、API 定義を与える方法が難しく、逆に複雑さが増してしまうからです。Go のコードを自動生成したいユーザであれば間違いなく Go の実行環境があるのでジェネレータを Go で実装させるような方式としています。
まず、API サーバが持つサービス (複数のメソッドを包括したものの名称) やそのサービスが持つメソッドを表す *apigen.Definition
を定義する必要があります。以下の例では Dummy
サービスに CreatePost
、ListPosts
、GetPost
メソッドを定義しています。Request
フィールドは実行環境を示しており、コード生成時にこの環境で実際に各メソッドを叩いていきます。この例であればいずれのメソッドも curl
を使ってリクエストを行うことになります。Request
フィールドの型 RequestFunc
を満たすものであればどのような実装でも良いため、curl
が好きではない人は Wget
を使ったり、Go の *http.Client
を使うということもできます。
ParamHint
はパスパラメータ (/posts/1
の 1
のようなリソースによって変化する部分) を解釈するためのものです。これはパスパラメータを利用しているメソッドでは必須となります。
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
は以下のようなプロセスでコード生成を行っています。
- API 定義を読み込む
- API 定義からリクエストを作成
- メソッド呼び出し
- API 定義とリクエストからリクエスト型を作成
- メソッドのレスポンスからレスポンス型を作成
- 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 クライアントの作成自体は結構好きなのですが、何度も同じことをやっていると虚無になるのでそういったときは自動化するのが良いですね。