Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

calsub: Add JSON as a supported format for calendar subscriptions #4196

Merged
merged 3 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 99 additions & 18 deletions calsub/http.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,78 @@
package calsub

import (
"context"
"encoding/json"
"fmt"
"mime"
"net/http"
"time"

"github.com/google/uuid"
"github.com/target/goalert/config"
"github.com/target/goalert/gadb"
"github.com/target/goalert/oncall"
"github.com/target/goalert/permission"
"github.com/target/goalert/util/errutil"
"github.com/target/goalert/version"
)

// PayloadType is the embedded type & version for calendar subscription payloads.
const PayloadType = "calendar-subscription/v1"

// JSONResponseV1 is the JSON response format for calendar subscription requests.
type JSONResponseV1 struct {
AppName string
AppVersion string

// Type is the embedded type & version for calendar subscription payloads and should be set to PayloadType.
Type string

ScheduleID uuid.UUID
ScheduleName string
ScheduleURL string

Start, End time.Time

Shifts []JSONShiftV1
}

// JSONShiftV1 is the JSON response format for a shift in a calendar subscription.
type JSONShiftV1 struct {
Start, End time.Time

UserID uuid.UUID
UserName string
UserURL string

Truncated bool
}

func (s *Store) userNameMap(ctx context.Context, shifts []oncall.Shift) (map[string]string, error) {
names := make(map[string]string)
var uniqueIDs []uuid.UUID
for _, s := range shifts {

// We'll use the map to track which IDs we've already seen.
// That way we don't ask the DB for the same user multiple times.
if _, ok := names[s.UserID]; ok {
continue
}
names[s.UserID] = "Unknown User"
uniqueIDs = append(uniqueIDs, uuid.MustParse(s.UserID))
}

users, err := gadb.New(s.db).CalSubUserNames(ctx, uniqueIDs)
if err != nil {
return nil, fmt.Errorf("lookup user names: %w", err)
}

for _, u := range users {
names[u.ID.String()] = u.Name
}
return names, nil
}

// ServeICalData will return an iCal file for the subscription associated with the current request.
func (s *Store) ServeICalData(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
Expand Down Expand Up @@ -50,6 +111,43 @@ func (s *Store) ServeICalData(w http.ResponseWriter, req *http.Request) {
shifts = filtered
}

ct, _, _ := mime.ParseMediaType(req.Header.Get("Accept"))
if ct == "application/json" {
data := JSONResponseV1{
AppName: cfg.ApplicationName(),
AppVersion: version.GitVersion(),
Type: PayloadType,
ScheduleID: info.ScheduleID,
ScheduleName: info.ScheduleName,
ScheduleURL: cfg.CallbackURL("/schedules/" + info.ScheduleID.String()),
Start: info.Now,
End: info.Now.AddDate(1, 0, 0),
}
m, err := s.userNameMap(ctx, shifts)
if errutil.HTTPError(ctx, w, err) {
return
}
for _, s := range shifts {
data.Shifts = append(data.Shifts, JSONShiftV1{
Start: s.Start,
End: s.End,
Truncated: s.Truncated,
UserID: uuid.MustParse(s.UserID),
UserName: m[s.UserID],
UserURL: cfg.CallbackURL("/users/" + s.UserID),
})
}
if len(data.Shifts) == 0 {
data.Shifts = []JSONShiftV1{}
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(data)
if errutil.HTTPError(ctx, w, err) {
return
}
return
}

data := renderData{
ApplicationName: cfg.ApplicationName(),
ScheduleID: info.ScheduleID,
Expand All @@ -63,27 +161,10 @@ func (s *Store) ServeICalData(w http.ResponseWriter, req *http.Request) {

if subCfg.FullSchedule {
// When rendering the full schedule, we need to fetch the names of all users.
data.UserNames = make(map[string]string)
var uniqueIDs []uuid.UUID
for _, s := range shifts {

// We'll use the map to track which IDs we've already seen.
// That way we don't ask the DB for the same user multiple times.
if _, ok := data.UserNames[s.UserID]; ok {
continue
}
data.UserNames[s.UserID] = "Unknown User"
uniqueIDs = append(uniqueIDs, uuid.MustParse(s.UserID))
}

users, err := gadb.New(s.db).CalSubUserNames(ctx, uniqueIDs)
data.UserNames, err = s.userNameMap(ctx, shifts)
if errutil.HTTPError(ctx, w, err) {
return
}

for _, u := range users {
data.UserNames[u.ID.String()] = u.Name
}
}

calData, err := data.renderICal()
Expand Down
113 changes: 113 additions & 0 deletions test/smoke/calendarsubscriptionjson_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package smoke

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/target/goalert/test/smoke/harness"
)

var (

// example: 2025-12-13T16:06:38.918293-06:00
isoRx = regexp.MustCompile(`"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|[-+]\d{2}:\d{2})"`)
urlHostRx = regexp.MustCompile(`"http://[^/]+`)
)

func TestCalendarSubscriptionJSON(t *testing.T) {
t.Parallel()

const sql = `
insert into users (id, name, email)
values
({{uuid "user"}}, 'bob', 'joe');
insert into schedules (id, name, time_zone, description)
values
({{uuid "schedId"}},'sched', 'America/Chicago', 'test description here');
`
h := harness.NewHarness(t, sql, "calendar-subscriptions")
defer h.Close()

doQL := func(query string, res interface{}) {
g := h.GraphQLQuery2(query)
for _, err := range g.Errors {
t.Error("GraphQL Error:", err.Message)
}
if len(g.Errors) > 0 {
t.Fatal("errors returned from GraphQL")
}
t.Log("Response:", string(g.Data))
if res == nil {
return
}
err := json.Unmarshal(g.Data, &res)
if err != nil {
t.Fatal("failed to parse response:", err)
}
}

var cs struct{ CreateUserCalendarSubscription struct{ URL string } }

const mut = `
mutation {
createUserCalendarSubscription (input: {
name: "%s",
reminderMinutes: [%d]
scheduleID: "%s",
}) {
url
}
}
`

// create subscription
doQL(fmt.Sprintf(mut, "foobar", 5, h.UUID("schedId")), &cs)

u, err := url.Parse(cs.CreateUserCalendarSubscription.URL)
assert.NoError(t, err)
assert.Contains(t, u.Path, "/api/v2/calendar")

resp, err := http.Get(cs.CreateUserCalendarSubscription.URL)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode, "serve iCalendar")

data, err := io.ReadAll(resp.Body)
require.NoError(t, err)

assert.Equal(t, "BEGIN:VCALENDAR\r\nPRODID:-//GoAlert//dev//EN\r\nVERSION:2.0\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nEND:VCALENDAR\r\n", string(data))

req, err := http.NewRequest("GET", cs.CreateUserCalendarSubscription.URL, nil)
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode, "serve JSON")
data, err = io.ReadAll(resp.Body)
require.NoError(t, err)

data = isoRx.ReplaceAll(data, []byte(`"2021-01-01T00:00:00Z"`))
data = urlHostRx.ReplaceAll(data, []byte(`"http://TEST_HOST`))

expected := fmt.Sprintf(`
{
"AppName": "GoAlert",
"AppVersion": "dev",
"Start": "2021-01-01T00:00:00Z",
"End": "2021-01-01T00:00:00Z",
"ScheduleID": "%s",
"ScheduleName": "sched",
"ScheduleURL": "http://TEST_HOST/schedules/%s",
"Shifts":[],
"Type": "calendar-subscription/v1"
}
`, h.UUID("schedId"), h.UUID("schedId"))

assert.JSONEq(t, expected, string(data))
}
Loading