GitHub GraphQL APIをGoから実行する。(GitHub App認証)

:::note
本記事では、GitHub GraphQL APIをGoから実行する場合における、認証処理の実装を記載しています。
認証はGitHub Appの仕組みを使用しています。
:::

# はじめに
こんにちは、株式会社Kakukakuのエンジニアのmamoです。
今回、社内でGitHub GraphQL APIをGoからで呼び出す必要があり、GitHub公式ページなどを参考に、実装してみたので実装例のご紹介です。
筆者はGitHub APIの呼び出しやgolang初体験で、至らないところもあるかと思います。

# 前提条件
:::note
go version go1.21.3 darwin/arm64
事前にGitHub Appの作成およびインストール済で、AppIDを取得し、秘密鍵をダウンロード済であること。
:::

# 対象読者
GitHub APIを便利なライブラリなしで認証実装したい人向け。
githubで公開されているような既存ライブラリを使えば認証処理の実装は省けると思うのですが、GitHub公式ページに記載の内容を参考に、実装を試みてみたので、その足跡です。
また、golangも初めて触れたので、末尾に所感を書いています。

# GitHub GraphQL API？
GitHub公式ページに「GraphQLでの呼び出しの作成」というページがあり、そこを足がかりに進めてみます。

https://docs.github.com/ja/graphql/guides/forming-calls-with-graphql

まずGraphQL APIの認証にはpersonal access token(PAT)、GitHub App、OAuth appの3種類の方法がありますが、今回は社内リポジトリを自動巡回するクローリングアプリを作成する要件であったため、GitHub Appの認証方法を採用しました。

PAT認証の場合、いち個人のユーザーに権限が紐づいてしまい管理上の問題が出るのと、ユーザーのアカウントがクローズした場合にクローリングアプリも停止する可能性があったため、途中でやめました。

# GraphQLでの通信

curlコマンドの場合、下記の文法になります。

```bash
curl -H "Authorization: bearer TOKEN" -X POST -d " \
 { \
   \"query\": \"query { viewer { login }}\" \
 } \
" https://api.github.com/graphql
```

ここで指定するTOKENは、JSON Web Token(JWT)ではなく、「GitHub App インストールとしての認証」ページに記載されているインストールアクセストークンを指定します。

https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation

# インストールアクセストークン

インストールアクセストークンの作り方は手順があり、少し手間がかかります。

1. JWTを作成する。
1. 作成したJWTトークンを使って/app/installationsなどへGETリクエストし、インストールIDを取得する。
1. 取得したインストールID(INSTALLATION_ID)を使って/app/installations/INSTALLATION_ID/access_tokensへPOSTリクエストし、インストールアクセストークンを取得する。

取得したインストールアクセストークンの有効時間は1時間で、有効時間内であれば、GitHubのGraphQL API、REST API両方の呼び出しで使用できます。

自前で認証処理を実装する場合、このようなトークンの有効時間を管理し、時間が切れるタイミングで新しいトークンを発行して使用するなどのライフサイクル管理も必要になってきます。

本記事の実装例では、ライフサイクル管理はしていないです。

:::note info
ChatGPT v4によれば、一度インストールIDを取得してしまえば、GitHub Appをアンインストール＆再インストールしたり、再認証を要する権限変更をしない限り、インストールIDを固定値として、使い回しできるようです。
:::

# goによる実装例

GitHubAppIDは、事前に作成済のGitHub AppのAppIDを指定します。
GitHubAppPrivateKeyFileは、GitHub Appの秘密鍵のpemファイルを指定します。
GraphQLの実行結果が単一(配列ではない)の場合と複数(配列)の場合があり、そこら辺の処理は場当たり的に対応したのでこの実装で良いのか疑問が残っています。

```go:github.go
package main

import (
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/dgrijalva/jwt-go"
)

const (
	GitHubAppID             = "XXXXXX"
	GitHubAppPrivateKeyFile = "commitcrawler.2023-10-30.private-key.pem"
)

/* 汎用関数定義 */

// HTTPリクエストの実行
// (戻り値)
// JSONバイトデータ
func doHttp(req *http.Request) []byte {
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	jsonByte, _ := io.ReadAll(resp.Body)

	return jsonByte
}

// JSONバイトデータの変換(入力が単一)
func convJsonByteToMap(jsonByte []byte) map[string]interface{} {
	// マップへ変換
	var data map[string]interface{}

	err := json.Unmarshal([]byte(jsonByte), &data)
	if err != nil {
		panic(err)
	}

	return data
}

// JSONバイトデータの変換(入力が複数)
func convJsonBytesToMaps(jsonByte []byte) []map[string]interface{} {
	str := string(jsonByte)
	parts := strings.Split(str, "\n")

	var allData []map[string]interface{}

	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}

		var data []map[string]interface{}
		err := json.Unmarshal([]byte(part), &data)
		if err != nil {
			panic(err)
		}

		allData = append(allData, data...)
	}

	return allData
}

/* GraphQL問い合わせ用 */

// JWTトークンの取得
// https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
func generateJWT() (string, error) {
	privateKey, err := os.ReadFile(GitHubAppPrivateKeyFile)
	if err != nil {
		return "", err
	}

	key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
	if err != nil {
		return "", err
	}

	// Create a new token object
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
		"iat": time.Now().Unix(),
		"exp": time.Now().Add(10 * time.Minute).Unix(),
		"iss": GitHubAppID,
	})

	// Sign and get the complete encoded token as a string using the secret
	tokenString, err := token.SignedString(key)

	return tokenString, err
}

// インストールアクセストークンURLの取得
// https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
func getGitHubInstallAccessTokenURL(jwt string) (string, error) {
	// Use the JWT to authenticate with the GitHub API
	// https://docs.github.com/ja/rest/apps/apps?apiVersion=2022-11-28
	req, _ := http.NewRequest("GET", "https://api.github.com/app/installations", nil)
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("Authorization", "Bearer "+jwt)
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

	// 結果の取得
	jsonByte := doHttp(req)
	data := convJsonBytesToMaps(jsonByte)

	return data[0]["access_tokens_url"].(string), nil
}

// インストールアクセストークンの取得
//
// (戻り値の例)
// "token": "ghs_xxx"
// "expires_at": "2023-10-31T06:32:37Z"
//
//	"permissions":
//	  "data":
//	    "contents": "read",
//	    "metadata": "read",
//	    "statuses": "read"
//
// "repository_selection": "all"
//
// https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
func getGitHubInstallAccessToken(jwt string) (map[string]interface{}, error) {
	accessTokenUrl, err := getGitHubInstallAccessTokenURL(jwt)
	if err != nil {
		panic(err)
	}

	// Use the JWT to authenticate with the GitHub API
	// https://docs.github.com/ja/rest/apps/apps?apiVersion=2022-11-28
	req, _ := http.NewRequest("POST", accessTokenUrl, nil)
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("Authorization", "Bearer "+jwt)
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

	// 結果の取得
	jsonByte := doHttp(req)
	data := convJsonByteToMap(jsonByte)

	return data, nil
}

// GitHub GraphQL APIの実行
// 引数 query クエリ文字列。改行・タブは自動削除されます。
// Explorer: https://docs.github.com/ja/graphql/overview/explorer
func RunGitHubGQLAPI(query string) (interface{}, error) {
	// MEMO: API呼び出しの度にJWTとアクセストークンを再生成している。
	// 頻繁にAPIをコールする場合は、処理が無駄なので個別管理する。
	jwt, err := generateJWT()
	if err != nil {
		panic(err)
	}

	accessToken, err := getGitHubInstallAccessToken(jwt)
	if err != nil {
		panic(err)
	}

	// queryから改行・タブを削除する。
	queryWithoutNewlines := strings.ReplaceAll(query, "\n", "")
	queryWithoutNewlinesAndTabs := strings.ReplaceAll(queryWithoutNewlines, "\t", "")

	// Use the AccessTokent to authenticate with the GitHub GraphQL API
	// https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
	req, _ := http.NewRequest("POST", "https://api.github.com/graphql", strings.NewReader(queryWithoutNewlinesAndTabs))
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("Authorization", "Bearer "+accessToken["token"].(string))

	// 結果の取得
	jsonByte := doHttp(req)
	var data interface{}
	if len(jsonByte) > 0 && jsonByte[0] == '[' {
		// データを正常に取得できた場合
		data = convJsonBytesToMaps(jsonByte)
	} else {
		// エラーの場合(返却値が配列ではない)
		data = convJsonByteToMap(jsonByte)
	}

	return data, nil
}

```

# goによるGraphQLの呼び出し例

先ほどのgithub.goのRunGitHubGQLAPIを呼び出します。

```go:main.go
import (
	"fmt"
	"log"
 )
 
func main() {
	// GraphQL query
	query := `{
		"query": "query {
			viewer {
			  repositories(first: 10) {
				nodes {
				  name
				  description
				  url
				}
				pageInfo {
				  endCursor
				  hasNextPage
				}
			  }
			}
		  }
		"
	}`

	data, err := RunGitHubGQLAPI(query)
	if err != nil {
		log.Fatalf("Error fetching GitHub repos: %v", err)
	}
    
    fmt.Println(data)
}
```

# まとめ
もっと簡単にGraphQL APIを呼び出せるかと思っていたのですが、意外に手順が多くて大変でした。
認証系の実装の場合、問題が発生しても「credential problem」という感じでエラー調査に使える情報がほとんど出力されず、一つ一つ手順を確認するしかないのが辛いポイントです。

# golangを1日触った所感

- main関数があるファイルのpackageはmainではないとだめ。
- 外部ファイルから呼び出される関数の名前の先頭は大文字。小文字だと内部のみになる。
- ポインタがある。C/C++以外で初めてみた。パフォーマンスをカリカリに詰めるため？
- throw/catchがなさそう。昔ながらの戻り値でエラーハンドリングが主流？
- やたらinterfaceが出てくる。map[string]interface{}など、まだ馴染めない。
- := は初回代入。= は2回目以降の代入。(Delphiでは := で初回、2回目以降の代入どちらも)
- requireの参照元GitHubリポジトリが更新されたら、go get で最新コミットを取得しないといけない。
- 関数の引数にデフォルト引数がない。なぜ・・・。

golangは周囲の評判が良かったので、初めて使ってみましたが、巷で言われるように軽量/コンパクトな言語である印象を持ちました。
軽量/コンパクトでありながら、パフォーマンスを意識した処理を実装でき、ライブラリも豊富にあるようなので、バックエンドの処理など、やりたいことをサッと書くのに便利そうです。
ただ、golangを使って大規模なコーディングをした場合、どうやって統制担保するのかは分かりませんでした。


# 参考文献
- GitHub公式
- ChatGPT先生
- その他、インターネット上のたくさんの記事

# 最後に

最後まで読んでいただきありがとうございました。
私達、株式会社Kakukakuでは一緒に開発を行ってくれるスタッフ、パートナーを募集しております。
https://kakukaku.app/