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

Add hooks support for WebSub (formerly PubSubHubbub) protocol #2397

Merged
merged 3 commits into from Aug 21, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 27 additions & 0 deletions github/github.go
Expand Up @@ -424,6 +424,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
}
gmlewis marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -596,6 +596,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)
})
}