blog.syfm

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

Go の "missing go.sum entry" エラーによって Dependabot による PR が自動でマージできなくなる問題を修正する

今まで設定していたものたち

個人利用のリポジトリや社内リポジトリでは Dependabot を利用していて、依存のアップデートを自動で行うようにしていた。
依存のアップデートがあると Dependabot は Pull Request を出してくれるものの、放置しがちだったので自動で approve してくれるような GitHub Actions を入れていた。

これと GitHub ネイティブではない Dependabot の automerged_updates という設定項目を有効にすることで approve がついたものを Dependabot が自動でマージしてくれるような設定にしていた。 *1

Go 1.16 からの変更

Go 1.16 から go.mod に含まれている依存のうち、go.sum に含まれていないものが1つでもあるとビルドが通らなくなった。これは Go 1.16 Release Notes にも記述されている。

Build commands like go build and go test no longer modify go.mod and go.sum by default. Instead, they report an error if a module requirement or checksum needs to be added or updated (as if the -mod=readonly flag were used). Module requirements and sums may be adjusted with go mod tidy or go get.

これにより、 go mod tidy を行っておらず、新しい依存を go.sum へ記録していないケースではビルドが通らなくなってしまった。

そして、Dependabot は go mod tidy を行うことができないという制約があった。 *2

GitHub ネイティブな Dependabot の go mod tidy サポート

GitHub ネイティブな Dependabot では v2 から go mod tidy が公式にサポートされることになった。

github.blog

これにより、GitHub ネイティブな Dependabot の v2 へ移行することでビルドが通るようになった。

自動マージ問題ふたたび

しかし、GitHub ネイティブな Dependabot へ移行したことにより、自動マージができなくなってしまった。
GitHub が自動マージを削除した理由としては以下のようなものがある。

Auto-merge will not be supported in GitHub-native Dependabot for the foreseeable future. We know some of you have built great workflows that rely on auto-merge, but right now, we’re concerned about auto-merge being used to quickly propagate a malicious package across the ecosystem. We recommend always verifying your dependencies before merging them. *3

理由としては至極まっとうなので、これからもしばらくは自動マージ機能を入れるのは難しいだろうな…と思う。

しかし、Go の場合、Go modules を入れていれば、タグを切らない限り新しい変更が伝搬することはない & リリース前にそれぞれの依存にどんな変更があったのかはチェックしているので、自プロジェクトであれば自動マージを入れても問題ないと判断した。

そこで、GitHub Actions を使って自動的にマージを行ってくれるワークフローを書いた。

name: "Auto approve Pull Requests and enable auto-merge"
on:
  pull_request_target
jobs:
  worker:
    runs-on: ubuntu-latest
    if: github.actor == 'dependabot[bot]'
    steps:
      - name: Auto approve and merge Pull Request
        uses: actions/github-script@v3.1
        with:
          github-token: "${{ secrets.GH_TOKEN }}"
          script: |
            await github.pulls.createReview({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
              event: 'APPROVE'
            })

            const res = await github.graphql(`query {
              repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") {
                pullRequest(number: ${context.issue.number}) {
                  id
                }
              }
            }`)

            await github.graphql(`mutation {
              enablePullRequestAutoMerge(input: { pullRequestId: "${res.repository.pullRequest.id}" }) {
                clientMutationId
              }
            }`)

secrets.GH_TOKEN *4 に対応するアカウントが PR を approve し、Enable auto-merge を有効にしてくれる。これにより、PR がすべてのチェックをパスすると自動的にマージされるようになる。

*1:Dependabot には GitHub ネイティブのものと、そうでないものの2種類がある。GitHub ネイティブのものは自動マージ機能が削除されている。

*2:https://github.com/dependabot/dependabot-core/issues/2229

*3:https://github.com/dependabot/dependabot-core/issues/1973#issuecomment-640918321

*4:デフォルトで設定されている secrets.GITHUB_TOKEN だと enablePullRequestAutoMerge が動かない

一年を振り返り… (2020)

f:id:ktr_0731:20201231032607p:plain

仕事

去年と変わらずメルペイのコード決済チームでバックエンドエンジニアをしていた。
今年関わったプロジェクトの中でもっとも大きかったのはメルペイと d 払いの QR コード (MPM) 統一だった。これによってメルペイの QR コードを d 払いアプリで読み込んで決済ができるようになった。個人的には今はなき MoPA を思い出す Yet Another MoPA だったので今度こそは、という気概があった。また、決済周りの開発のコアメンバーとして携われ、今まで得た技術・運用知識を元にベストな設計を模索できる時間が十分にもらえたのもありがたかった。実際にリリースされると、店頭で新しいアクセプタンスマークを見ることが多くなったので達成感も非常に大きかった。

jp.merpay.com

また、運用を二年もしているとあらゆるパターンの障害やデータ不整合を経験するので、これらによって発生する問題を解消するためにどうするかを考えることも多かった。単純にデータ不整合を解消するだけであれば比較的容易であっても、ビジネス要件からデータ不整合の状態が許容される時間が制限されるので、いかにしてデータ不整合の未解消時間を短くするかが一番中心の課題だった。

技術

仕事面では去年と変わらず同じチームに所属していたため、扱っていた技術はあまり大きな変化はなかった。Cloud Spanner や Cloud Pub/Sub、Kubernetes、Go、gRPC、Protocol Buffers あたりが最もよく使っていた技術だと思う。Cloud Spanner の気持ちも少しずつわかってきて、クエリを見てそのクエリがどんな感じで実行されるのかがわかるようになってきたし、シンプルに記述するだけではどうやっても効率が悪くなるクエリをどう書き換えれば良いかがだんだん掴めるようになってきた。*1

また、社内で Istio の導入が始まっているため、Istio を始めとした Service Mesh 関連技術の勉強を始めた。

ソフトウェアアーキテクチャパターンへの関心が薄れてきて、高凝集で疎結合かつ SOLID 原則に基づいたモジュール分割ができていればそれで良いというシンプルな考えかたでコードを書くことが多くなった。おそらくこのあたりの考え方は以前に読んだ A Philosophy of Software Design の影響が強い。

プライベートではもっぱら Go を書いていることが多い。もともとプログラミング言語自体にあんまり関心がないので、自分がやりたいことをシンプルに記述できる言語である Go ばかり使っているし、今後も使い続けると思う。
趣味のプログラミングではバックエンドだけでなくフロントエンドも書くことが多かったので、JavaScript や TypeScript もよく使っていた。最近書いていたアプリケーションでは TypeScript、Vue.js、Nuxt.js、Apollo GraphQL あたりを使っていた。このあたりの技術はかなり Developer Experience が良くて好き。

執筆

Software Design へメルカリ・メルペイエンジニアが寄稿している連載の一環でゴルーチンとチャネルについての記事を書いた。普段の業務でゴルーチンやチャネルをそのまま扱うようなコードを書くことがあまりないため、自分自身にとっても良い頭の整理となった。また、自分が Go を学習しているときに読んでいた、プログラミング言語 Go を読み返してその内容の深さに改めて驚いた。

個人ブログではあまり多く執筆できなかった。ちゃんと意識していないとどんどん書かないようになってしまうので些細なことでもしっかりまとめてアウトプットしていけるように心がけたい。

イベント登壇

Merpay Tech Fest 2020 で登壇する予定だったが、新型コロナの影響でイベント自体が中止になった。12 月に Software Design へ寄稿したメルペイメンバーによる座談会で話したりはしたものの、今年はイベントで登壇することがほとんどなかった。リモート開催のイベントでスピーカーになる意欲はあまりなく、今と同じ状況が続くのであればしばらくは登壇することはないだろうなと漠然と感じる。

OSS

引き続き Evans や go-fuzzyfinder のメンテナンスをしていた。ただ、Evans へ新しく機能追加するモチベーションがなくなってきているので、v1 を見据えてそろそろ Evans の今後をどうするか考えないといけないなと漠然と感じている。go-fuzzyfinder については、利用してくれるツールが増えてきていたり、コントリビュートしてくれる人が増えてきているのでとても嬉しい。インターフェースのシンプルさがかなり気に入っているので、このシンプルさを保ったまま継続的に改善していければ、と思う。

自作 OSS の影響で自分が知っているツールを開発している企業からリファラル DM が飛んできたり *2、自作 OSS を紹介している記事が増えてきたりしていてとても嬉しい。自分がつくったものが実際に使われているのを目撃するとやっぱり嬉しいし、頑張ろうという励みになる。

自作 OSS 以外へのコントリビューションは、普段利用しているライブラリやツールのバグを見つけたときに行うことが多かった。GCP の Go ライブラリや Datadog APM の Go ライブラリ、protoreflect など。思ったより少なかったのでもう少し OSS のコードを読む量を増やしていきたい。

GitHub Sponsors では去年に引き続き 4 名もの方から支援をいただいていて、とても励みになっている。支援に見合うだけの動きができるように努力したい。

読んだ本

技術書

  • Database Internals
  • Istio: Up and Running
  • Microservices Patterns
  • OAuth徹底入門 セキュアな認可システムを適用するための原則と実践
  • Kubernetesで実践するクラウドネイティブDevOps
  • 人月の神話

かなり少ない…。月に最低一冊くらいは読んでいきたい。

ビジネス書

どちらもかなり読み応えのある本だった。Winny については金子勇自身が書いた本も読んでみたい。

ルポルタージュ

  • サカナとヤクザ: 暴力団の巨大資金源「密漁ビジネス」を追う

知らないことだらけで面白い。

哲学書

  • 愛するということ

哲学が好きなので引き込まれた。また読み返したい。

漫画

サマータイムレンダ、ランウェイで笑って、進撃の巨人が特に面白かった。

旅行

本来であれば草津や、毎年恒例の ISUCON メンバーでの旅行など、もっと多くの旅行をするつもりが新型コロナによって大きく予定が狂ってしまった。来年は少しは落ち着きますように 🙏

沖縄

閑散期である 1 月に行った。航空券は往復で 8000 円程度と非常に安くなっていた。また、大学の友人らと行ったため Airbnb で一軒家の宿を借りたところ、広々とした綺麗な家で大満足だった。アメリカンビレッジへも徒歩で行ける距離だったので夜ごはんや飲みのために繰り出したりできて便利だった。

京都

Go To トラベルが適用されていた 12 月の第一週に行った。東京 ⇔ 京都の新幹線往復券 + 一泊で 15000 円に加え、地域共通クーポン 3500 円分、京都タワーでもらえた謎の商品券 1000 円分が加わり実質 10500 円程度という異次元の安さだった。ちょうど Go To トラベルを停止するべきという声が大きくなっていた時期だったのもあり、人出は思った以上に少なくなっていた。特に清水寺二年坂あたりは普段であれば尋常じゃない人がいるのに、今回は周りを気にせず歩けるくらいに人が少なかった。また、はてなインターン同窓会ぶりにカマルへ行けたので良かった。

f:id:ktr_0731:20201229133301j:plainf:id:ktr_0731:20201229133305j:plainf:id:ktr_0731:20201229133453j:plainf:id:ktr_0731:20201229133306j:plain

観たアニメ

あんまり観た記憶がなかったけど、履歴を元に洗い出したら結構な数のアニメを観ていた…。テレビアニメだと思い出補正もあるだろうけどイエスタデイをうたってが特に良かった。劇場アニメだと千と千尋の神隠しを除くと SHIROBAKO が最も良かった。観ると仕事を頑張ろうという気持ちにさせてくれるようなアニメだし、制作陣がやりたいことをやっているのを感じられて良かった。円盤も買ったので年明けが楽しみ。千と千尋の神隠しは最も好きな・影響を受けたアニメーション作品なので、今の時代に劇場で観れたことが本当に嬉しい。

やったゲーム

十三機兵防衛圏はかなりストーリーが難解で、かなり後半にならないと全貌がわからなかったが、伏線が張り巡らされていてとても面白かった。Detroit: Become Human は今年やったゲームのなかで一番面白かった。*3 哲学的な面も多くあるのでこういった近未来 SF はとても惹きつけられたし、ゲームシステムも独特で常に映画を観ているような臨場感があった。コナーとハンクの関係が好きすぎる。

人生

父方の祖父が亡くなった。きわめて身近な人の死に触れたのは初めてだったのでとても哀しかったし、家族や自分自身の死を考えてしまって改めて死への恐怖を感じるようになってしまったのとともに、日常の変化からだんだんと身近な人の死が近づいてくるのが感じ取れるようになってしまって苦しい。人間いつかは死ぬものの、今は死ぬのに未練がありすぎるし早く電脳化したい。

終わりに

今年は新型コロナによって生活が大きく変わり、常識だったものがそうではなくなってしまった一年だった。その影響で苦しいことが増えた一年だったものの、嬉しいことや楽しいこともまた同じように感じられた一年でもあった。WFH をしていると変化のない日常が繰り返されているかのように感じてしまうので、来年はいろいろな変化をつけられるような年にしていきたい。


去年

syfm.hatenablog.com

*1:とはいえ必ず実行計画を見るべきだけど…。

*2:今の所転職するつもりはないので断った…。

*3:そもそも本数が少ないけど

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 クライアントの作成自体は結構好きなのですが、何度も同じことをやっていると虚無になるのでそういったときは自動化するのが良いですね。