diff --git a/README.md b/README.md index 0f99698d17..9406f76e08 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,40 @@ Preview functionality may take the form of entire methods or simply additional data returned from an otherwise non-preview method. Refer to the GitHub API documentation for details on preview functionality. +### Calendar Versioning ### + +As of 2022-11-28, GitHub [has announced](https://github.blog/2022-11-28-to-infinity-and-beyond-enabling-the-future-of-githubs-rest-api-with-api-versioning/) +that they are starting to version their v3 API based on "calendar-versioning". + +In practice, our goal is to make per-method version overrides (at +least in the core library) rare and temporary. + +Our understanding of the GitHub docs is that they will be revving the +entire API to each new date-based version, even if only a few methods +have breaking changes. Other methods will accept the new version with +their existing functionality. So when a new date-based version of the +GitHub API is released, we (the repo maintainers) plan to: + +* update each method that had breaking changes, overriding their + per-method API version header. This may happen in one or multiple + commits and PRs, and is all done in the main branch. + +* once all of the methods with breaking changes have been updated, + have a final commit that bumps the default API version, and remove + all of the per-method overrides. That would now get a major version + bump when the next go-github release is made. + +### Version Compatibility Table ### + +The following table identifies which version of the GitHub API is +supported by this (and past) versions of this repo (go-github). +Versions prior to 48.2.0 are not listed. + +| go-github Version | GitHub v3 API Version | +| ----------------- | --------------------- | +| 48.2.0 | 2022-11-28 | + + ## License ## This library is distributed under the BSD-style license found in the [LICENSE](./LICENSE) diff --git a/github/github.go b/github/github.go index 38a5bcd29b..3599a88d09 100644 --- a/github/github.go +++ b/github/github.go @@ -27,12 +27,14 @@ import ( ) const ( - Version = "v48.0.0" + Version = "v48.2.0" - defaultBaseURL = "https://api.github.com/" - defaultUserAgent = "go-github" + "/" + Version - uploadBaseURL = "https://uploads.github.com/" + defaultAPIVersion = "2022-11-28" + defaultBaseURL = "https://api.github.com/" + defaultUserAgent = "go-github" + "/" + Version + uploadBaseURL = "https://uploads.github.com/" + headerAPIVersion = "X-GitHub-Api-Version" headerRateLimit = "X-RateLimit-Limit" headerRateRemaining = "X-RateLimit-Remaining" headerRateReset = "X-RateLimit-Reset" @@ -392,12 +394,24 @@ func NewEnterpriseClient(baseURL, uploadURL string, httpClient *http.Client) (*C return c, nil } +// RequestOption represents an option that can modify an http.Request. +type RequestOption func(req *http.Request) + +// WithVersion overrides the GitHub v3 API version for this individual request. +// For more information, see: +// https://github.blog/2022-11-28-to-infinity-and-beyond-enabling-the-future-of-githubs-rest-api-with-api-versioning/ +func WithVersion(version string) RequestOption { + return func(req *http.Request) { + req.Header.Set(headerAPIVersion, version) + } +} + // NewRequest 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. If // specified, the value pointed to by body is JSON encoded and included as the // request body. -func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { +func (c *Client) NewRequest(method, urlStr string, body interface{}, opts ...RequestOption) (*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) } @@ -430,6 +444,12 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ if c.UserAgent != "" { req.Header.Set("User-Agent", c.UserAgent) } + req.Header.Set(headerAPIVersion, defaultAPIVersion) + + for _, opt := range opts { + opt(req) + } + return req, nil } @@ -437,7 +457,7 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ // 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) { +func (c *Client) NewFormRequest(urlStr string, body io.Reader, opts ...RequestOption) (*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) } @@ -457,13 +477,19 @@ func (c *Client) NewFormRequest(urlStr string, body io.Reader) (*http.Request, e if c.UserAgent != "" { req.Header.Set("User-Agent", c.UserAgent) } + req.Header.Set(headerAPIVersion, defaultAPIVersion) + + for _, opt := range opts { + opt(req) + } + 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. -func (c *Client) NewUploadRequest(urlStr string, reader io.Reader, size int64, mediaType string) (*http.Request, error) { +func (c *Client) NewUploadRequest(urlStr string, reader io.Reader, size int64, mediaType string, opts ...RequestOption) (*http.Request, error) { if !strings.HasSuffix(c.UploadURL.Path, "/") { return nil, fmt.Errorf("UploadURL must have a trailing slash, but %q does not", c.UploadURL) } @@ -485,6 +511,12 @@ func (c *Client) NewUploadRequest(urlStr string, reader io.Reader, size int64, m req.Header.Set("Content-Type", mediaType) req.Header.Set("Accept", mediaTypeV3) req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set(headerAPIVersion, defaultAPIVersion) + + for _, opt := range opts { + opt(req) + } + return req, nil } @@ -1358,8 +1390,8 @@ func formatRateReset(d time.Duration) string { // When using roundTripWithOptionalFollowRedirect, note that it // is the responsibility of the caller to close the response body. -func (c *Client) roundTripWithOptionalFollowRedirect(ctx context.Context, u string, followRedirects bool) (*http.Response, error) { - req, err := c.NewRequest("GET", u, nil) +func (c *Client) roundTripWithOptionalFollowRedirect(ctx context.Context, u string, followRedirects bool, opts ...RequestOption) (*http.Response, error) { + req, err := c.NewRequest("GET", u, nil, opts...) if err != nil { return nil, err } @@ -1380,7 +1412,7 @@ func (c *Client) roundTripWithOptionalFollowRedirect(ctx context.Context, u stri if followRedirects && resp.StatusCode == http.StatusMovedPermanently { resp.Body.Close() u = resp.Header.Get("Location") - resp, err = c.roundTripWithOptionalFollowRedirect(ctx, u, false) + resp, err = c.roundTripWithOptionalFollowRedirect(ctx, u, false, opts...) } return resp, err } diff --git a/github/github_test.go b/github/github_test.go index c4ceb89bb5..53809399fa 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -517,6 +517,17 @@ func TestNewRequest(t *testing.T) { if !strings.Contains(userAgent, Version) { t.Errorf("NewRequest() User-Agent should contain %v, found %v", Version, userAgent) } + + apiVersion := req.Header.Get(headerAPIVersion) + if got, want := apiVersion, defaultAPIVersion; got != want { + t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) + } + + req, _ = c.NewRequest("GET", inURL, inBody, WithVersion("2022-11-29")) + apiVersion = req.Header.Get(headerAPIVersion) + if got, want := apiVersion, "2022-11-29"; got != want { + t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) + } } func TestNewRequest_invalidJSON(t *testing.T) { @@ -626,6 +637,17 @@ func TestNewFormRequest(t *testing.T) { if got, want := req.Header.Get("User-Agent"), c.UserAgent; got != want { t.Errorf("NewFormRequest() User-Agent is %v, want %v", got, want) } + + apiVersion := req.Header.Get(headerAPIVersion) + if got, want := apiVersion, defaultAPIVersion; got != want { + t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) + } + + req, _ = c.NewFormRequest(inURL, inBody, WithVersion("2022-11-29")) + apiVersion = req.Header.Get(headerAPIVersion) + if got, want := apiVersion, "2022-11-29"; got != want { + t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) + } } func TestNewFormRequest_badURL(t *testing.T) { @@ -680,6 +702,22 @@ func TestNewFormRequest_errorForNoTrailingSlash(t *testing.T) { } } +func TestNewUploadRequest_WithVersion(t *testing.T) { + c := NewClient(nil) + req, _ := c.NewUploadRequest("https://example.com/", nil, 0, "") + + apiVersion := req.Header.Get(headerAPIVersion) + if got, want := apiVersion, defaultAPIVersion; got != want { + t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) + } + + req, _ = c.NewUploadRequest("https://example.com/", nil, 0, "", WithVersion("2022-11-29")) + apiVersion = req.Header.Get(headerAPIVersion) + if got, want := apiVersion, "2022-11-29"; got != want { + t.Errorf("NewRequest() %v header is %v, want %v", headerAPIVersion, got, want) + } +} + func TestNewUploadRequest_badURL(t *testing.T) { c := NewClient(nil) _, err := c.NewUploadRequest(":", nil, 0, "")