Skip to content

Commit

Permalink
Add hooks support for WebSub (formerly PubSubHubbub) protocol (#2397)
Browse files Browse the repository at this point in the history
Fixes: #1265.
  • Loading branch information
YuyaAbo committed Aug 21, 2022
1 parent 2eccd8c commit e5e45da
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 0 deletions.
27 changes: 27 additions & 0 deletions github/github.go
Expand Up @@ -426,6 +426,33 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ
return req, nil
}

// NewFormRequest creates an API request. A relative URL can be provided in urlStr,
// in which case it is resolved relative to the BaseURL of the Client.
// Relative URLs should always be specified without a preceding slash.
// Body is sent with Content-Type: application/x-www-form-urlencoded.
func (c *Client) NewFormRequest(urlStr string, body io.Reader) (*http.Request, error) {
if !strings.HasSuffix(c.BaseURL.Path, "/") {
return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL)
}

u, err := c.BaseURL.Parse(urlStr)
if err != nil {
return nil, err
}

req, err := http.NewRequest(http.MethodPost, u.String(), body)
if err != nil {
return nil, err
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", mediaTypeV3)
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
return req, nil
}

// NewUploadRequest creates an upload request. A relative URL can be provided in
// urlStr, in which case it is resolved relative to the UploadURL of the Client.
// Relative URLs should always be specified without a preceding slash.
Expand Down
78 changes: 78 additions & 0 deletions github/github_test.go
Expand Up @@ -602,6 +602,84 @@ func TestNewRequest_errorForNoTrailingSlash(t *testing.T) {
}
}

func TestNewFormRequest(t *testing.T) {
c := NewClient(nil)

inURL, outURL := "/foo", defaultBaseURL+"foo"
form := url.Values{}
form.Add("login", "l")
inBody, outBody := strings.NewReader(form.Encode()), "login=l"
req, _ := c.NewFormRequest(inURL, inBody)

// test that relative URL was expanded
if got, want := req.URL.String(), outURL; got != want {
t.Errorf("NewFormRequest(%q) URL is %v, want %v", inURL, got, want)
}

// test that body was form encoded
body, _ := ioutil.ReadAll(req.Body)
if got, want := string(body), outBody; got != want {
t.Errorf("NewFormRequest(%q) Body is %v, want %v", inBody, got, want)
}

// test that default user-agent is attached to the request
if got, want := req.Header.Get("User-Agent"), c.UserAgent; got != want {
t.Errorf("NewFormRequest() User-Agent is %v, want %v", got, want)
}
}

func TestNewFormRequest_badURL(t *testing.T) {
c := NewClient(nil)
_, err := c.NewFormRequest(":", nil)
testURLParseError(t, err)
}

func TestNewFormRequest_emptyUserAgent(t *testing.T) {
c := NewClient(nil)
c.UserAgent = ""
req, err := c.NewFormRequest(".", nil)
if err != nil {
t.Fatalf("NewFormRequest returned unexpected error: %v", err)
}
if _, ok := req.Header["User-Agent"]; ok {
t.Fatal("constructed request contains unexpected User-Agent header")
}
}

func TestNewFormRequest_emptyBody(t *testing.T) {
c := NewClient(nil)
req, err := c.NewFormRequest(".", nil)
if err != nil {
t.Fatalf("NewFormRequest returned unexpected error: %v", err)
}
if req.Body != nil {
t.Fatalf("constructed request contains a non-nil Body")
}
}

func TestNewFormRequest_errorForNoTrailingSlash(t *testing.T) {
tests := []struct {
rawURL string
wantError bool
}{
{rawURL: "https://example.com/api/v3", wantError: true},
{rawURL: "https://example.com/api/v3/", wantError: false},
}
c := NewClient(nil)
for _, test := range tests {
u, err := url.Parse(test.rawURL)
if err != nil {
t.Fatalf("url.Parse returned unexpected error: %v.", err)
}
c.BaseURL = u
if _, err := c.NewFormRequest("test", nil); test.wantError && err == nil {
t.Fatalf("Expected error to be returned.")
} else if !test.wantError && err != nil {
t.Fatalf("NewFormRequest returned unexpected error: %v.", err)
}
}
}

func TestNewUploadRequest_badURL(t *testing.T) {
c := NewClient(nil)
_, err := c.NewUploadRequest(":", nil, 0, "")
Expand Down
55 changes: 55 additions & 0 deletions github/repos_hooks.go
Expand Up @@ -8,6 +8,9 @@ package github
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)

Expand Down Expand Up @@ -197,3 +200,55 @@ func (s *RepositoriesService) TestHook(ctx context.Context, owner, repo string,
}
return s.client.Do(ctx, req, nil)
}

// Subscribe lets servers register to receive updates when a topic is updated.
//
// GitHub API docs: https://docs.github.com/en/rest/webhooks#pubsubhubbub
func (s *RepositoriesService) Subscribe(ctx context.Context, owner, repo, event, callback string, secret []byte) (*Response, error) {
req, err := s.createWebSubRequest("subscribe", owner, repo, event, callback, secret)
if err != nil {
return nil, err
}

return s.client.Do(ctx, req, nil)
}

// Unsubscribe lets servers unregister to no longer receive updates when a topic is updated.
//
// GitHub API docs: https://docs.github.com/en/rest/webhooks#pubsubhubbub
func (s *RepositoriesService) Unsubscribe(ctx context.Context, owner, repo, event, callback string, secret []byte) (*Response, error) {
req, err := s.createWebSubRequest("unsubscribe", owner, repo, event, callback, secret)
if err != nil {
return nil, err
}

return s.client.Do(ctx, req, nil)
}

// createWebSubRequest returns a subscribe/unsubscribe request that implements
// the WebSub (formerly PubSubHubbub) protocol.
//
// See: https://www.w3.org/TR/websub/#subscriber-sends-subscription-request
func (s *RepositoriesService) createWebSubRequest(hubMode, owner, repo, event, callback string, secret []byte) (*http.Request, error) {
topic := fmt.Sprintf(
"https://github.com/%s/%s/events/%s",
owner,
repo,
event,
)
form := url.Values{}
form.Add("hub.mode", hubMode)
form.Add("hub.topic", topic)
form.Add("hub.callback", callback)
if secret != nil {
form.Add("hub.secret", string(secret))
}
body := strings.NewReader(form.Encode())

req, err := s.client.NewFormRequest("hub", body)
if err != nil {
return nil, err
}

return req, nil
}
66 changes: 66 additions & 0 deletions github/repos_hooks_test.go
Expand Up @@ -564,3 +564,69 @@ func TestBranchHook_Marshal(t *testing.T) {

testJSONMarshal(t, v, want)
}

func TestRepositoriesService_Subscribe(t *testing.T) {
client, mux, _, teardown := setup()
defer teardown()

mux.HandleFunc("/hub", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, http.MethodPost)
testHeader(t, r, "Content-Type", "application/x-www-form-urlencoded")
testFormValues(t, r, values{
"hub.mode": "subscribe",
"hub.topic": "https://github.com/o/r/events/push",
"hub.callback": "http://postbin.org/123",
"hub.secret": "test secret",
})
})

ctx := context.Background()
_, err := client.Repositories.Subscribe(
ctx,
"o",
"r",
"push",
"http://postbin.org/123",
[]byte("test secret"),
)
if err != nil {
t.Errorf("Repositories.Subscribe returned error: %v", err)
}

testNewRequestAndDoFailure(t, "Subscribe", client, func() (*Response, error) {
return client.Repositories.Subscribe(ctx, "o", "r", "push", "http://postbin.org/123", nil)
})
}

func TestRepositoriesService_Unsubscribe(t *testing.T) {
client, mux, _, teardown := setup()
defer teardown()

mux.HandleFunc("/hub", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, http.MethodPost)
testHeader(t, r, "Content-Type", "application/x-www-form-urlencoded")
testFormValues(t, r, values{
"hub.mode": "unsubscribe",
"hub.topic": "https://github.com/o/r/events/push",
"hub.callback": "http://postbin.org/123",
"hub.secret": "test secret",
})
})

ctx := context.Background()
_, err := client.Repositories.Unsubscribe(
ctx,
"o",
"r",
"push",
"http://postbin.org/123",
[]byte("test secret"),
)
if err != nil {
t.Errorf("Repositories.Unsubscribe returned error: %v", err)
}

testNewRequestAndDoFailure(t, "Unsubscribe", client, func() (*Response, error) {
return client.Repositories.Unsubscribe(ctx, "o", "r", "push", "http://postbin.org/123", nil)
})
}

0 comments on commit e5e45da

Please sign in to comment.