はじめに
前回までの続きです。今回はカレンダーに紐づくイベントの一覧を実装していきます!!!
実装箇所
カレンダーに紐づくイベントの一覧取得です。
GoogleCalendarAPIの仕様
→赤枠の部分を実装します。では次にレスポンスを見ていきましょう!
{
"kind": "calendar#events", // イベントリストを表すリソース種別
"etag": "\"p32kl9ah4f0gk20g\"", // レスポンス全体のバージョン識別用タグ
"summary": "Alice's Work Calendar", // カレンダー名
"description": "会議・締切・打ち合わせなどを管理", // カレンダーの説明
"updated": "2025-05-17T08:00:00.000Z", // 最終更新日時(ISO 8601形式、UTC)
"timeZone": "Asia/Tokyo", // カレンダーのタイムゾーン
"accessRole": "owner", // このカレンダーに対するアクセス権(owner, readerなど)
"defaultReminders": [ // デフォルトのリマインダー設定(全イベント共通)
{
"method": "popup", // リマインダーの通知方法(popup, emailなど)
"minutes": 10 // 開始時間の何分前に通知するか
}
],
"nextPageToken": "cGFnZVRva2VuMTIz", // 次ページの取得に使うトークン(ページネーション用)
"nextSyncToken": "CPzj5I3h5fkCEpzj5I3h5fkCGAU=", // 差分取得のための同期トークン
"items": [ // 実際のイベント一覧(配列)
{
"kind": "calendar#event", // イベントリソースであることを示す種別
"etag": "\"334455aa\"", // イベントのバージョン識別子
"id": "evt-001", // イベントの一意なID
"status": "confirmed", // ステータス(confirmed, cancelledなど)
"htmlLink": "https://www.google.com/calendar/event?eid=ZXZ0LTAwMQ", // Web UI上でイベントを開くリンク
"summary": "朝会(Daily Stand-up)", // イベントのタイトル
"description": "開発チームの毎朝の短い打ち合わせ", // イベントの詳細説明
"start": {
"dateTime": "2025-05-17T09:00:00+09:00" // 開始日時(タイムゾーン付き)
},
"end": {
"dateTime": "2025-05-17T09:15:00+09:00" // 終了日時(タイムゾーン付き)
},
"location": "会議室A", // イベントの場所
"updated": "2025-05-16T23:00:00.000Z", // このイベントの最終更新日時(UTC)
"creator": {
"email": "alice@example.com", // 作成者のメールアドレス
"displayName": "Alice" // 作成者の表示名
},
"organizer": {
"email": "alice@example.com", // 主催者のメールアドレス
"displayName": "Alice" // 主催者の表示名
}
},
{
"kind": "calendar#event",
"etag": "\"334455bb\"",
"id": "evt-002",
"status": "confirmed",
"summary": "プロジェクト締切", // イベント名(終日イベント)
"start": {
"date": "2025-05-17" // 開始日(終日イベントの場合は date)
},
"end": {
"date": "2025-05-18" // 終了日(Google Calendarでは排他的なので翌日)
},
"description": "〇〇プロジェクトの納品期限です。",
"updated": "2025-05-17T00:00:00.000Z",
"creator": {
"email": "bob@example.com",
"displayName": "Bob"
},
"organizer": {
"email": "project-team@example.com",
"displayName": "プロジェクトチーム"
},
"transparency": "transparent" // 空き時間として扱う(デフォルトでは busy)
}
]
}
→ちょっと重厚すぎるので、今回必要な分だけに削ぎ落としていきます。
{
"id":1,
"summary": "Alice's Work Calendar", // カレンダー名
"description": "会議・締切・打ち合わせなどを管理", // カレンダーの説明
"accessRole": "owner", // このカレンダーに対するアクセス権(owner, readerなど)
"items": [ // 実際のイベント一覧(配列)
{
"id": 1, // イベントの一意なID
"status": "confirmed", // ステータス(confirmed, cancelledなど)
"summary": "朝会(Daily Stand-up)", // イベントのタイトル
"description": "開発チームの毎朝の短い打ち合わせ", // イベントの詳細説明
"start": {
"dateTime": "2025-05-17T09:00:00+09:00" // 開始日時(タイムゾーン付き)
},
"end": {
"dateTime": "2025-05-17T09:15:00+09:00" // 終了日時(タイムゾーン付き)
},
"location": "会議室A", // イベントの場所
"creator": {
"email": "alice@example.com", // 作成者のメールアドレス
"displayName": "Alice" // 作成者の表示名
},
"organizer": {
"email": "alice@example.com", // 主催者のメールアドレス
"displayName": "Alice" // 主催者の表示名
}
"transparency": "busy" // 空き時間として扱う(デフォルトでは busy)
},
{
"id": 2,
"status": "confirmed",
"summary": "プロジェクト締切", // イベント名(終日イベント)
"start": {
"date": "2025-05-17" // 開始日(終日イベントの場合は date)
},
"end": {
"date": "2025-05-18" // 終了日(Google Calendarでは排他的なので翌日)
},
"description": "〇〇プロジェクトの納品期限です。",
"creator": {
"email": "bob@example.com",
"displayName": "Bob"
},
"organizer": {
"email": "project-team@example.com",
"displayName": "プロジェクトチーム"
},
"transparency": "transparent" // 空き時間として扱う(デフォルトでは busy)
}
]
}
イベントのURLとは何か?
カレンダーのイベントのURL。一般的に機能として提供されてそうだけど、GoogleCalendarにそんなイベントの詳細を見るページってあったけ??って思ったので、調べてみました。ソースコード
ディレクトリ構成
~/develop/google_calendar_sample (feat/even_get)$ tree -I pgdata/
.
├── docker-compose.yml
├── Dockerfile
├── docs
├── go.mod
├── go.sum
├── init
│ ├── 01_schema.sql
│ ├── 02_seed.sql
│ └── 03_alter_table.sql
├── internal
│ ├── controller
│ │ └── calendars
│ │ ├── calendar.go
│ │ ├── controller_dto
│ │ │ └── calendar.go
│ │ └── events
│ │ ├── controller_dto
│ │ │ └── event.go
│ │ └── event.go
│ ├── model
│ │ ├── calendar.go
│ │ ├── event.go
│ │ └── user_settings.go
│ ├── repository
│ │ ├── calendar_repository.go
│ │ ├── event.go
│ │ └── user_settings_repository.go
│ └── service
│ └── calendar
│ ├── calendar.go
│ └── service_dto
│ ├── calendar.go
│ └── event.go
├── main.go
├── Makefile
└── tmp
├── build-errors.log
└── main
15 directories, 24 files
main.go
package main
import (
"database/sql"
"fmt"
"log"
"os"
controller "calendar/internal/controller/calendars"
"calendar/internal/controller/calendars/events"
"calendar/internal/repository"
service "calendar/internal/service/calendar"
"github.com/labstack/echo/v4"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
_ "github.com/uptrace/bun/driver/pgdriver"
"github.com/uptrace/bun/extra/bundebug"
)
func main() {
// Initialize Echo
e := echo.New()
// Database connection
dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"),
)
sqldb, err := sql.Open("pg", dsn)
if err != nil {
log.Fatal(err)
}
// PostgreSQL接続用 bun.DB を作成
db := bun.NewDB(sqldb, pgdialect.New())
// クエリログを出力
db.AddQueryHook(bundebug.NewQueryHook(
bundebug.WithVerbose(true),
bundebug.FromEnv("BUNDEBUG"),
))
// Test database connection
if err := db.Ping(); err != nil {
log.Fatal(err)
}
// Initialize repositories
calendarRepo := repository.NewCalendarRepository(db)
settingsRepo := repository.NewUserSettingsRepository(db)
eventRepo := repository.NewEventRepository(db)
// Initialize service
calendarService := service.NewCalendarService(calendarRepo, settingsRepo, eventRepo)
// Initialize controller
calendarController := controller.NewCalendarController(calendarService)
eventController := events.NewEventController(calendarService)
// Register routes
calendarController.RegisterRoutes(e)
eventController.RegisterRoutes(e)
// Start server
e.Logger.Fatal(e.Start(":8081"))
}
internal/controller/calendars/events/event.go
package events
import (
"calendar/internal/controller/calendars/events/controller_dto"
service "calendar/internal/service/calendar"
"net/http"
"github.com/labstack/echo/v4"
)
type EventController struct {
service service.CalendarService
}
func NewEventController(service service.CalendarService) *EventController {
return &EventController{service: service}
}
func (c *EventController) RegisterRoutes(e *echo.Echo) {
e.GET("/calendars/:calendarId/events", c.GetEvents)
}
// GetEvents は指定されたカレンダーのイベント一覧を返却します
func (c *EventController) GetEvents(ctx echo.Context) error {
req := new(controller_dto.ListEventsRequest)
if err := ctx.Bind(req); err != nil {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request parameters"})
}
res, err := c.service.ListEvents(ctx.Request().Context(), req.ToServiceDTO())
if err != nil {
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return ctx.JSON(http.StatusOK, res)
}
internal/controller/calendars/events/controller_dto/event.go
package controller_dto
import (
"calendar/internal/service/calendar/service_dto"
"time"
)
type ListEventsRequest struct {
CalendarID int64 `param:"calendarId" validate:"required"`
StartDate time.Time `query:"start_date" validate:"omitempty" format:"2006-01-02T15:04:05Z07:00"`
EndDate time.Time `query:"end_date" validate:"omitempty" format:"2006-01-02T15:04:05Z07:00"`
}
func (r *ListEventsRequest) ToServiceDTO() service_dto.ListEventsRequest {
return service_dto.ListEventsRequest{
CalendarID: r.CalendarID,
}
}
internal/service/calendar/event.go
package calendar
import (
"context"
"calendar/internal/service/calendar/service_dto"
)
func (s *calendarService) ListEvents(ctx context.Context, req service_dto.ListEventsRequest) (service_dto.ListEventsResponse, error) {
// カレンダー情報の取得
calendar, err := s.calendarRepo.Get(ctx, req.CalendarID)
if err != nil {
return service_dto.ListEventsResponse{}, err
}
// イベントの取得
events, err := s.eventRepo.List(ctx, req.CalendarID)
if err != nil {
return service_dto.ListEventsResponse{}, err
}
// レスポンスの変換
return service_dto.ConvertToEventsResponse(calendar, events), nil
}
internal/service/calendar/service_dto/event.go
package service_dto
import (
"calendar/internal/model"
"time"
)
type ListEventsRequest struct {
UserID int64
CalendarID int64
}
type ListEventsResponse struct {
CalendarID int64 `json:"calendar_id"`
Summary string `json:"summary"`
Description string `json:"description"`
AccessRole string `json:"accessRole"`
Items []EventResponse `json:"items"`
}
type EventResponse struct {
ID int64 `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start TimeInfo `json:"start"`
End TimeInfo `json:"end"`
Location string `json:"location"`
Creator Person `json:"creator"`
Organizer Person `json:"organizer"`
Transparency string `json:"transparency"`
}
type TimeInfo struct {
DateTime string `json:"dateTime,omitempty"`
Date string `json:"date,omitempty"`
}
type Person struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
// ConvertToEventsResponse converts a calendar and its events to ListEventsResponse
func ConvertToEventsResponse(calendar *model.Calendar, events []*model.CalendarEvent) ListEventsResponse {
items := make([]EventResponse, len(events))
for i, event := range events {
// デフォルト値を設定
status := "confirmed"
if event.Status != "" {
status = event.Status
}
transparency := "opaque"
if event.Transparency != "" {
transparency = event.Transparency
}
// オーガナイザー情報が空の場合のデフォルト値
organizerEmail := "system@example.com"
organizerName := "System"
if event.OrganizerEmail != "" {
organizerEmail = event.OrganizerEmail
organizerName = event.OrganizerName
}
items[i] = EventResponse{
ID: event.EventID,
Status: status,
Summary: event.EventSummary,
Description: event.EventDesc,
Start: TimeInfo{
DateTime: event.StartTime.Format(time.RFC3339),
},
End: TimeInfo{
DateTime: event.EndTime.Format(time.RFC3339),
},
Location: event.Location,
Creator: Person{
Email: organizerEmail,
DisplayName: organizerName,
},
Organizer: Person{
Email: organizerEmail,
DisplayName: organizerName,
},
Transparency: transparency,
}
}
// カレンダーのアクセス権限が空の場合のデフォルト値
accessRole := "reader"
if calendar.AccessRole != "" {
accessRole = calendar.AccessRole
}
return ListEventsResponse{
CalendarID: calendar.ID,
Summary: calendar.Summary,
Description: calendar.Description,
AccessRole: accessRole,
Items: items,
}
}
internal/repository/event.go
package repository
import (
"context"
"fmt"
"calendar/internal/model"
"github.com/uptrace/bun"
)
type EventRepository interface {
List(ctx context.Context, calendarID int64) ([]*model.CalendarEvent, error)
}
type eventRepository struct {
db *bun.DB
}
func NewEventRepository(db *bun.DB) EventRepository {
return &eventRepository{db: db}
}
func (r *eventRepository) List(ctx context.Context, calendarID int64) ([]*model.CalendarEvent, error) {
var events []*model.CalendarEvent
accessRoleCase := fmt.Sprintf("CASE WHEN c.owner_id = %d THEN 'owner' ELSE 'reader' END AS access_role", calendarID)
err := r.db.NewSelect().
TableExpr("calendars AS c").
ColumnExpr("c.id AS calendar_id").
ColumnExpr("c.summary").
ColumnExpr("c.description").
ColumnExpr(accessRoleCase).
ColumnExpr("e.id AS event_id").
ColumnExpr("e.status").
ColumnExpr("e.title AS summary").
ColumnExpr("e.description").
ColumnExpr("e.location").
ColumnExpr("lower(e.time_range) AS start_time").
ColumnExpr("upper(e.time_range) AS end_time").
ColumnExpr("u.email AS organizer_email").
ColumnExpr("u.display_name AS organizer_name").
ColumnExpr("e.transparency").
Join("LEFT JOIN events e ON e.calendar_id = c.id").
Join("LEFT JOIN users u ON u.id = e.organizer_id").
Where("c.id = ?", calendarID).
Order("start_time ASC").
Scan(ctx, &events)
return events, err
}
ColumnExpr("lower(e.time_range) AS start_time").
ColumnExpr("upper(e.time_range) AS end_time").
→ここ重要ですね。["2025-05-05 01:00:00+00","2025-05-05 01:30:00+00")
こんなデータをどうやって取り扱うのかなぁと思ったんですが、こんな風にすればいいんですね!
internal/model/event.go
package model
import "time"
type Event struct {
ID int64 `bun:"id,pk"`
CalendarID int64 `bun:"calendar_id"`
TimeRange string `bun:"time_range"`
Title string `bun:"title"`
}
type CalendarEvent struct {
CalendarID int64 `bun:"calendar_id" json:"calendar_id"`
Summary string `bun:"summary" json:"summary"`
Description string `bun:"description" json:"description"`
AccessRole string `bun:"access_role" json:"access_role"`
EventID int64 `bun:"event_id" json:"event_id"`
Status string `bun:"status" json:"status"`
EventSummary string `bun:"summary" json:"event_summary"`
EventDesc string `bun:"description" json:"event_description"`
Location string `bun:"location" json:"location"`
StartTime time.Time `bun:"start_time" json:"start_time"`
EndTime time.Time `bun:"end_time" json:"end_time"`
OrganizerEmail string `bun:"organizer_email" json:"organizer_email"`
OrganizerName string `bun:"organizer_name" json:"organizer_name"`
Transparency string `bun:"transparency" json:"transparency"`
}
type Person struct {
Email string
DisplayName string
}
APIの実行結果
{
"calendar_id": 1,
"summary": "Alice Work",
"description": "Work calendar for Alice",
"accessRole": "reader",
"items": [
{
"id": 4,
"status": "tentative",
"summary": "Weekly Check-In",
"description": "check in",
"start": {
"dateTime": "2025-05-05T01:00:00Z"
},
"end": {
"dateTime": "2025-05-05T01:30:00Z"
},
"location": "Zoom",
"creator": {
"email": "alice@example.com",
"displayName": "Alice"
},
"organizer": {
"email": "alice@example.com",
"displayName": "Alice"
},
"transparency": "opaque"
},
{
"id": 1,
"status": "confirmed",
"summary": "Project Kickoff",
"description": "Initial project meeting",
"start": {
"dateTime": "2025-05-17T05:57:09Z"
},
"end": {
"dateTime": "2025-05-17T06:57:09Z"
},
"location": "Zoom",
"creator": {
"email": "alice@example.com",
"displayName": "Alice"
},
"organizer": {
"email": "alice@example.com",
"displayName": "Alice"
},
"transparency": "opaque"
},
{
"id": 3,
"status": "confirmed",
"summary": "Team Sync",
"description": "Weekly sync meeting",
"start": {
"dateTime": "2025-05-20T01:00:00Z"
},
"end": {
"dateTime": "2025-05-20T02:00:00Z"
},
"location": "Google Meet",
"creator": {
"email": "alice@example.com",
"displayName": "Alice"
},
"organizer": {
"email": "alice@example.com",
"displayName": "Alice"
},
"transparency": "opaque"
}
]
}