Skip to content

Commit e5e45da

Browse files
authored
Add hooks support for WebSub (formerly PubSubHubbub) protocol (#2397)
Fixes: #1265.
1 parent 2eccd8c commit e5e45da

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

github/github.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,33 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ
426426
return req, nil
427427
}
428428

429+
// NewFormRequest creates an API request. A relative URL can be provided in urlStr,
430+
// in which case it is resolved relative to the BaseURL of the Client.
431+
// Relative URLs should always be specified without a preceding slash.
432+
// Body is sent with Content-Type: application/x-www-form-urlencoded.
433+
func (c *Client) NewFormRequest(urlStr string, body io.Reader) (*http.Request, error) {
434+
if !strings.HasSuffix(c.BaseURL.Path, "/") {
435+
return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL)
436+
}
437+
438+
u, err := c.BaseURL.Parse(urlStr)
439+
if err != nil {
440+
return nil, err
441+
}
442+
443+
req, err := http.NewRequest(http.MethodPost, u.String(), body)
444+
if err != nil {
445+
return nil, err
446+
}
447+
448+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
449+
req.Header.Set("Accept", mediaTypeV3)
450+
if c.UserAgent != "" {
451+
req.Header.Set("User-Agent", c.UserAgent)
452+
}
453+
return req, nil
454+
}
455+
429456
// NewUploadRequest creates an upload request. A relative URL can be provided in
430457
// urlStr, in which case it is resolved relative to the UploadURL of the Client.
431458
// Relative URLs should always be specified without a preceding slash.

github/github_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,84 @@ func TestNewRequest_errorForNoTrailingSlash(t *testing.T) {
602602
}
603603
}
604604

605+
func TestNewFormRequest(t *testing.T) {
606+
c := NewClient(nil)
607+
608+
inURL, outURL := "/foo", defaultBaseURL+"foo"
609+
form := url.Values{}
610+
form.Add("login", "l")
611+
inBody, outBody := strings.NewReader(form.Encode()), "login=l"
612+
req, _ := c.NewFormRequest(inURL, inBody)
613+
614+
// test that relative URL was expanded
615+
if got, want := req.URL.String(), outURL; got != want {
616+
t.Errorf("NewFormRequest(%q) URL is %v, want %v", inURL, got, want)
617+
}
618+
619+
// test that body was form encoded
620+
body, _ := ioutil.ReadAll(req.Body)
621+
if got, want := string(body), outBody; got != want {
622+
t.Errorf("NewFormRequest(%q) Body is %v, want %v", inBody, got, want)
623+
}
624+
625+
// test that default user-agent is attached to the request
626+
if got, want := req.Header.Get("User-Agent"), c.UserAgent; got != want {
627+
t.Errorf("NewFormRequest() User-Agent is %v, want %v", got, want)
628+
}
629+
}
630+
631+
func TestNewFormRequest_badURL(t *testing.T) {
632+
c := NewClient(nil)
633+
_, err := c.NewFormRequest(":", nil)
634+
testURLParseError(t, err)
635+
}
636+
637+
func TestNewFormRequest_emptyUserAgent(t *testing.T) {
638+
c := NewClient(nil)
639+
c.UserAgent = ""
640+
req, err := c.NewFormRequest(".", nil)
641+
if err != nil {
642+
t.Fatalf("NewFormRequest returned unexpected error: %v", err)
643+
}
644+
if _, ok := req.Header["User-Agent"]; ok {
645+
t.Fatal("constructed request contains unexpected User-Agent header")
646+
}
647+
}
648+
649+
func TestNewFormRequest_emptyBody(t *testing.T) {
650+
c := NewClient(nil)
651+
req, err := c.NewFormRequest(".", nil)
652+
if err != nil {
653+
t.Fatalf("NewFormRequest returned unexpected error: %v", err)
654+
}
655+
if req.Body != nil {
656+
t.Fatalf("constructed request contains a non-nil Body")
657+
}
658+
}
659+
660+
func TestNewFormRequest_errorForNoTrailingSlash(t *testing.T) {
661+
tests := []struct {
662+
rawURL string
663+
wantError bool
664+
}{
665+
{rawURL: "https://example.com/api/v3", wantError: true},
666+
{rawURL: "https://example.com/api/v3/", wantError: false},
667+
}
668+
c := NewClient(nil)
669+
for _, test := range tests {
670+
u, err := url.Parse(test.rawURL)
671+
if err != nil {
672+
t.Fatalf("url.Parse returned unexpected error: %v.", err)
673+
}
674+
c.BaseURL = u
675+
if _, err := c.NewFormRequest("test", nil); test.wantError && err == nil {
676+
t.Fatalf("Expected error to be returned.")
677+
} else if !test.wantError && err != nil {
678+
t.Fatalf("NewFormRequest returned unexpected error: %v.", err)
679+
}
680+
}
681+
}
682+
605683
func TestNewUploadRequest_badURL(t *testing.T) {
606684
c := NewClient(nil)
607685
_, err := c.NewUploadRequest(":", nil, 0, "")

github/repos_hooks.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ package github
88
import (
99
"context"
1010
"fmt"
11+
"net/http"
12+
"net/url"
13+
"strings"
1114
"time"
1215
)
1316

@@ -197,3 +200,55 @@ func (s *RepositoriesService) TestHook(ctx context.Context, owner, repo string,
197200
}
198201
return s.client.Do(ctx, req, nil)
199202
}
203+
204+
// Subscribe lets servers register to receive updates when a topic is updated.
205+
//
206+
// GitHub API docs: https://docs.github.com/en/rest/webhooks#pubsubhubbub
207+
func (s *RepositoriesService) Subscribe(ctx context.Context, owner, repo, event, callback string, secret []byte) (*Response, error) {
208+
req, err := s.createWebSubRequest("subscribe", owner, repo, event, callback, secret)
209+
if err != nil {
210+
return nil, err
211+
}
212+
213+
return s.client.Do(ctx, req, nil)
214+
}
215+
216+
// Unsubscribe lets servers unregister to no longer receive updates when a topic is updated.
217+
//
218+
// GitHub API docs: https://docs.github.com/en/rest/webhooks#pubsubhubbub
219+
func (s *RepositoriesService) Unsubscribe(ctx context.Context, owner, repo, event, callback string, secret []byte) (*Response, error) {
220+
req, err := s.createWebSubRequest("unsubscribe", owner, repo, event, callback, secret)
221+
if err != nil {
222+
return nil, err
223+
}
224+
225+
return s.client.Do(ctx, req, nil)
226+
}
227+
228+
// createWebSubRequest returns a subscribe/unsubscribe request that implements
229+
// the WebSub (formerly PubSubHubbub) protocol.
230+
//
231+
// See: https://www.w3.org/TR/websub/#subscriber-sends-subscription-request
232+
func (s *RepositoriesService) createWebSubRequest(hubMode, owner, repo, event, callback string, secret []byte) (*http.Request, error) {
233+
topic := fmt.Sprintf(
234+
"https://github.com/%s/%s/events/%s",
235+
owner,
236+
repo,
237+
event,
238+
)
239+
form := url.Values{}
240+
form.Add("hub.mode", hubMode)
241+
form.Add("hub.topic", topic)
242+
form.Add("hub.callback", callback)
243+
if secret != nil {
244+
form.Add("hub.secret", string(secret))
245+
}
246+
body := strings.NewReader(form.Encode())
247+
248+
req, err := s.client.NewFormRequest("hub", body)
249+
if err != nil {
250+
return nil, err
251+
}
252+
253+
return req, nil
254+
}

github/repos_hooks_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,69 @@ func TestBranchHook_Marshal(t *testing.T) {
564564

565565
testJSONMarshal(t, v, want)
566566
}
567+
568+
func TestRepositoriesService_Subscribe(t *testing.T) {
569+
client, mux, _, teardown := setup()
570+
defer teardown()
571+
572+
mux.HandleFunc("/hub", func(w http.ResponseWriter, r *http.Request) {
573+
testMethod(t, r, http.MethodPost)
574+
testHeader(t, r, "Content-Type", "application/x-www-form-urlencoded")
575+
testFormValues(t, r, values{
576+
"hub.mode": "subscribe",
577+
"hub.topic": "https://github.com/o/r/events/push",
578+
"hub.callback": "http://postbin.org/123",
579+
"hub.secret": "test secret",
580+
})
581+
})
582+
583+
ctx := context.Background()
584+
_, err := client.Repositories.Subscribe(
585+
ctx,
586+
"o",
587+
"r",
588+
"push",
589+
"http://postbin.org/123",
590+
[]byte("test secret"),
591+
)
592+
if err != nil {
593+
t.Errorf("Repositories.Subscribe returned error: %v", err)
594+
}
595+
596+
testNewRequestAndDoFailure(t, "Subscribe", client, func() (*Response, error) {
597+
return client.Repositories.Subscribe(ctx, "o", "r", "push", "http://postbin.org/123", nil)
598+
})
599+
}
600+
601+
func TestRepositoriesService_Unsubscribe(t *testing.T) {
602+
client, mux, _, teardown := setup()
603+
defer teardown()
604+
605+
mux.HandleFunc("/hub", func(w http.ResponseWriter, r *http.Request) {
606+
testMethod(t, r, http.MethodPost)
607+
testHeader(t, r, "Content-Type", "application/x-www-form-urlencoded")
608+
testFormValues(t, r, values{
609+
"hub.mode": "unsubscribe",
610+
"hub.topic": "https://github.com/o/r/events/push",
611+
"hub.callback": "http://postbin.org/123",
612+
"hub.secret": "test secret",
613+
})
614+
})
615+
616+
ctx := context.Background()
617+
_, err := client.Repositories.Unsubscribe(
618+
ctx,
619+
"o",
620+
"r",
621+
"push",
622+
"http://postbin.org/123",
623+
[]byte("test secret"),
624+
)
625+
if err != nil {
626+
t.Errorf("Repositories.Unsubscribe returned error: %v", err)
627+
}
628+
629+
testNewRequestAndDoFailure(t, "Unsubscribe", client, func() (*Response, error) {
630+
return client.Repositories.Unsubscribe(ctx, "o", "r", "push", "http://postbin.org/123", nil)
631+
})
632+
}

0 commit comments

Comments
 (0)