blob: 85e344c3c887307f5252fe15ab2a8aa039fc9536 [file] [log] [blame]
// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package monorail
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"golang.org/x/net/context/ctxhttp"
"google.golang.org/grpc"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry/transient"
)
// epClient implements MonorailClient by sending requests to Monorail's
// Cloud Endpoints API.
// See https://monorail-staging.appspot.com/_ah/api/explorer#p/monorail/v1/
type epClient struct {
HTTP *http.Client
// url is root of API without trailing slash,
// e.g. "https://monorail-staging.appspot.com/_ah/api/monorail/v1"
url string
}
// NewEndpointsClient creates a MonorailClient that send requests to
// Monorail Cloud Endpoints API at url.
//
// Example of url: "https://monorail-staging.appspot.com/_ah/api/monorail/v1"
//
// Methods do not implement retries.
// Use "go.chromium.org/luci/common/errors".IsTransient to check
// if an error is transient.
//
// Client methods return an error on any grpc.CallOption.
func NewEndpointsClient(client *http.Client, url string) MonorailClient {
return &epClient{HTTP: client, url: strings.TrimSuffix(url, "/")}
}
func (c *epClient) call(ctx context.Context, method, urlSuffix string, request, response interface{}) error {
client := c.HTTP
if client == nil {
client = http.DefaultClient
}
// Limit ctx deadline to timeout set in client.
if client.Timeout > 0 {
clientDeadline := clock.Now(ctx).Add(client.Timeout)
if deadline, ok := ctx.Deadline(); !ok || deadline.After(clientDeadline) {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, clientDeadline)
defer cancel()
}
}
// Convert request object to JSON.
reqBuf := &bytes.Buffer{}
if request != nil {
if err := json.NewEncoder(reqBuf).Encode(request); err != nil {
return err
}
}
// Make an HTTP request.
req, err := http.NewRequest(method, c.url+urlSuffix, reqBuf)
if err != nil {
return fmt.Errorf("could not make a request to %s: %s", req.URL, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Send the request.
logging.Debugf(ctx, "%s %s %s", method, req.URL, reqBuf.Bytes())
res, err := ctxhttp.Do(ctx, client, req)
if err != nil {
return transient.Tag.Apply(err)
}
defer res.Body.Close()
// Check HTTP status code.
if res.StatusCode != http.StatusOK {
text, _ := ioutil.ReadAll(io.LimitReader(res.Body, 1024))
err := fmt.Errorf("unexpected status %q. Response: %s", res.Status, text)
if res.StatusCode == http.StatusNotFound || res.StatusCode > 500 {
// Cloud Endpoints often flake with HTTP 404.
// Treat such responses transient errors.
err = transient.Tag.Apply(err)
}
return err
}
if response == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(response)
}
func (c *epClient) InsertIssue(ctx context.Context, req *InsertIssueRequest, options ...grpc.CallOption) (*InsertIssueResponse, error) {
if err := checkOptions(options); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
url := fmt.Sprintf("/projects/%s/issues?sendEmail=%v", req.Issue.ProjectId, req.SendEmail)
res := &InsertIssueResponse{Issue: &Issue{}}
return res, c.call(ctx, "POST", url, &req.Issue, res.Issue)
}
func (c *epClient) InsertComment(ctx context.Context, req *InsertCommentRequest, options ...grpc.CallOption) (*InsertCommentResponse, error) {
if err := checkOptions(options); err != nil {
return nil, err
}
params := url.Values{}
if req.SendEmail {
params.Set("sendEmail", "true")
}
u := fmt.Sprintf("/projects/%s/issues/%d/comments?%s", req.Issue.ProjectId, req.Issue.IssueId, params.Encode())
return &InsertCommentResponse{}, c.call(ctx, "POST", u, req.Comment, nil)
}
func (c *epClient) IssuesList(ctx context.Context, req *IssuesListRequest, options ...grpc.CallOption) (*IssuesListResponse, error) {
if err := checkOptions(options); err != nil {
return nil, err
}
args := url.Values{}
args.Set("can", strings.ToLower(req.Can.String()))
args.Set("q", req.Q)
args.Set("label", req.Label)
args.Set("maxResults", fmt.Sprintf("%d", req.MaxResults))
args.Set("owner", req.Owner)
args.Set("publishedMax", fmt.Sprintf("%d", req.PublishedMax))
args.Set("publishedMin", fmt.Sprintf("%d", req.PublishedMin))
args.Set("sort", req.Sort)
args.Set("startIndex", fmt.Sprintf("%d", req.StartIndex))
args.Set("status", req.Status)
args.Set("updatedMax", fmt.Sprintf("%d", req.UpdatedMax))
args.Set("updatedMin", fmt.Sprintf("%d", req.UpdatedMin))
url := fmt.Sprintf("/projects/%s/issues?%s", req.ProjectId, args.Encode())
res := &IssuesListResponse{}
err := c.call(ctx, "GET", url, nil, res)
if err != nil {
return nil, err
}
return res, nil
}
func (c *epClient) GetIssue(ctx context.Context, req *GetIssueRequest, options ...grpc.CallOption) (*Issue, error) {
if err := checkOptions(options); err != nil {
return nil, err
}
url := fmt.Sprintf("/projects/%s/issues/%d", req.Issue.ProjectId, req.Issue.IssueId)
res := &Issue{}
err := c.call(ctx, "GET", url, nil, res)
if err != nil {
return nil, err
}
return res, nil
}
func (c *epClient) ListComments(ctx context.Context, req *ListCommentsRequest, options ...grpc.CallOption) (*ListCommentsResponse, error) {
if err := checkOptions(options); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
args := url.Values{}
if req.MaxResults > 0 {
args.Set("maxResults", fmt.Sprintf("%d", req.MaxResults))
}
args.Set("startIndex", fmt.Sprintf("%d", req.StartIndex))
u := fmt.Sprintf("/projects/%s/issues/%d/comments?%s", req.Issue.ProjectId, req.Issue.IssueId, args.Encode())
res := &ListCommentsResponse{}
err := c.call(ctx, "GET", u, nil, res)
if err != nil {
return nil, err
}
return res, nil
}
func checkOptions(options []grpc.CallOption) error {
if len(options) > 0 {
return errGrpcOptions
}
return nil
}