diff --git a/github/github.go b/github/github.go index 08b7db8e55..8ee5cabd91 100644 --- a/github/github.go +++ b/github/github.go @@ -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 + } + + 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. diff --git a/github/github_test.go b/github/github_test.go index 0a28ced93c..7b0ddc1dd2 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -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, "") diff --git a/github/repos_hooks.go b/github/repos_hooks.go index 4e738cfe8c..44eac8a401 100644 --- a/github/repos_hooks.go +++ b/github/repos_hooks.go @@ -8,6 +8,9 @@ package github import ( "context" "fmt" + "net/http" + "net/url" + "strings" "time" ) @@ -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 +} diff --git a/github/repos_hooks_test.go b/github/repos_hooks_test.go index 50dad9c236..3c6ee6fa16 100644 --- a/github/repos_hooks_test.go +++ b/github/repos_hooks_test.go @@ -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) + }) +}