Files
MealQuest/internal/gcal/events.go
T

218 lines
7.5 KiB
Go

package gcal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
)
// maxEventBody caps how much of an events response is read.
const maxEventBody = 64 << 10
var (
// ErrNoCalendar means Google Calendar is connected but no calendar is
// chosen to push to.
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
// ErrCalendarUnavailable means the chosen calendar was deleted or the
// connected account can no longer add events to it. Such a calendar
// cannot be chosen again; the household must choose another.
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
)
// Google's rule for a client-chosen event id: base32hex characters
// (lowercase a-v and 0-9), 5 to 1024 of them.
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
const (
minEventID = 5
maxEventID = 1024
)
// Event is one event to write to the chosen calendar.
type Event struct {
// ID is the event's id, chosen by the caller so that writing the same
// event again updates it; it must satisfy Google's id rule.
ID string
Summary string
Description string
Start, End time.Time
// TimeZone is the IANA zone the event is in, such as America/New_York.
TimeZone string
}
func (e Event) validate() error {
switch {
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
case e.Summary == "":
return fmt.Errorf("gcal: event %s has no summary", e.ID)
case !e.End.After(e.Start):
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
case e.TimeZone == "":
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
}
return nil
}
// eventBody is an event as the Calendar API takes it.
type eventBody struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start eventTime `json:"start"`
End eventTime `json:"end"`
}
type eventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// PushEvents writes events, in order, to the chosen calendar: each is
// inserted with its id, and an id Google already has (HTTP 409, which
// includes an event deleted in Calendar) is updated in place instead, so
// writing an event again never adds a second one. It stops at the first
// failure; the events before it stay written, and writing them all again
// is safe. Nothing connected is ErrNotConnected, a grant already recorded
// as refused is ErrReconnect whatever calendar is chosen, no calendar
// chosen is ErrNoCalendar, a chosen calendar that was deleted or is no longer
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
// other Google failure is a *CalendarError, retryable when Google answered
// every try with HTTP 429 or 5xx.
//
// Once every event is written, PushEvents returns record(), which the
// caller uses to store what it pushed. record runs under the same hold of
// the account as the writes, so the chosen calendar cannot change between
// them; it is not run when a write fails.
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
if len(events) == 0 {
return errors.New("gcal: no events to push")
}
for _, e := range events {
if err := e.validate(); err != nil {
return err
}
}
s.account.Lock()
defer s.account.Unlock()
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
}
// pushEvents is PushEvents once the events are valid. The caller holds
// s.account. A refused grant is reported before no calendar chosen
// (grantedConnection): choosing a calendar cannot fix it, connecting again
// can.
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
row, err := s.grantedConnection(ctx)
if err != nil {
return err
}
if !row.CalendarID.Valid || row.CalendarID.String == "" {
return ErrNoCalendar
}
access, err := s.accessToken(ctx, row)
if err != nil {
return err
}
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
for _, e := range events {
if err := s.writeEvent(ctx, access, base, e); err != nil {
return err
}
}
return record()
}
// writeEvent inserts e at base, the chosen calendar's events collection,
// and updates it when Google already has its id.
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
body, err := json.Marshal(eventBody{
ID: e.ID,
Status: "confirmed",
Summary: e.Summary,
Description: e.Description,
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
})
if err != nil {
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
}
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
if err != nil {
return err
}
if status != http.StatusConflict {
return nil
}
// The status "confirmed" in the body restores an event deleted in
// Calendar, which Google keeps as cancelled under the same id.
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
return err
}
// eventRequest sends an event insert (POST) or update (PUT), retrying a
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
// every other answer is an error. An insert Google took but answered with
// a transient failure is answered 409 when retried, and updated.
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
what := "event insert"
if method == http.MethodPut {
what = "event update"
}
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return 0, err
}
switch {
case status == http.StatusOK:
return http.StatusOK, nil
case status == http.StatusConflict && method == http.MethodPost:
return http.StatusConflict, nil
case status == http.StatusUnauthorized:
// The access token was just refreshed from the grant.
return 0, ErrReconnect
case status == http.StatusNotFound && method == http.MethodPost,
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
// An insert into a deleted calendar, or one the account no longer
// sees, is not found; a write to one it can only read is
// requiredAccessLevel. An update not found is the event missing
// from the calendar after Google reported its id taken, which
// says nothing about the calendar.
return 0, ErrCalendarUnavailable
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
}
// errorReason is the reason of the first error in a Calendar API error
// body, or "" when there is none. Only the reason is read; the rest of
// Google's text is never kept.
func errorReason(body []byte) string {
var reply struct {
Error struct {
Errors []struct {
Reason string `json:"reason"`
} `json:"errors"`
} `json:"error"`
}
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
return ""
}
return reply.Error.Errors[0].Reason
}