blob: 5dfa90492be69b8afd8cf92f8958da161dd8ae97 [file] [log] [blame]
package gerrit
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"regexp"
"strings"
"github.com/google/go-querystring/query"
)
// TODO Try to reduce the code duplications of a std API req
// Maybe with http://play.golang.org/p/j-667shCCB
// and https://groups.google.com/forum/#!topic/golang-nuts/D-gIr24k5uY
// A Client manages communication with the Gerrit API.
type Client struct {
// client is the HTTP client used to communicate with the API.
client *http.Client
// baseURL is the base URL of the Gerrit instance for API requests.
// It must have a trailing slash.
baseURL *url.URL
// Gerrit service for authentication.
Authentication *AuthenticationService
// Services used for talking to different parts of the standard Gerrit API.
Access *AccessService
Accounts *AccountsService
Changes *ChangesService
Config *ConfigService
Groups *GroupsService
Plugins *PluginsService
Projects *ProjectsService
// Additional services used for talking to non-standard Gerrit APIs.
EventsLog *EventsLogService
}
// Response is a Gerrit API response.
// This wraps the standard http.Response returned from Gerrit.
type Response struct {
*http.Response
}
var (
// ErrNoInstanceGiven is returned by NewClient in the event the
// gerritURL argument was blank.
ErrNoInstanceGiven = errors.New("no Gerrit instance given")
// ErrUserProvidedWithoutPassword is returned by NewClient
// if a user name is provided without a password.
ErrUserProvidedWithoutPassword = errors.New("a username was provided without a password")
// ErrAuthenticationFailed is returned by NewClient in the event the provided
// credentials didn't allow us to query account information using digest, basic or cookie
// auth.
ErrAuthenticationFailed = errors.New("failed to authenticate using the provided credentials")
// ReParseURL is used to parse the url provided to NewClient(). This
// regular expression contains five groups which capture the scheme,
// username, password, hostname and port. If we parse the url with this
// regular expression
ReParseURL = regexp.MustCompile(`^(http|https)://(.+):(.+)@(.+):(\d+)(.*)$`)
)
// NewClient returns a new Gerrit API client. gerritURL specifies the
// HTTP endpoint of the Gerrit instance. For example, "http://localhost:8080/".
// If gerritURL does not have a trailing slash, one is added automatically.
// If a nil httpClient is provided, http.DefaultClient will be used.
//
// The url may contain credentials, http://admin:secret@localhost:8081/ for
// example. These credentials may either be a user name and password or
// name and value as in the case of cookie based authentication. If the url contains
// credentials then this function will attempt to validate the credentials before
// returning the client. ErrAuthenticationFailed will be returned if the credentials
// cannot be validated. The process of validating the credentials is relatively simple and
// only requires that the provided user have permission to GET /a/accounts/self.
func NewClient(ctx context.Context, gerritURL string, httpClient *http.Client) (*Client, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}
endpoint := gerritURL
if endpoint == "" {
return nil, ErrNoInstanceGiven
}
hasAuth := false
username := ""
password := ""
// Depending on the contents of the username and password the default
// url.Parse may not work. The below is an example URL that
// would end up being parsed incorrectly with url.Parse:
// http://admin:ZOSOKjgV/kgEkN0bzPJp+oGeJLqpXykqWFJpon/Ckg@localhost:38607
// So instead of depending on url.Parse we'll try using a regular expression
// first to match a specific pattern. If that ends up working we modify
// the incoming endpoint to remove the username and password so the rest
// of this function will run as expected.
submatches := ReParseURL.FindAllStringSubmatch(endpoint, -1)
if len(submatches) > 0 && len(submatches[0]) > 5 {
submatch := submatches[0]
username = submatch[2]
password = submatch[3]
endpoint = fmt.Sprintf(
"%s://%s:%s%s", submatch[1], submatch[4], submatch[5], submatch[6])
hasAuth = true
}
baseURL, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
if !strings.HasSuffix(baseURL.Path, "/") {
baseURL.Path += "/"
}
// Note, if we retrieved the URL and password using the regular
// expression above then the below code will do nothing.
if baseURL.User != nil {
username = baseURL.User.Username()
parsedPassword, haspassword := baseURL.User.Password()
// Catches cases like http://user@localhost:8081/ where no password
// was at all. If a blank password is required
if !haspassword {
return nil, ErrUserProvidedWithoutPassword
}
password = parsedPassword
// Reconstruct the url but without the username and password.
baseURL, err = url.Parse(
fmt.Sprintf("%s://%s%s", baseURL.Scheme, baseURL.Host, baseURL.RequestURI()))
if err != nil {
return nil, err
}
hasAuth = true
}
c := &Client{
client: httpClient,
baseURL: baseURL,
}
c.Authentication = &AuthenticationService{client: c}
c.Access = &AccessService{client: c}
c.Accounts = &AccountsService{client: c}
c.Changes = &ChangesService{client: c}
c.Config = &ConfigService{client: c}
c.Groups = &GroupsService{client: c}
c.Plugins = &PluginsService{client: c}
c.Projects = &ProjectsService{client: c}
c.EventsLog = &EventsLogService{client: c}
if hasAuth {
// Digest auth (first since that's the default auth type)
c.Authentication.SetDigestAuth(username, password)
if success, err := checkAuth(ctx, c); success || err != nil {
return c, err
}
// Basic auth
c.Authentication.SetBasicAuth(username, password)
if success, err := checkAuth(ctx, c); success || err != nil {
return c, err
}
// Cookie auth
c.Authentication.SetCookieAuth(username, password)
if success, err := checkAuth(ctx, c); success || err != nil {
return c, err
}
// Reset auth in case the consumer needs to do something special.
c.Authentication.ResetAuth()
return c, ErrAuthenticationFailed
}
return c, nil
}
// checkAuth is used by NewClient to check if the current credentials are
// valid. If the response is 401 Unauthorized then the error will be discarded.
func checkAuth(ctx context.Context, client *Client) (bool, error) {
_, response, err := client.Accounts.GetAccount(ctx, "self")
switch err {
case ErrWWWAuthenticateHeaderMissing:
return false, nil
case ErrWWWAuthenticateHeaderNotDigest:
return false, nil
default:
// Response could be nil if the connection outright failed
// or some other error occurred before we got a response.
if response == nil && err != nil {
return false, err
}
if err != nil && response.StatusCode == http.StatusUnauthorized {
err = nil
}
return response.StatusCode == http.StatusOK, err
}
}
// 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(ctx context.Context, method, urlStr string, body interface{}) (*http.Request, error) {
// Build URL for request
u, err := c.buildURLForRequest(urlStr)
if err != nil {
return nil, err
}
var buf io.ReadWriter
if body != nil {
buf = new(bytes.Buffer)
err = json.NewEncoder(buf).Encode(body)
if err != nil {
return nil, err
}
}
req, err := http.NewRequestWithContext(ctx, method, u, buf)
if err != nil {
return nil, err
}
// Apply Authentication
if err := c.addAuthentication(ctx, req); err != nil {
return nil, err
}
// Request compact JSON
// See https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
req.Header.Add("Accept", "application/json")
// No need to send the content type if there is no content
if body != nil {
req.Header.Add("Content-Type", "application/json")
}
// TODO: Add gzip encoding
// Accept-Encoding request header is set to gzip
// See https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
return req, nil
}
// NewRawPutRequest creates a raw PUT request and makes no attempt to encode
// or marshal the body. Just passes it straight through.
func (c *Client) NewRawPutRequest(ctx context.Context, urlStr string, body string) (*http.Request, error) {
// Build URL for request
u, err := c.buildURLForRequest(urlStr)
if err != nil {
return nil, err
}
buf := bytes.NewBuffer([]byte(body))
req, err := http.NewRequestWithContext(ctx, "PUT", u, buf)
if err != nil {
return nil, err
}
// Apply Authentication
if err := c.addAuthentication(ctx, req); err != nil {
return nil, err
}
// Request compact JSON
// See https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// TODO: Add gzip encoding
// Accept-Encoding request header is set to gzip
// See https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
return req, nil
}
// Call is a combine function for Client.NewRequest and Client.Do.
//
// Most API methods are quite the same.
// Get the URL, apply options, make a request, and get the response.
// Without adding special headers or something.
// To avoid a big amount of code duplication you can Client.Call.
//
// method is the HTTP method you want to call.
// u is the URL you want to call.
// body is the HTTP body.
// v is the HTTP response.
//
// For more information read https://github.com/google/go-github/issues/234
func (c *Client) Call(ctx context.Context, method, u string, body interface{}, v interface{}) (*Response, error) {
req, err := c.NewRequest(ctx, method, u, body)
if err != nil {
return nil, err
}
resp, err := c.Do(req, v)
if err != nil {
return resp, err
}
return resp, err
}
// buildURLForRequest will build the URL (as string) that will be called.
// We need such a utility method, because the URL.Path needs to be escaped (partly).
//
// E.g. if a project is called via "projects/%s" and the project is named "plugin/delete-project"
// there has to be "projects/plugin%25Fdelete-project" instead of "projects/plugin/delete-project".
// The second url will return nothing.
func (c *Client) buildURLForRequest(urlStr string) (string, error) {
// If there is a "/" at the start, remove it.
// TODO: It can be arranged for all callers of buildURLForRequest to never have a "/" prefix,
// which can be ensured via tests. This is how it's done in go-github.
// Then, this run-time check becomes unnecessary and can be removed.
urlStr = strings.TrimPrefix(urlStr, "/")
// If we are authenticated, let's apply the "a/" prefix,
// but only if it has not already been applied.
if c.Authentication.HasAuth() && !strings.HasPrefix(urlStr, "a/") {
urlStr = "a/" + urlStr
}
rel, err := url.Parse(urlStr)
if err != nil {
return "", err
}
return c.baseURL.String() + rel.String(), nil
}
// Do sends an API request and returns the API response.
// The API response is JSON decoded and stored in the value pointed to by v,
// or returned as an error if an API error has occurred.
// If v implements the io.Writer interface, the raw response body will be written to v,
// without attempting to first decode it.
func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) {
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
// Wrap response
response := &Response{Response: resp}
err = CheckResponse(resp)
if err != nil {
// even though there was an error, we still return the response
// in case the caller wants to inspect it further
return response, err
}
if v != nil {
defer resp.Body.Close() // nolint: errcheck
if w, ok := v.(io.Writer); ok {
if _, err := io.Copy(w, resp.Body); err != nil { // nolint: vetshadow
return nil, err
}
} else {
var body []byte
body, err = io.ReadAll(resp.Body)
if err != nil {
// even though there was an error, we still return the response
// in case the caller wants to inspect it further
return response, err
}
body = RemoveMagicPrefixLine(body)
err = json.Unmarshal(body, v)
}
}
return response, err
}
func (c *Client) addAuthentication(ctx context.Context, req *http.Request) error {
// Apply HTTP Basic Authentication
if c.Authentication.HasBasicAuth() {
req.SetBasicAuth(c.Authentication.name, c.Authentication.secret)
return nil
}
// Apply HTTP Cookie
if c.Authentication.HasCookieAuth() {
req.AddCookie(&http.Cookie{
Name: c.Authentication.name,
Value: c.Authentication.secret,
})
return nil
}
// Apply Digest Authentication. If we're using digest based
// authentication we need to make a request, process the
// WWW-Authenticate header, then set the Authorization header on the
// incoming request. We do not need to send a body along because
// the request itself should fail first.
if c.Authentication.HasDigestAuth() {
uri, err := c.buildURLForRequest(req.URL.RequestURI())
if err != nil {
return err
}
// WARNING: Don't use c.NewRequest here unless you like
// infinite recursion.
digestRequest, err := http.NewRequestWithContext(ctx, req.Method, uri, nil)
digestRequest.Header.Set("Accept", "*/*")
digestRequest.Header.Set("Content-Type", "application/json")
if err != nil {
return err
}
response, err := c.client.Do(digestRequest)
if err != nil {
return err
}
// When the function exits discard the rest of the
// body and close it. This should cause go to
// reuse the connection.
defer io.Copy(io.Discard, response.Body) // nolint: errcheck
defer response.Body.Close() // nolint: errcheck
if response.StatusCode == http.StatusUnauthorized {
authorization, err := c.Authentication.digestAuthHeader(response)
if err != nil {
return err
}
req.Header.Set("Authorization", authorization)
}
}
return nil
}
// DeleteRequest sends an DELETE API Request to urlStr with optional body.
// It is a shorthand combination for Client.NewRequest with Client.Do.
//
// 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) DeleteRequest(ctx context.Context, urlStr string, body interface{}) (*Response, error) {
req, err := c.NewRequest(ctx, "DELETE", urlStr, body)
if err != nil {
return nil, err
}
return c.Do(req, nil)
}
// BaseURL returns the client's Gerrit instance HTTP endpoint.
func (c *Client) BaseURL() url.URL {
return *c.baseURL
}
// RemoveMagicPrefixLine removes the "magic prefix line" of Gerris JSON
// response if present. The JSON response body starts with a magic prefix line
// that must be stripped before feeding the rest of the response body to a JSON
// parser. The reason for this is to prevent against Cross Site Script
// Inclusion (XSSI) attacks. By default all standard Gerrit APIs include this
// prefix line though some plugins may not.
//
// Gerrit API docs: https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
func RemoveMagicPrefixLine(body []byte) []byte {
if bytes.HasPrefix(body, magicPrefix) {
return body[5:]
}
return body
}
var magicPrefix = []byte(")]}'\n")
// CheckResponse checks the API response for errors, and returns them if present.
// A response is considered an error if it has a status code outside the 200 range.
// API error responses are expected to have no response body.
//
// Gerrit API docs: https://gerrit-review.googlesource.com/Documentation/rest-api.html#response-codes
func CheckResponse(r *http.Response) error {
if c := r.StatusCode; 200 <= c && c <= 299 {
return nil
}
// Some calls require an authentification
// In such cases errors like:
// API call to https://review.typo3.org/accounts/self failed: 403 Forbidden
// will be thrown.
err := fmt.Errorf("API call to %s failed: %s", r.Request.URL.String(), r.Status)
return err
}
// queryParameterReplacements are values in a url, specifically the query
// portion of the url, which should not be escaped before being sent to
// Gerrit. Note, Gerrit itself does not escape these values when using the
// search box so we shouldn't escape them either.
var queryParameterReplacements = map[string]string{
"+": "GOGERRIT_URL_PLACEHOLDER_PLUS",
":": "GOGERRIT_URL_PLACEHOLDER_COLON"}
// addOptions adds the parameters in opt as URL query parameters to s.
// opt must be a struct whose fields may contain "url" tags.
func addOptions(s string, opt interface{}) (string, error) {
v := reflect.ValueOf(opt)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return s, err
}
qs, err := query.Values(opt)
if err != nil {
return s, err
}
// If the url contained one or more query parameters (q) then we need
// to do some escaping on these values before Encode() is called. By
// doing so we're ensuring that : and + don't get encoded which means
// they'll be passed along to Gerrit as raw ascii. Without this Gerrit
// could return 400 Bad Request depending on the query parameters. For
// more complete information see this issue on GitHub:
// https://github.com/andygrunwald/go-gerrit/issues/18
_, hasQuery := qs["q"]
if hasQuery {
values := []string{}
for _, value := range qs["q"] {
for key, replacement := range queryParameterReplacements {
value = strings.Replace(value, key, replacement, -1)
}
values = append(values, value)
}
qs.Del("q")
for _, value := range values {
qs.Add("q", value)
}
}
encoded := qs.Encode()
if hasQuery {
for key, replacement := range queryParameterReplacements {
encoded = strings.Replace(encoded, replacement, key, -1)
}
}
u.RawQuery = encoded
return u.String(), nil
}
// getStringResponseWithoutOptions retrieved a single string Response for a GET request
func getStringResponseWithoutOptions(ctx context.Context, client *Client, u string) (string, *Response, error) {
v := new(string)
resp, err := client.Call(ctx, "GET", u, nil, v)
return *v, resp, err
}