go言語でGoogleカレンダーを操作する便利なサンプルコード(codeを標準入力しなくてよい版)

## はじめに

go言語でGoogleカレンダーを操作する方法について、2023年12月時点の公式のクイックスタートのサンプルコードが不便だったため、本ブログではより便利なサンプルコードを紹介します。

(この投稿は[GO Inc. Advent Calendar 2023](https://adventar.org/calendars/8633)の一部です)

## クイックスタートのサンプルコードは何が面倒か

GoogleカレンダーをGO言語で操作しようとしてインターネットを検索すると、一番最初にでてくるのは公式の[クイックスタートのサンプルコード](https://developers.google.com/calendar/api/quickstart/go?hl=ja)だとおもいます。

ですが、このコード、**初回実行時に認証コードを切り取って標準入力に渡す部分がとても面倒です**。

具体的には、手順どおりに作業して、go言語のプログラムを書いて実行すると、コンソールに以下のように指示が出て、標準入力待ちになります。

```
Go to the following link in your browser then type the authorization code: 
https://accounts.google.com/o/oauth2/auth?access_type=offline&client_id=xxx.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly&state=state-token
```

これが何を言ってるかというと、このURLをブラウザに入力し、ブラウザで認証を行い、認証後のリダイレクトURLにあるcodeの値を取得し、コンソールの標準入力に入力しろってことです。

指示通りブラウザにURLを入力し、認証するまでは良いのですが、不便なのはこの後です。

認証後の画面は、以下のような「localhostに到達できません」といったエラーメッセージの画面がでます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33447/53a01b49-190d-e97c-1232-0a6147930567.png)

この画面をみると認証が失敗したのかなと思いますが、そうではありません。

よく見るとブラウザのURL欄には以下のようなURLが記載されているはずです。

```
http://localhost/?state=state-token&code=XXX&scope=https://www.googleapis.com/auth/calendar.readonly
```

実は、このURLのパラメータの`code=XXX`の`XXX`の部分をコピーし、標準入力待ちしているコンソールに入力しろという指示でした。

localhostに到達できないというエラーが出ていたのはURLのサーバがlocalhostを指定しているためでした。

codeを標準入力に入れることでプログラムは先に進み、トークンが保存された旨が表示され、所望のカレンダーの予定が表示されます。

```
Saving credential file to: token.json
Upcoming events:
出社 (2023-12-20T09:00:00+09:00)
会議 (2023-12-20T10:00:00+09:00)
...
```

このように、かなりわかりにくく不便なコードになっています。

## より便利なサンプルコード

ではどうすべきかというと、以下のコードを実行して下さい

```golang
package main

import (
	"context"
	"encoding/gob"
	"fmt"
	"hash/fnv"
	"log"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/calendar/v3"
	"google.golang.org/api/option"
)

func osUserCacheDir() string {
	switch runtime.GOOS {
	case "darwin":
		return filepath.Join(os.Getenv("HOME"), "Library", "Caches")
	case "linux", "freebsd":
		return filepath.Join(os.Getenv("HOME"), ".cache")
	}
	log.Printf("TODO: osUserCacheDir on GOOS %q", runtime.GOOS)
	return "."
}

func tokenCacheFile(config *oauth2.Config) string {
	hash := fnv.New32a()
	hash.Write([]byte(config.ClientID))
	hash.Write([]byte(config.ClientSecret))
	hash.Write([]byte(strings.Join(config.Scopes, " ")))
	fn := fmt.Sprintf("go-api-demo-tok%v", hash.Sum32())
	return filepath.Join(osUserCacheDir(), url.QueryEscape(fn))
}

func tokenFromFile(file string) (*oauth2.Token, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	t := new(oauth2.Token)
	err = gob.NewDecoder(f).Decode(t)
	return t, err
}

func saveToken(file string, token *oauth2.Token) {
	f, err := os.Create(file)
	if err != nil {
		log.Printf("Warning: failed to cache oauth token: %v", err)
		return
	}
	defer f.Close()
	gob.NewEncoder(f).Encode(token)
}

func newOAuthClient(ctx context.Context, config *oauth2.Config) *http.Client {
	cacheFile := tokenCacheFile(config)
	token, err := tokenFromFile(cacheFile)
	if err != nil {
		token = tokenFromWeb(ctx, config)
		saveToken(cacheFile, token)
	} else {
		log.Printf("Using cached token %#v from %q", token, cacheFile)
	}

	return config.Client(ctx, token)
}

func tokenFromWeb(ctx context.Context, config *oauth2.Config) *oauth2.Token {
	ch := make(chan string)
	randState := fmt.Sprintf("st%d", time.Now().UnixNano())
	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
		if req.URL.Path == "/favicon.ico" {
			http.Error(rw, "", 404)
			return
		}
		if req.FormValue("state") != randState {
			log.Printf("State doesn't match: req = %#v", req)
			http.Error(rw, "", 500)
			return
		}
		if code := req.FormValue("code"); code != "" {
			fmt.Fprintf(rw, "<h1>Success</h1>Authorized.")
			rw.(http.Flusher).Flush()
			ch <- code
			return
		}
		log.Printf("no code")
		http.Error(rw, "", 500)
	}))
	defer ts.Close()

	config.RedirectURL = ts.URL
	authURL := config.AuthCodeURL(randState)
	go openURL(authURL)
	log.Printf("Authorize this app at: %s", authURL)
	code := <-ch
	log.Printf("Got code: %s", code)

	token, err := config.Exchange(ctx, code)
	if err != nil {
		log.Fatalf("Token exchange error: %v", err)
	}
	return token
}

func openURL(url string) {
	try := []string{"xdg-open", "google-chrome", "open"}
	for _, bin := range try {
		err := exec.Command(bin, url).Run()
		if err == nil {
			return
		}
	}
	log.Printf("Error opening URL in browser.")
}

func valueOrFileContents(value string, filename string) string {
	if value != "" {
		return value
	}
	slurp, err := os.ReadFile(filename)
	if err != nil {
		log.Fatalf("Error reading %q: %v", filename, err)
	}
	return strings.TrimSpace(string(slurp))
}

func main() {
	ctx := context.Background()
	b, err := os.ReadFile("credentials.json")
	if err != nil {
		log.Fatalf("Unable to read client secret file: %v", err)
	}

	// If modifying these scopes, delete your previously saved token.json.
	config, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope)
	if err != nil {
		log.Fatalf("Unable to parse client secret file to config: %v", err)
	}
	c := newOAuthClient(ctx, config)

	srv, err := calendar.NewService(ctx, option.WithHTTPClient(c))
	if err != nil {
		log.Fatalf("Unable to retrieve Calendar client: %v", err)
	}

	t := time.Now().Format(time.RFC3339)
	events, err := srv.Events.List("primary").ShowDeleted(false).
		SingleEvents(true).TimeMin(t).MaxResults(10).OrderBy("startTime").Do()
	if err != nil {
		log.Fatalf("Unable to retrieve next ten of the user's events: %v", err)
	}
	fmt.Println("Upcoming events:")
	if len(events.Items) == 0 {
		fmt.Println("No upcoming events found.")
	} else {
		for _, item := range events.Items {
			date := item.Start.DateTime
			if date == "" {
				date = item.Start.Date
			}
			fmt.Printf("%v (%v)\n", item.Summary, date)
		}
	}
}
```

このコードは、 google-api-go-client のレポジトリにある[サンプルコード](https://github.com/googleapis/google-api-go-client/blob/main/examples/main.go)をもとに作りました。

このコードを実行すると、ブラウザで認証した後に以下のように「Success Authorized」と表示される画面に遷移し、先程のコードで発生した標準入力にcodeを入力する手間が不要ことがわかります！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33447/d6c15a1c-2a64-4bba-2221-27831b2e7f5b.png)

仕組みとしては、localhostに一時的にWebサーバを立てて、リダイレクトURLを処理している作りになっています。

このコードを使って、便利なGoogleカレンダーAPIライフを送ってください！