📆

LaravelでGoogle Calendarを使った適当なアプリを作るまで

2021/02/15に公開
3

こんにちは皆さん。

なぜ突然こんな記事を書いたかというのは、まあ、いろいろあるわけです。
いや、正直アプリ内の日程管理程度であれば、自力で作っちゃってもいいんですが、自分たちのアプリだけ使っている人なんて、むしろ少ないわけで、多くの人は別の大手のスケジュール管理ツール使っているわけですよ。
メジャーなところであれば、やっぱりGoogleカレンダーですよね。

というわけで、LaravelでGoogleカレンダーとアプリを連携させるツールを作ってみましょう。

記事を見るより結果を見たい!

リポジトリに放り込んでおきました。
https://github.com/niisan-tokyo/ex-oauth-laravel-calendar/tree/v1.0.0

OAuthを使って権限を得るまで

カレンダーとの連携をするには、プロダクト側でカレンダーをいじってもいいよっていう権限を、ユーザーから移譲してもらう必要があります。
この辺はOAuthを使えばらくちんです。

laravel/socialite

LaravelでOAuthでの認可や認証を扱うのなら、Socialite使うのが簡単です。
まず、パッケージのゲット。

composer require laravel/socialite

次に、アカウントの設定をします。
https://console.developers.google.com/apis/credentials

GCPのプロジェクトができていないと、まずはプロジェクトを作らされると思いますが、そっちは割愛。
OAuth2のユーザを作成すると、クライアントIDとシークレットがわかるので、それを環境変数に入れます。

GOOGLE_CLIENT_ID=**********************
GOOGLE_CLIENT_SECRET=********************

さらにconfigにIDとシークレットを入れる箱を作ります。

config/services.php

    'google' => [
        'client_id' => env('GOOGLE_CLIENT_ID'),
        'client_secret' => env('GOOGLE_CLIENT_SECRET'),
        'redirect' => 'http://localhost:8000/auth/callback',
    ],

これでオッケー。

OAuthするページを作る

適当なルーティングを作ります。

routes/web.php

// ログイン用のボタンを置く
Route::get('/', function () {
    return view('google_login');
});

// oauth認証するためのURLにリダイレクトする
Route::get('/auth/redirect', function () {
    return Socialite::driver('google')
        ->scopes(['https://www.googleapis.com/auth/calendar.events'])
        ->with(['access_type' => 'offline'])
        ->redirect();
});

// oauthで飛んできたコードを使ってユーザを認証している
Route::get('/auth/callback', function () {
    $social_user = Socialite::driver('google')->user();
    $google_user = GoogleUser::whereGoogleId($social_user->id)->first();
    $user = ($google_user) ? $google_user->user: new User;
    if (!$google_user) {
        $user->name = $social_user->name;
        $user->email = $social_user->email;
        $user->password = bcrypt(Str::random(20));
        $user->save();

        $google_user = new GoogleUser;
        $google_user->google_id = $social_user->id;
    }
    
    // アクセストークンとリフレッシュトークンをセットしている
    $google_user->access_token = $social_user->token;
    $google_user->refresh_token = $social_user->refreshToken ?? $google_user->refreshToken;
    $google_user->expires = Carbon::now()->timestamp + $social_user->expiresIn;

    $user->googleUser()->save($google_user);
    Auth::login($user);
    return redirect('/todo');
});

適当なviewも用意します

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
</head>
<body>
    <a href="/auth/redirect"><button>Googleでログイン</button></a>
</body>
</html>

左上に「Googleでログイン」というちっこいボタンが出るだけの怖めのページです。

ルーティングのほうを、ちょっと解説しておきます。
auth/redirect->scopes(['https://www.googleapis.com/auth/calendar.events'])としているのは、カレンダーのイベントを読み書きする権限をもらうためです。また、->with(['access_type' => 'offline'])でrefresh_tokenをもらおうとしています。

まあ、とりあえず、ボタンをポチすれば、カレンダー予定をいじる権限をもらった状態でログインできます。
ちなみに、ここでは面倒くさいのでログインまでさせましたが、ここで本当にやりたいのはカレンダーの権限をもらうところまでなので、すでに別の方法でログインしてしまっていても、アクセストークンとリフレッシュトークンさえゲットできれば問題ないです。

カレンダーの予定を読み書きする

サービスの作成

SocialiteはOAuthの認証をしたり、トークンを得るところまではできますが、逆に言えばここまでです。次はカレンダーを取得するサービスを作る必要があります。
まず、google用のクライアントを持ってきましょう。

composer require google/apiclient

これだけで、カレンダー用のパッケージも全部落ちてきます。
これを素っ裸で使ってもいいのですが、割と面倒なので、サービスクラスを一個作って、そいつに処理を入れます。

app/Services/CalendarService.php

<?php
namespace App\Services;

use Google_Client;

class CalendarService
{
    private Google_Client $client;

    public function __construct(Google_Client $client)
    {
        $this->client = $client;
    }
}

Google_Clientはできれば入れ替えできるようにしておきたいので、AppServiceProviderで、

    public function register()
    {
        $this->app->singleton(Google_Client::class, function () {
            return new Google_Client([
                'client_id' => config('services.google.client_id'),
                'client_secret' => config('services.google.client_secret')
            ]);
        });
    }

まあ、テスト書いてないですけどね!

イベントのリストを取得する

前後1週間分のイベントのリストを取得する処理を書きます。先ほどのCalendarServiceクラスに処理を書きます。


    /**
     * イベントのリストの取得
     * 
     * @param GoogleUser $user
     * 
     * @return array
     */
    public function getEventList(GoogleUser $user)
    {
        $this->setAccessToken($user);
        $service = new Google_Service_Calendar($this->client);
        $events = $service->events->listEvents('primary', [
            'timeMin' => Carbon::now()->subDays(7)->format(DATE_RFC3339),
            'timeMax' => Carbon::now()->addDays(7)->format(DATE_RFC3339)
        ]);
        $ret = [];

        while(true) {
            foreach ($events->getItems() as $event) {
                if ($event->start and $event->end) {
                    $ret[] = [
                        'id' => $event->id,
                        'summary' => $event->getSummary(),
                        'start' => $event->start->dateTime,
                        'end' => $event->end->dateTime
                    ];
                }
            }
            $pageToken = $events->getNextPageToken();
            if ($pageToken) {
                $optParams = array('pageToken' => $pageToken);
                $events = $service->events->listEvents('primary', $optParams);
            } else {
                break;
            }
        }
        return $ret;
    }
    
    /**
     * アクセストークンのセット
     * 
     * @param GoogleUser $user
     * 
     * @return void
     */
    private function setAccessToken(GoogleUser $user): void
    {
        if (Carbon::now()->timestamp >= $user->expires - 30) {
            $token = $this->client->fetchAccessTokenWithRefreshToken($user->refresh_token);
            $user->access_token = $token['access_token'];
            $user->expires = Carbon::now()->timestamp + $token['expires_in'];
            $user->save();
        }

        $this->client->setAccessToken($user->access_token);
    }

リスト取得がやや面倒ですね。
ロジックはこんな感じです。

  1. イベントのリストを取得する
  2. 順番に返却値の配列に値を詰めていく
  3. イベントの数が多く、全員ベントをとってきていない場合は次のページのイベントのリストをとりに行く
  4. 範囲内のイベントをとり終わったらループ終わり
  5. 配列を返す

whileループは何ページあるかわからないので、次のページがなくなるまで取ってくるために使っています。ただし、範囲さえ指定していれば、そこまでエキセントリックな量の予定が取得されることはないでしょう。

一方、setAccessTokenは少しトリッキーな動きをしています。というのも、トークンの寿命が来ていたら、refresh_tokenを使って新しいトークンに入れ替える動作が入っているからです。
後は適当にコントローラ作って、ビューで表示します。

app/Http/Controllers/TodoController.php

    public function index()
    {
        $user = Auth::user();
        return view('todo.index', [
            'events' => $this->service->getEventList($user->googleUser)
        ]);
    }

oauth/resources/views/todo/index.blade.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
</head>
<body>
    <a href="/todo/create"><button>TODO作成</button></a>
    <ul>
        @foreach ($events as $event)
            <li>
                {{ $event['start'] }} ~ {{ $event['end'] }}: {{ $event['summary'] }}
            </li>
        @endforeach
    </ul>
</body>
</html>

これで、日時とタイトルのイベントのリストが表示されます。

イベントを書き込む

何かのTODOを作ってカレンダーに書き込みたいと考えるわけですが、さすがにアプリの作った予定と普通の予定を区別できるようにはしておきたい。というわけで、適当なテーブル作っておきます。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTodosTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('todos', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')
                ->constrained('users')
                ->onUpdate('cascade')
                ->onDelete('cascade');
            $table->string('event_id', 1024);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('todos');
    }
}

これ自体はほとんどデータを持ってません。
カレンダーのイベントと紐づけておき、イベントがアプリ側の操作によって作られたかどうかをわかるようにしています。
まず、サービスクラスでカレンダーへの書き込み処理を書いておきます。

    /**
     * イベントの作成
     * 基本的にタイトル、開始時間、終了時間を入れる
     *
     * @param array $data
     * @param GoogleUser $user
     * @return string
     */
    public function createEvent(array $data, GoogleUser $user): string
    {
        $event = new Google_Service_Calendar_Event([
            'summary' => $data['summary'],
            'start' => [
                'dateTime' => Carbon::parse($data['start'])->format(DATE_RFC3339)
            ],
            'end' => [
                'dateTime' => Carbon::parse($data['end'])->format(DATE_RFC3339)
            ]
        ]);
        $this->setAccessToken($user);
        $service = new Google_Service_Calendar($this->client);
        $new_event = $service->events->insert('primary', $event);
        return $new_event->id;
    }

注意点は、イベント作成時の時間指定はRFC3339に準拠しているということで、これ以外のフォーマットで時間を入れるとエラーになります。これはなぜかメッセージで教えてくれません。

コントローラ側でサービスを使ってカレンダー登録したら、TODOも作っちゃいます。

   /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $user = Auth::user();
        $event_id = $this->service->createEvent($request->all(), $user->googleUser);
        $todo = new Todo(['event_id' => $event_id]);
        $todo->user()->associate(Auth::user());
        $todo->save();
        return redirect('/todo');

    }

いつもの素のHTMLで適当フォームを作っておきます。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
</head>
<body>
    <h1>TODO作成</h1>
    <form action="/todo" method="POST">
        @csrf
        タイトル<input name="summary" type="text" /> <br>
        開始<input name="start" type="text" /> <br>
        終了<input name="end" type="text" /> <br>
        <button type="submit">作成する</button>
    </form>
</body>
</html>

このフォームに各値を入力して作成するボタンを押せばオッケーです。

削除する

カレンダー何でもかんでも消したくはないですが、アプリで作ったTODOを消したら、ちゃんとカレンダーからも消えてほしいもの。
というわけで、削除の機構も作ります。
権限さえもらっていれば、あっさり消せます。

サービスクラスを見てみます。

    /**
     * カレンダーから予定を削除する
     *
     * @param string $event_id
     * @param GoogleUser $user
     * @return mixed
     */
    public function deleteEvent($event_id, GoogleUser $user)
    {
        $this->setAccessToken($user);
        $service = new Google_Service_Calendar($this->client);
        return $service->events->delete('primary', $event_id);
    }

イベントIDはTODO作成時にDBに入れてありますので、それを使えばよいでしょう。

コントローラ側ではTODOのレコードを消します。

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Models\Todo  $todo
     * @return \Illuminate\Http\Response
     */
    public function destroy(Todo $todo)
    {
        $user = Auth::user();
        $this->service->deleteEvent($todo->event_id, $user->googleUser);
        $todo->delete();
        return redirect('/todo');
    }

こんな感じ。
後は、まあ、リポジトリを見てくだされば。

まとめ

というわけで、アプリケーションとGoogleカレンダーとの連携機構を適当に作ってみました。
即興で作ってみた感想として、アプリケーションの中にGoogleカレンダーの文脈ががっつり入っていて、気持ち悪いなって感じですかね。
これを、分離させたいなっていう欲望が、湧き上がってきます。
この辺の欲望は次回やりますんで、今回はこんなところです。

参考

カレンダーのリファレンス
Socialite

Discussion

まーぼうまーぼう

これってGoogleカレンダー側の「特定のユーザーとの共有」を設定したり、サービスアカウントの「Google Workspace ドメイン全体の委任を有効」を行わなくてもカレンダー情報取得できましたか?

niisanniisan

はい、できます。
できるんですが、一般公開するにはGoogleさんの審査を受ける必要があるので、その点注意です。
チームや自分のアカウントであれば審査受ける前でもテストできます。

ちなみに、審査内容は、「英語化した状態で、本番URLで、該当のOAuthクライアントIDを使用していることを明示しながら、使用すると宣言した権限を過不足なく使っているシーンを動画で撮影し、youtubeに投稿し、その動画URLを提出する」というものです。
めっちゃ面倒です

まーぼうまーぼう

お返事ありがとうございます。

はい、できます。
できるんですが、一般公開するにはGoogleさんの審査を受ける必要があるので、その点注意です。
チームや自分のアカウントであれば審査受ける前でもテストできます。

この言葉を信じて、自分でも取得するところまで確認できました。

めっちゃ面倒です

こんなに手順があるんですね。。。先に聞けて良かったです。