0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoogleCalendar APIを学ぶ(Step4:カレンダーに紐づくイベントの一覧取得APIを実装する)

Posted at

はじめに

前回までの続きです。今回はカレンダーに紐づくイベントの一覧を実装していきます!!!

実装箇所

カレンダーに紐づくイベントの一覧取得です。

image.png

GoogleCalendarAPIの仕様

image.png
→赤枠の部分を実装します。では次にレスポンスを見ていきましょう!

{
  "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にそんなイベントの詳細を見るページってあったけ??って思ったので、調べてみました。

image.png
イベントをクリックしてみると、確かにURLらしきものが。

image.png
使った事はないんですが、こんなのがあるんですね。イベントってあまり詳細ページが必要なきはしないですね。

ソースコード

ディレクトリ構成
~/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"
    }
  ]
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?