はじめに
go言語でGoogleカレンダーを操作する方法について、2023年12月時点の公式のクイックスタートのサンプルコードが不便だったため、本ブログではより便利なサンプルコードを紹介します。
(この投稿はGO Inc. Advent Calendar 2023の一部です)
クイックスタートのサンプルコードは何が面倒か
GoogleカレンダーをGO言語で操作しようとしてインターネットを検索すると、一番最初にでてくるのは公式のクイックスタートのサンプルコードだとおもいます。
ですが、このコード、初回実行時に認証コードを切り取って標準入力に渡す部分がとても面倒です。
具体的には、手順どおりに作業して、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に到達できません」といったエラーメッセージの画面がでます。
この画面をみると認証が失敗したのかなと思いますが、そうではありません。
よく見るとブラウザの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)
...
このように、かなりわかりにくく不便なコードになっています。
より便利なサンプルコード
ではどうすべきかというと、以下のコードを実行して下さい
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 のレポジトリにあるサンプルコードをもとに作りました。
このコードを実行すると、ブラウザで認証した後に以下のように「Success Authorized」と表示される画面に遷移し、先程のコードで発生した標準入力にcodeを入力する手間が不要ことがわかります!!
仕組みとしては、localhostに一時的にWebサーバを立てて、リダイレクトURLを処理している作りになっています。
このコードを使って、便利なGoogleカレンダーAPIライフを送ってください!