api/graph: Implement GraphQL-native tracker webhooks

Implement GraphQL-native tracker webhooks for tracker, label, and ticket
CRUD operations.
This commit is contained in:
Adnan Maolood 2022-04-28 10:03:51 -04:00 committed by Drew DeVault
parent 467dd4075f
commit f495ccdf32
8 changed files with 703 additions and 42 deletions

View File

@ -22,3 +22,5 @@ require (
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
)
replace github.com/Masterminds/squirrel => github.com/lieut-data/squirrel v1.5.4

View File

@ -46,8 +46,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3 h1:XcF0cTDJeiuZ5NU8w7WUDge0HRwwNRmxj/GGk6KSA6g=
github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
@ -216,6 +214,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lieut-data/squirrel v1.5.4 h1:OGzJNl0/ZxdjLEHuFzDo797zB2V7i8wQXBVThcOzbHE=
github.com/lieut-data/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=

View File

@ -233,6 +233,7 @@ func (ev *Event) Fields() *database.ModelFields {
// Always fetch:
{"id", "", &ev.ID},
{"created", "", &ev.Created},
{"event_type", "", &ev.EventType},
{"participant_id", "", &ev.ParticipantID},
{"ticket_id", "", &ev.TicketID},

View File

@ -231,3 +231,110 @@ func (sub *UserWebhookSubscription) QueryWithCursor(ctx context.Context,
return subs, cur
}
type TrackerWebhookSubscription struct {
ID int `json:"id"`
Events []WebhookEvent `json:"events"`
Query string `json:"query"`
URL string `json:"url"`
TrackerID int `json:"trackerId"`
UserID int
AuthMethod string
ClientID *string
TokenHash *string
Expires *time.Time
Grants *string
NodeID *string
alias string
fields *database.ModelFields
}
func (TrackerWebhookSubscription) IsWebhookSubscription() {}
func (sub *TrackerWebhookSubscription) As(alias string) *TrackerWebhookSubscription {
sub.alias = alias
return sub
}
func (sub *TrackerWebhookSubscription) Alias() string {
return sub.alias
}
func (sub *TrackerWebhookSubscription) Table() string {
return "gql_tracker_wh_sub"
}
func (sub *TrackerWebhookSubscription) Fields() *database.ModelFields {
if sub.fields != nil {
return sub.fields
}
sub.fields = &database.ModelFields{
Fields: []*database.FieldMap{
{"events", "events", pq.Array(&sub.Events)},
{"url", "url", &sub.URL},
// Always fetch:
{"id", "", &sub.ID},
{"query", "", &sub.Query},
{"user_id", "", &sub.UserID},
{"auth_method", "", &sub.AuthMethod},
{"token_hash", "", &sub.TokenHash},
{"client_id", "", &sub.ClientID},
{"grants", "", &sub.Grants},
{"expires", "", &sub.Expires},
{"node_id", "", &sub.NodeID},
{"tracker_id", "", &sub.TrackerID},
},
}
return sub.fields
}
func (sub *TrackerWebhookSubscription) QueryWithCursor(ctx context.Context,
runner sq.BaseRunner, q sq.SelectBuilder,
cur *model.Cursor) ([]WebhookSubscription, *model.Cursor) {
var (
err error
rows *sql.Rows
)
if cur.Next != "" {
next, _ := strconv.ParseInt(cur.Next, 10, 64)
q = q.Where(database.WithAlias(sub.alias, "id")+"<= ?", next)
}
q = q.
OrderBy(database.WithAlias(sub.alias, "id")).
Limit(uint64(cur.Count + 1))
if rows, err = q.RunWith(runner).QueryContext(ctx); err != nil {
panic(err)
}
defer rows.Close()
var (
subs []WebhookSubscription
lastID int
)
for rows.Next() {
var sub TrackerWebhookSubscription
if err := rows.Scan(database.Scan(ctx, &sub)...); err != nil {
panic(err)
}
subs = append(subs, &sub)
lastID = sub.ID
}
if len(subs) > cur.Count {
cur = &model.Cursor{
Count: cur.Count,
Next: strconv.Itoa(lastID),
Search: cur.Search,
}
subs = subs[:cur.Count]
} else {
cur = nil
}
return subs, cur
}

View File

@ -137,6 +137,18 @@ type Tracker {
archive of the tracker.
"""
export: URL!
"""
Returns a list of tracker webhook subscriptions. For clients
authenticated with a personal access token, this returns all webhooks
configured by all GraphQL clients for your account. For clients
authenticated with an OAuth 2.0 access token, this returns only webhooks
registered for your client.
"""
webhooks(cursor: Cursor): WebhookSubscriptionCursor!
"Returns details of a tracker webhook subscription by its ID."
webhook(id: Int!): WebhookSubscription
}
type OAuthClient {
@ -148,6 +160,11 @@ enum WebhookEvent {
TRACKER_UPDATE @access(scope: TRACKERS, kind: RO)
TRACKER_DELETED @access(scope: TRACKERS, kind: RO)
TICKET_CREATED @access(scope: TICKETS, kind: RO)
TICKET_UPDATE @access(scope: TICKETS, kind: RO)
LABEL_CREATED @access(scope: TRACKERS, kind: RO)
LABEL_UPDATE @access(scope: TRACKERS, kind: RO)
LABEL_DELETED @access(scope: TRACKERS, kind: RO)
EVENT_CREATED @access(scope: EVENTS, kind: RO)
}
interface WebhookSubscription {
@ -179,6 +196,18 @@ type UserWebhookSubscription implements WebhookSubscription {
sample(event: WebhookEvent): String!
}
type TrackerWebhookSubscription implements WebhookSubscription {
id: Int!
events: [WebhookEvent!]!
query: String!
url: String!
client: OAuthClient @private
deliveries(cursor: Cursor): WebhookDeliveryCursor!
sample(event: WebhookEvent!): String!
tracker: Tracker!
}
type WebhookDelivery {
uuid: String!
date: Time!
@ -219,6 +248,22 @@ type TicketEvent implements WebhookPayload {
ticket: Ticket!
}
type EventCreated implements WebhookPayload {
uuid: String!
event: WebhookEvent!
date: Time!
newEvent: Event!
}
type LabelEvent implements WebhookPayload {
uuid: String!
event: WebhookEvent!
date: Time!
label: Label!
}
enum TicketStatus {
REPORTED
CONFIRMED
@ -728,6 +773,12 @@ input UserWebhookInput {
query: String!
}
input TrackerWebhookInput {
url: String!
events: [WebhookEvent!]!
query: String!
}
type Mutation {
"""
Creates a new bug tracker. If specified, the 'import' field specifies a
@ -860,7 +911,7 @@ type Mutation {
may be used to access details of the event which trigged the webhook. The
query may not make any mutations.
"""
createWebhook(config: UserWebhookInput!): WebhookSubscription!
createUserWebhook(config: UserWebhookInput!): WebhookSubscription!
"""
Deletes a user webhook. Any events already queued may still be
@ -870,5 +921,11 @@ type Mutation {
Manually deleting a webhook configured by a third-party client may cause
unexpected behavior with the third-party integration.
"""
deleteWebhook(id: Int!): WebhookSubscription
deleteUserWebhook(id: Int!): WebhookSubscription
"Creates a new tracker webhook."
createTrackerWebhook(trackerId: Int!, config: TrackerWebhookInput!): WebhookSubscription!
"Deletes a tracker webhook."
deleteTrackerWebhook(id: Int!): WebhookSubscription
}

View File

@ -25,6 +25,7 @@ import (
"git.sr.ht/~sircmpwn/todo.sr.ht/api/webhooks"
"github.com/99designs/gqlgen/graphql"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/lib/pq"
)
@ -193,7 +194,7 @@ func (r *mutationResolver) CreateTracker(ctx context.Context, name string, descr
return nil, err
}
webhooks.DeliverLegacyTrackerEvent(ctx, &tracker, "tracker:create")
webhooks.DeliverTrackerEvent(ctx, model.WebhookEventTrackerCreated, &tracker)
webhooks.DeliverUserTrackerEvent(ctx, model.WebhookEventTrackerCreated, &tracker)
return &tracker, nil
}
@ -243,6 +244,7 @@ func (r *mutationResolver) UpdateTracker(ctx context.Context, id int, input map[
return nil, err
}
webhooks.DeliverLegacyTrackerEvent(ctx, &tracker, "tracker:update")
webhooks.DeliverUserTrackerEvent(ctx, model.WebhookEventTrackerUpdate, &tracker)
webhooks.DeliverTrackerEvent(ctx, model.WebhookEventTrackerUpdate, &tracker)
return &tracker, nil
}
@ -266,6 +268,10 @@ func (r *mutationResolver) DeleteTracker(ctx context.Context, id int) (*model.Tr
return err
}
tracker.Access = model.ACCESS_ALL
webhooks.DeliverLegacyTrackerDelete(ctx, tracker.ID, user.UserID)
webhooks.DeliverUserTrackerEvent(ctx, model.WebhookEventTrackerDeleted, &tracker)
webhooks.DeliverTrackerEvent(ctx, model.WebhookEventTrackerDeleted, &tracker)
return nil
}); err != nil {
if err == sql.ErrNoRows {
@ -273,9 +279,6 @@ func (r *mutationResolver) DeleteTracker(ctx context.Context, id int) (*model.Tr
}
return nil, err
}
webhooks.DeliverLegacyTrackerDelete(ctx, tracker.ID, user.UserID)
webhooks.DeliverTrackerEvent(ctx, model.WebhookEventTrackerDeleted, &tracker)
return &tracker, nil
}
@ -341,6 +344,7 @@ func (r *mutationResolver) UpdateTrackerACL(ctx context.Context, trackerID int,
return nil, err
}
webhooks.DeliverLegacyTrackerEvent(ctx, &tracker, "tracker:update")
webhooks.DeliverUserTrackerEvent(ctx, model.WebhookEventTrackerUpdate, &tracker)
webhooks.DeliverTrackerEvent(ctx, model.WebhookEventTrackerUpdate, &tracker)
acl := &model.DefaultACL{}
acl.SetBits(bits)
@ -572,6 +576,7 @@ func (r *mutationResolver) CreateLabel(ctx context.Context, trackerID int, name
return nil, err
}
webhooks.DeliverLegacyLabelCreate(ctx, tracker, &label)
webhooks.DeliverTrackerLabelEvent(ctx, model.WebhookEventLabelCreated, label.TrackerID, &label)
return &label, nil
}
@ -608,31 +613,28 @@ func (r *mutationResolver) UpdateLabel(ctx context.Context, id int, input map[st
return nil, nil
}
label, err := loaders.ForContext(ctx).LabelsByID.Load(id)
if err != nil || label == nil {
return nil, err
}
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(label.TrackerID)
if err != nil {
return nil, err
}
if tracker.OwnerID != auth.ForContext(ctx).UserID {
return nil, fmt.Errorf("Access denied")
}
var label model.Label
userID := auth.ForContext(ctx).UserID
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
var err error
if len(input) != 0 {
_, err = query.
Where(database.WithAlias(label.Alias(), `id`)+"= ?", id).
RunWith(tx).
ExecContext(ctx)
row := query.
From(`tracker tr`).
Where(`label.id = ? AND tracker_id = tr.id AND tr.owner_id = ?`, id, userID).
Suffix(`RETURNING label.id, label.tracker_id, label.created, label.name, label.color, label.text_color`).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(&label.ID, &label.TrackerID, &label.Created,
&label.Name, &label.BackgroundColor, &label.ForegroundColor); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("No label by ID %d found for this user", id)
}
return err
}
return err
return nil
}); err != nil {
return nil, err
}
return label, nil
webhooks.DeliverTrackerLabelEvent(ctx, model.WebhookEventLabelUpdate, label.TrackerID, &label)
return &label, nil
}
func (r *mutationResolver) DeleteLabel(ctx context.Context, id int) (*model.Label, error) {
@ -657,6 +659,7 @@ func (r *mutationResolver) DeleteLabel(ctx context.Context, id int) (*model.Labe
return nil, err
}
webhooks.DeliverLegacyLabelDelete(ctx, label.TrackerID, label.ID)
webhooks.DeliverTrackerLabelEvent(ctx, model.WebhookEventLabelDeleted, label.TrackerID, &label)
return &label, nil
}
@ -820,7 +823,8 @@ func (r *mutationResolver) SubmitTicket(ctx context.Context, trackerID int, inpu
return nil, err
}
webhooks.DeliverLegacyTicketCreate(ctx, tracker, &ticket)
webhooks.DeliverTicketEvent(ctx, model.WebhookEventTicketCreated, &ticket)
webhooks.DeliverUserTicketEvent(ctx, model.WebhookEventTicketCreated, &ticket)
webhooks.DeliverTrackerTicketEvent(ctx, model.WebhookEventTicketCreated, ticket.TrackerID, &ticket)
return &ticket, nil
}
@ -930,7 +934,8 @@ func (r *mutationResolver) SubmitEmail(ctx context.Context, trackerID int, input
return nil, err
}
webhooks.DeliverLegacyTicketCreate(ctx, tracker, &ticket)
webhooks.DeliverTicketEvent(ctx, model.WebhookEventTicketCreated, &ticket)
webhooks.DeliverUserTicketEvent(ctx, model.WebhookEventTicketCreated, &ticket)
webhooks.DeliverTrackerTicketEvent(ctx, model.WebhookEventTicketCreated, ticket.TrackerID, &ticket)
return &ticket, nil
}
@ -1001,6 +1006,7 @@ func (r *mutationResolver) UpdateTicket(ctx context.Context, trackerID int, tick
return nil, err
}
webhooks.DeliverTrackerTicketEvent(ctx, model.WebhookEventTicketUpdate, ticket.TrackerID, ticket)
return ticket, nil
}
@ -1072,11 +1078,12 @@ func (r *mutationResolver) UpdateTicketStatus(ctx context.Context, trackerID int
columns := database.Columns(ctx, &event)
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
_, err := update.
row := update.
Where(`ticket.id = ?`, ticket.PKID).
Suffix(`RETURNING ticket.status, ticket.resolution`).
RunWith(tx).
ExecContext(ctx)
if err != nil {
QueryRowContext(ctx)
if err := row.Scan(&ticket.RawStatus, &ticket.RawResolution); err != nil {
return err
}
@ -1111,6 +1118,8 @@ func (r *mutationResolver) UpdateTicketStatus(ctx context.Context, trackerID int
return nil, err
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerTicketEvent(ctx, model.WebhookEventTicketUpdate, ticket.TrackerID, ticket)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
return &event, nil
}
@ -1266,6 +1275,7 @@ func (r *mutationResolver) SubmitComment(ctx context.Context, trackerID int, tic
return nil, err
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
return &event, nil
}
@ -1392,6 +1402,7 @@ func (r *mutationResolver) AssignUser(ctx context.Context, trackerID int, ticket
return nil, err
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
return &event, nil
}
@ -1515,6 +1526,7 @@ func (r *mutationResolver) UnassignUser(ctx context.Context, trackerID int, tick
return nil, err
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
return &event, nil
}
@ -1602,6 +1614,7 @@ func (r *mutationResolver) LabelTicket(ctx context.Context, trackerID int, ticke
return nil, err
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
return &event, nil
}
@ -1682,10 +1695,11 @@ func (r *mutationResolver) UnlabelTicket(ctx context.Context, trackerID int, tic
return nil, err
}
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event)
return &event, nil
}
func (r *mutationResolver) CreateWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) {
schema := server.ForContext(ctx).Schema
if err := corewebhooks.Validate(schema, config.Query); err != nil {
return nil, err
@ -1714,6 +1728,8 @@ func (r *mutationResolver) CreateWebhook(ctx context.Context, config model.UserW
access = "TRACKERS"
case model.WebhookEventTicketCreated:
access = "TICKETS"
default:
return nil, fmt.Errorf("Unsupported event %s", ev.String())
}
if !user.Grants.Has(access, auth.RO) {
return nil, fmt.Errorf("Insufficient access granted for webhook event %s", ev.String())
@ -1759,7 +1775,7 @@ func (r *mutationResolver) CreateWebhook(ctx context.Context, config model.UserW
return &sub, nil
}
func (r *mutationResolver) DeleteWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
var sub model.UserWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
@ -1789,6 +1805,117 @@ func (r *mutationResolver) DeleteWebhook(ctx context.Context, id int) (model.Web
return &sub, nil
}
func (r *mutationResolver) CreateTrackerWebhook(ctx context.Context, trackerID int, config model.TrackerWebhookInput) (model.WebhookSubscription, error) {
schema := server.ForContext(ctx).Schema
if err := corewebhooks.Validate(schema, config.Query); err != nil {
return nil, err
}
user := auth.ForContext(ctx)
ac, err := corewebhooks.NewAuthConfig(ctx)
if err != nil {
return nil, err
}
var sub model.TrackerWebhookSubscription
if len(config.Events) == 0 {
return nil, fmt.Errorf("Must specify at least one event")
}
events := make([]string, len(config.Events))
for i, ev := range config.Events {
events[i] = ev.String()
// TODO: gqlgen does not support doing anything useful with directives
// on enums at the time of writing, so we have to do a little bit of
// manual fuckery
var access string
switch ev {
case model.WebhookEventTrackerUpdate, model.WebhookEventTrackerDeleted,
model.WebhookEventLabelCreated, model.WebhookEventLabelUpdate,
model.WebhookEventLabelDeleted:
access = "TRACKERS"
case model.WebhookEventTicketCreated, model.WebhookEventTicketUpdate:
access = "TICKETS"
case model.WebhookEventEventCreated:
access = "EVENTS"
default:
return nil, fmt.Errorf("Unsupported event %s", ev.String())
}
if !user.Grants.Has(access, auth.RO) {
return nil, fmt.Errorf("Insufficient access granted for webhook event %s", ev.String())
}
}
u, err := url.Parse(config.URL)
if err != nil {
return nil, err
} else if u.Host == "" {
return nil, fmt.Errorf("Cannot use URL without host")
} else if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("Cannot use non-HTTP or HTTPS URL")
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
INSERT INTO gql_tracker_wh_sub (
created, events, url, query,
auth_method,
token_hash, grants, client_id, expires,
node_id,
user_id,
tracker_id
) VALUES (
NOW() at time zone 'utc',
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
) RETURNING id, url, query, events, user_id, tracker_id;`,
pq.Array(events), config.URL, config.Query,
ac.AuthMethod,
ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2
ac.NodeID, // INTERNAL
user.UserID,
trackerID)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID, &sub.TrackerID); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &sub, nil
}
func (r *mutationResolver) DeleteTrackerWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
var sub model.TrackerWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := sq.Delete(`gql_tracker_wh_sub`).
PlaceholderFormat(sq.Dollar).
Where(sq.And{sq.Expr(`id = ?`, id), filter}).
Suffix(`RETURNING id, url, query, events, user_id, tracker_id`).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(&sub.ID, &sub.URL,
&sub.Query, pq.Array(&sub.Events), &sub.UserID, &sub.TrackerID); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sub, nil
}
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
return &model.Version{
Major: 0,
@ -2258,6 +2385,71 @@ func (r *trackerResolver) Export(ctx context.Context, obj *model.Tracker) (strin
panic(fmt.Errorf("not implemented")) // TODO
}
func (r *trackerResolver) Webhooks(ctx context.Context, obj *model.Tracker, cursor *coremodel.Cursor) (*model.WebhookSubscriptionCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
var subs []model.WebhookSubscription
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
sub := (&model.TrackerWebhookSubscription{}).As(`sub`)
query := database.
Select(ctx, sub).
From(`gql_tracker_wh_sub sub`).
Where(sq.And{sq.Expr(`tracker_id = ?`, obj.ID), filter})
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookSubscriptionCursor{subs, cursor}, nil
}
func (r *trackerResolver) Webhook(ctx context.Context, obj *model.Tracker, id int) (model.WebhookSubscription, error) {
var sub model.TrackerWebhookSubscription
filter, err := corewebhooks.FilterWebhooks(ctx)
if err != nil {
return nil, err
}
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
row := database.
Select(ctx, &sub).
From(`gql_tracker_wh_sub`).
Where(sq.And{
sq.Expr(`id = ?`, id),
sq.Expr(`tracker_id = ?`, obj.ID),
filter,
}).
RunWith(tx).
QueryRowContext(ctx)
if err := row.Scan(database.Scan(ctx, &sub)...); err != nil {
return err
}
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sub, nil
}
func (r *trackerACLResolver) Tracker(ctx context.Context, obj *model.TrackerACL) (*model.Tracker, error) {
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
}
@ -2270,6 +2462,161 @@ func (r *trackerSubscriptionResolver) Tracker(ctx context.Context, obj *model.Tr
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
}
func (r *trackerWebhookSubscriptionResolver) Client(ctx context.Context, obj *model.TrackerWebhookSubscription) (*model.OAuthClient, error) {
if obj.ClientID == nil {
return nil, nil
}
return &model.OAuthClient{
UUID: *obj.ClientID,
}, nil
}
func (r *trackerWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.TrackerWebhookSubscription, cursor *coremodel.Cursor) (*model.WebhookDeliveryCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
}
var deliveries []*model.WebhookDelivery
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
d := (&model.WebhookDelivery{}).
WithName(`tracker`).
As(`delivery`)
query := database.
Select(ctx, d).
From(`gql_tracker_wh_delivery delivery`).
Where(`delivery.subscription_id = ?`, obj.ID)
deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor)
return nil
}); err != nil {
return nil, err
}
return &model.WebhookDeliveryCursor{deliveries, cursor}, nil
}
func (r *trackerWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.TrackerWebhookSubscription, event model.WebhookEvent) (string, error) {
payloadUUID := uuid.New()
webhook := corewebhooks.WebhookContext{
User: auth.ForContext(ctx),
PayloadUUID: payloadUUID,
Name: "tracker",
Event: event.String(),
Subscription: &corewebhooks.WebhookSubscription{
ID: obj.ID,
URL: obj.URL,
Query: obj.Query,
AuthMethod: obj.AuthMethod,
TokenHash: obj.TokenHash,
Grants: obj.Grants,
ClientID: obj.ClientID,
Expires: obj.Expires,
NodeID: obj.NodeID,
},
}
auth := auth.ForContext(ctx)
switch event {
case model.WebhookEventTrackerUpdate, model.WebhookEventTrackerDeleted:
desc := "Sample todo tracker for testing webhooks"
webhook.Payload = &model.TrackerEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Tracker: &model.Tracker{
ID: -1,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
Name: "sample-tracker",
Description: &desc,
Visibility: model.VisibilityPublic,
OwnerID: auth.UserID,
Access: model.ACCESS_ALL,
DefaultAccess: model.ACCESS_ALL,
ACLID: nil,
},
}
case model.WebhookEventLabelCreated, model.WebhookEventLabelUpdate,
model.WebhookEventLabelDeleted:
webhook.Payload = &model.LabelEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Label: &model.Label{
ID: -1,
Created: time.Now().UTC(),
Name: "sample-label",
BackgroundColor: "#ffffff",
ForegroundColor: "#000000",
TrackerID: -1,
},
}
case model.WebhookEventTicketCreated, model.WebhookEventTicketUpdate:
body := "This is a sample ticket body."
webhook.Payload = &model.TicketEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Ticket: &model.Ticket{
ID: 1,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
Subject: "A sample ticket",
Body: &body,
PKID: -1,
TrackerID: -1,
TrackerName: "sample-tracker",
OwnerName: auth.Username,
SubmitterID: -1,
RawAuthenticity: model.AUTH_AUTHENTIC,
RawStatus: model.STATUS_REPORTED,
RawResolution: model.RESOLVED_UNRESOLVED,
},
}
case model.WebhookEventEventCreated:
oldStatus := model.STATUS_REPORTED
newStatus := model.STATUS_RESOLVED
oldResolution := model.RESOLVED_UNRESOLVED
newResolution := model.RESOLVED_FIXED
webhook.Payload = &model.EventCreated{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
NewEvent: &model.Event{
ID: -1,
Created: time.Now().UTC(),
EventType: model.EVENT_STATUS_CHANGE,
ParticipantID: -1,
TicketID: -1,
ByParticipantID: nil,
CommentID: nil,
LabelID: nil,
FromTicketID: nil,
OldStatus: &oldStatus,
NewStatus: &newStatus,
OldResolution: &oldResolution,
NewResolution: &newResolution,
},
}
default:
return "", fmt.Errorf("Unsupported event %s", event.String())
}
subctx := corewebhooks.Context(ctx, webhook.Payload)
bytes, err := webhook.Exec(subctx, server.ForContext(ctx).Schema)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (r *trackerWebhookSubscriptionResolver) Tracker(ctx context.Context, obj *model.TrackerWebhookSubscription) (*model.Tracker, error) {
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
}
func (r *userResolver) Trackers(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.TrackerCursor, error) {
if cursor == nil {
cursor = coremodel.NewCursor(nil)
@ -2370,12 +2717,23 @@ func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.W
ReadOnly: true,
}, func(tx *sql.Tx) error {
// XXX: This needs some work to generalize to other kinds of webhooks
subscription := (&model.UserWebhookSubscription{}).As(`sub`)
var subscription interface {
model.WebhookSubscription
database.Model
} = nil
switch obj.Name {
case "user":
subscription = (&model.UserWebhookSubscription{}).As(`sub`)
case "tracker":
subscription = (&model.TrackerWebhookSubscription{}).As(`sub`)
default:
panic(fmt.Errorf("unknown webhook name %q", obj.Name))
}
// Note: No filter needed because, if we have access to the delivery,
// we also have access to the subscription.
row := database.
Select(ctx, subscription).
From(`gql_user_wh_sub sub`).
From(`gql_`+obj.Name+`_wh_sub sub`).
Where(`sub.id = ?`, obj.SubscriptionID).
RunWith(tx).
QueryRowContext(ctx)
@ -2439,6 +2797,11 @@ func (r *Resolver) TrackerSubscription() api.TrackerSubscriptionResolver {
return &trackerSubscriptionResolver{r}
}
// TrackerWebhookSubscription returns api.TrackerWebhookSubscriptionResolver implementation.
func (r *Resolver) TrackerWebhookSubscription() api.TrackerWebhookSubscriptionResolver {
return &trackerWebhookSubscriptionResolver{r}
}
// User returns api.UserResolver implementation.
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
@ -2468,6 +2831,7 @@ type ticketSubscriptionResolver struct{ *Resolver }
type trackerResolver struct{ *Resolver }
type trackerACLResolver struct{ *Resolver }
type trackerSubscriptionResolver struct{ *Resolver }
type trackerWebhookSubscriptionResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type userMentionResolver struct{ *Resolver }
type userWebhookSubscriptionResolver struct{ *Resolver }

View File

@ -24,7 +24,19 @@ func deliverUserWebhook(ctx context.Context, event model.WebhookEvent,
payloadUUID, payload)
}
func DeliverTrackerEvent(ctx context.Context,
func deliverTrackerWebhook(ctx context.Context, trackerID int,
event model.WebhookEvent, payload model.WebhookPayload, payloadUUID uuid.UUID) {
q := webhooks.ForContext(ctx)
userID := auth.ForContext(ctx).UserID
query := sq.
Select().
From("gql_tracker_wh_sub sub").
Where("sub.user_id = ? AND sub.tracker_id = ?", userID, trackerID)
q.Schedule(ctx, query, "tracker", event.String(),
payloadUUID, payload)
}
func DeliverUserTrackerEvent(ctx context.Context,
event model.WebhookEvent, tracker *model.Tracker) {
payloadUUID := uuid.New()
payload := model.TrackerEvent{
@ -36,14 +48,62 @@ func DeliverTrackerEvent(ctx context.Context,
deliverUserWebhook(ctx, event, &payload, payloadUUID)
}
func DeliverTicketEvent(ctx context.Context,
func DeliverUserTicketEvent(ctx context.Context,
event model.WebhookEvent, ticket *model.Ticket) {
payloadUUID := uuid.New()
payload := model.TicketEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Ticket: ticket,
}
deliverUserWebhook(ctx, event, &payload, payloadUUID)
}
func DeliverTrackerEvent(ctx context.Context,
event model.WebhookEvent, tracker *model.Tracker) {
payloadUUID := uuid.New()
payload := model.TrackerEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Tracker: tracker,
}
deliverTrackerWebhook(ctx, tracker.ID, event, &payload, payloadUUID)
}
func DeliverTrackerLabelEvent(ctx context.Context,
event model.WebhookEvent, trackerID int, label *model.Label) {
payloadUUID := uuid.New()
payload := model.LabelEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Label: label,
}
deliverTrackerWebhook(ctx, trackerID, event, &payload, payloadUUID)
}
func DeliverTrackerTicketEvent(ctx context.Context,
event model.WebhookEvent, trackerID int, ticket *model.Ticket) {
payloadUUID := uuid.New()
payload := model.TicketEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
Ticket: ticket,
}
deliverTrackerWebhook(ctx, trackerID, event, &payload, payloadUUID)
}
func DeliverTrackerEventCreated(ctx context.Context, trackerID int, newEvent *model.Event) {
event := model.WebhookEventEventCreated
payloadUUID := uuid.New()
payload := model.EventCreated{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
NewEvent: newEvent,
}
deliverTrackerWebhook(ctx, trackerID, event, &payload, payloadUUID)
}

View File

@ -0,0 +1,70 @@
"""Add GraphQL tracker webhook tables
Revision ID: 87daab81985b
Revises: dbed5c6ea613
Create Date: 2022-04-11 14:21:35.885142
"""
# revision identifiers, used by Alembic.
revision = '87daab81985b'
down_revision = 'dbed5c6ea613'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("""
CREATE TYPE tracker_webhook_event AS ENUM (
'TRACKER_UPDATE',
'TRACKER_DELETED',
'TICKET_CREATED',
'TICKET_UPDATE',
'LABEL_CREATED',
'LABEL_UPDATE',
'LABEL_DELETED',
'EVENT_CREATED'
);
CREATE TABLE gql_tracker_wh_sub (
id serial PRIMARY KEY,
created timestamp NOT NULL,
events tracker_webhook_event[] NOT NULL check (array_length(events, 1) > 0),
url varchar NOT NULL,
query varchar NOT NULL,
auth_method auth_method NOT NULL check (auth_method in ('OAUTH2', 'INTERNAL')),
token_hash varchar(128) check ((auth_method = 'OAUTH2') = (token_hash IS NOT NULL)),
grants varchar,
client_id uuid,
expires timestamp check ((auth_method = 'OAUTH2') = (expires IS NOT NULL)),
node_id varchar check ((auth_method = 'INTERNAL') = (node_id IS NOT NULL)),
user_id integer NOT NULL references "user"(id),
tracker_id integer NOT NULL references "tracker"(id) ON DELETE CASCADE
);
CREATE INDEX gql_tracker_wh_sub_token_hash_idx ON gql_tracker_wh_sub (token_hash);
CREATE TABLE gql_tracker_wh_delivery (
id serial PRIMARY KEY,
uuid uuid NOT NULL,
date timestamp NOT NULL,
event tracker_webhook_event NOT NULL,
subscription_id integer NOT NULL references gql_tracker_wh_sub(id) ON DELETE CASCADE,
request_body varchar NOT NULL,
response_body varchar,
response_headers varchar,
response_status integer
);
""")
def downgrade():
op.execute("""
DROP TABLE gql_tracker_wh_delivery;
DROP INDEX gql_tracker_wh_sub_token_hash_idx;
DROP TABLE gql_tracker_wh_sub;
DROP TYPE tracker_webhook_event;
""")