go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/api/gitiles/rest.go (about)

     1  // Copyright 2018 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package gitiles
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/base64"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"net/url"
    26  	"sort"
    27  	"strconv"
    28  	"strings"
    29  
    30  	"golang.org/x/net/context/ctxhttp"
    31  	"google.golang.org/grpc"
    32  	"google.golang.org/grpc/codes"
    33  	"google.golang.org/grpc/status"
    34  
    35  	"go.chromium.org/luci/common/errors"
    36  	"go.chromium.org/luci/common/logging"
    37  	"go.chromium.org/luci/common/proto/git"
    38  	"go.chromium.org/luci/common/proto/gitiles"
    39  )
    40  
    41  // This file implements gitiles proto service client
    42  // on top of Gitiles REST API.
    43  
    44  // NewRESTClient creates a new Gitiles client based on Gitiles's REST API.
    45  //
    46  // The host must be a full Gitiles host, e.g. "chromium.googlesource.com".
    47  //
    48  // If auth is true, indicates that the given HTTP client sends authenticated
    49  // requests. If so, the requests to Gitiles will include "/a/" URL path
    50  // prefix.
    51  //
    52  // RPC methods of the returned client return an error if a grpc.CallOption is
    53  // passed.
    54  func NewRESTClient(httpClient *http.Client, host string, auth bool) (gitiles.GitilesClient, error) {
    55  	if strings.Contains(host, "/") {
    56  		return nil, errors.Reason("invalid host %q", host).Err()
    57  	}
    58  	baseURL := "https://" + host
    59  	if auth {
    60  		baseURL += "/a"
    61  	}
    62  	return &client{Client: httpClient, BaseURL: baseURL}, nil
    63  }
    64  
    65  // Implementation.
    66  
    67  var jsonPrefix = []byte(")]}'")
    68  
    69  // client implements gitiles.GitilesClient.
    70  type client struct {
    71  	Client *http.Client
    72  	// BaseURL is the base URL for all API requests,
    73  	// for example "https://chromium.googlesource.com/a".
    74  	BaseURL string
    75  }
    76  
    77  func (c *client) Log(ctx context.Context, req *gitiles.LogRequest, opts ...grpc.CallOption) (*gitiles.LogResponse, error) {
    78  	if err := checkArgs(opts, req); err != nil {
    79  		return nil, err
    80  	}
    81  
    82  	params := url.Values{}
    83  	if req.PageSize > 0 {
    84  		params.Set("n", strconv.FormatInt(int64(req.PageSize), 10))
    85  	}
    86  	if req.TreeDiff {
    87  		params.Set("name-status", "1")
    88  	}
    89  	if req.PageToken != "" {
    90  		params.Set("s", req.PageToken)
    91  	}
    92  
    93  	ref := req.Committish
    94  	if req.ExcludeAncestorsOf != "" {
    95  		ref = fmt.Sprintf("%s..%s", req.ExcludeAncestorsOf, req.Committish)
    96  	}
    97  	path := fmt.Sprintf("/%s/+log/%s", url.PathEscape(req.Project), url.PathEscape(ref))
    98  	if req.Path != "" {
    99  		path = fmt.Sprintf("%s/%s", path, req.Path)
   100  	}
   101  	var resp struct {
   102  		Log  []commit `json:"log"`
   103  		Next string   `json:"next"`
   104  	}
   105  	if err := c.get(ctx, path, params, &resp); err != nil {
   106  		return nil, err
   107  	}
   108  
   109  	ret := &gitiles.LogResponse{
   110  		Log:           make([]*git.Commit, len(resp.Log)),
   111  		NextPageToken: resp.Next,
   112  	}
   113  	for i, c := range resp.Log {
   114  		var err error
   115  		ret.Log[i], err = c.Proto()
   116  		if err != nil {
   117  			return nil, status.Errorf(codes.Internal, "could not parse commit %#v: %s", c, err)
   118  		}
   119  	}
   120  	return ret, nil
   121  }
   122  
   123  func (c *client) Refs(ctx context.Context, req *gitiles.RefsRequest, opts ...grpc.CallOption) (*gitiles.RefsResponse, error) {
   124  	if err := checkArgs(opts, req); err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	refsPath := strings.TrimRight(req.RefsPath, "/")
   129  
   130  	path := fmt.Sprintf("/%s/+%s", url.PathEscape(req.Project), url.PathEscape(refsPath))
   131  
   132  	resp := map[string]struct {
   133  		Value  string `json:"value"`
   134  		Target string `json:"target"`
   135  	}{}
   136  	if err := c.get(ctx, path, nil, &resp); err != nil {
   137  		return nil, err
   138  	}
   139  
   140  	ret := &gitiles.RefsResponse{
   141  		Revisions: make(map[string]string, len(resp)),
   142  	}
   143  	for ref, v := range resp {
   144  		switch {
   145  		case v.Value == "":
   146  			// Weird case of what looks like hash with a target in at least Chromium
   147  			// repo.
   148  		case ref == "HEAD":
   149  			ret.Revisions["HEAD"] = v.Target
   150  		case refsPath != "refs":
   151  			// Gitiles omits refsPath from each ref if refsPath != "refs".
   152  			// Undo this inconsistency.
   153  			ret.Revisions[refsPath+"/"+ref] = v.Value
   154  		default:
   155  			ret.Revisions[ref] = v.Value
   156  		}
   157  	}
   158  	return ret, nil
   159  }
   160  
   161  func (c *client) DownloadFile(ctx context.Context, req *gitiles.DownloadFileRequest, opts ...grpc.CallOption) (*gitiles.DownloadFileResponse, error) {
   162  	if err := checkArgs(opts, req); err != nil {
   163  		return nil, err
   164  	}
   165  	query := make(url.Values, 1)
   166  	query.Set("format", "TEXT")
   167  	ref := strings.TrimRight(req.Committish, "/")
   168  	path := fmt.Sprintf("/%s/+/%s/%s", url.PathEscape(req.Project), url.PathEscape(ref), req.Path)
   169  	_, b, err := c.getRaw(ctx, path, query)
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  
   174  	d, err := base64.StdEncoding.DecodeString(string(b))
   175  	if err != nil {
   176  		return nil, status.Errorf(codes.Internal, "failed to decode response: %s", err)
   177  	}
   178  
   179  	return &gitiles.DownloadFileResponse{Contents: string(d)}, nil
   180  }
   181  
   182  func (c *client) DownloadDiff(ctx context.Context, req *gitiles.DownloadDiffRequest, opts ...grpc.CallOption) (*gitiles.DownloadDiffResponse, error) {
   183  	if err := checkArgs(opts, req); err != nil {
   184  		return nil, err
   185  	}
   186  	query := make(url.Values, 1)
   187  	query.Set("format", "TEXT")
   188  	var path string
   189  	if req.Base != "" {
   190  		path = fmt.Sprintf("/%s/+diff/%s..%s/%s", url.PathEscape(req.Project), url.PathEscape(req.Base), url.PathEscape(req.Committish), req.Path)
   191  	} else {
   192  		path = fmt.Sprintf("/%s/+/%s%s/%s", url.PathEscape(req.Project), url.PathEscape(req.Committish), url.PathEscape("^!"), req.Path)
   193  	}
   194  	_, b, err := c.getRaw(ctx, path, query)
   195  	if err != nil {
   196  		return nil, err
   197  	}
   198  
   199  	d, err := base64.StdEncoding.DecodeString(string(b))
   200  	if err != nil {
   201  		return nil, status.Errorf(codes.Internal, "failed to decode response: %s", err)
   202  	}
   203  
   204  	return &gitiles.DownloadDiffResponse{Contents: string(d)}, nil
   205  }
   206  
   207  var archiveExtensions = map[gitiles.ArchiveRequest_Format]string{
   208  	gitiles.ArchiveRequest_BZIP2: ".bzip2",
   209  	gitiles.ArchiveRequest_GZIP:  ".tar.gz",
   210  	gitiles.ArchiveRequest_TAR:   ".tar",
   211  	gitiles.ArchiveRequest_XZ:    ".xz",
   212  }
   213  
   214  func (c *client) Archive(ctx context.Context, req *gitiles.ArchiveRequest, opts ...grpc.CallOption) (*gitiles.ArchiveResponse, error) {
   215  	if err := checkArgs(opts, req); err != nil {
   216  		return nil, err
   217  	}
   218  	resp := &gitiles.ArchiveResponse{}
   219  
   220  	ref := strings.TrimRight(req.Ref, "/")
   221  	path := strings.TrimRight(req.Path, "/")
   222  	if path != "" {
   223  		path = fmt.Sprintf("/%s", path)
   224  	}
   225  	urlPath := fmt.Sprintf("/%s/+archive/%s%s%s", url.PathEscape(req.Project), url.PathEscape(ref), url.PathEscape(path), archiveExtensions[req.Format])
   226  	h, b, err := c.getRaw(ctx, urlPath, nil)
   227  	if err != nil {
   228  		return resp, err
   229  	}
   230  	resp.Contents = b
   231  
   232  	filenames := h["Filename"]
   233  	switch len(filenames) {
   234  	case 0:
   235  	case 1:
   236  		resp.Filename = filenames[0]
   237  	default:
   238  		return resp, status.Errorf(codes.Internal, "received too many (%d) filenames for archive", len(filenames))
   239  	}
   240  	return resp, nil
   241  }
   242  
   243  func (c *client) Projects(ctx context.Context, req *gitiles.ProjectsRequest, opts ...grpc.CallOption) (*gitiles.ProjectsResponse, error) {
   244  	var resp map[string]project
   245  
   246  	if err := c.get(ctx, "/", url.Values{}, &resp); err != nil {
   247  		return nil, err
   248  	}
   249  	ret := &gitiles.ProjectsResponse{}
   250  	for name := range resp {
   251  		ret.Projects = append(ret.Projects, name)
   252  	}
   253  	sort.Strings(ret.Projects)
   254  	return ret, nil
   255  }
   256  
   257  func (c *client) ListFiles(ctx context.Context, req *gitiles.ListFilesRequest, opts ...grpc.CallOption) (*gitiles.ListFilesResponse, error) {
   258  	if err := checkArgs(opts, req); err != nil {
   259  		return nil, err
   260  	}
   261  	path := fmt.Sprintf("/%s/+show/%s/%s", url.PathEscape(req.Project), url.PathEscape(req.Committish), req.Path)
   262  	type file struct {
   263  		Mode uint32 `json:"mode"`
   264  		ID   string `json:"id"`
   265  		Name string `json:"name"`
   266  		Type string `json:"type"`
   267  	}
   268  	var data struct {
   269  		Files []file `json:"entries"`
   270  	}
   271  	err := c.get(ctx, path, nil, &data)
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  	resp := &gitiles.ListFilesResponse{
   276  		Files: make([]*git.File, len(data.Files)),
   277  	}
   278  
   279  	for i, f := range data.Files {
   280  		resp.Files[i] = &git.File{
   281  			Mode: f.Mode,
   282  			Id:   f.ID,
   283  			Path: f.Name,
   284  			Type: git.File_Type(git.File_Type_value[strings.ToUpper(f.Type)]),
   285  		}
   286  	}
   287  
   288  	return resp, nil
   289  }
   290  
   291  func (c *client) get(ctx context.Context, urlPath string, query url.Values, dest any) error {
   292  	if query == nil {
   293  		query = make(url.Values, 1)
   294  	}
   295  	query.Set("format", "JSON")
   296  
   297  	_, body, err := c.getRaw(ctx, urlPath, query)
   298  	if err != nil {
   299  		return err
   300  	}
   301  	if err = json.Unmarshal(body, dest); err != nil {
   302  		return status.Errorf(codes.Internal, "could not deserialize response: %s", err)
   303  	}
   304  	return nil
   305  }
   306  
   307  // getRaw makes a raw HTTP get request and returns the header and body returned.
   308  //
   309  // In case of errors, getRaw translates the generic HTTP errors to grpc errors.
   310  func (c *client) getRaw(ctx context.Context, urlPath string, query url.Values) (http.Header, []byte, error) {
   311  	u := fmt.Sprintf("%s/%s", strings.TrimSuffix(c.BaseURL, "/"), strings.TrimPrefix(urlPath, "/"))
   312  	if query != nil {
   313  		u = fmt.Sprintf("%s?%s", u, query.Encode())
   314  	}
   315  	r, err := ctxhttp.Get(ctx, c.Client, u)
   316  	if err != nil {
   317  		return http.Header{}, nil, status.Errorf(codes.Unknown, "%s", err)
   318  	}
   319  	defer r.Body.Close()
   320  	body, err := io.ReadAll(r.Body)
   321  	if err != nil {
   322  		return r.Header, nil, status.Errorf(codes.Internal, "could not read response body: %s", err)
   323  	}
   324  
   325  	switch r.StatusCode {
   326  	case http.StatusOK:
   327  		return r.Header, bytes.TrimPrefix(body, jsonPrefix), nil
   328  
   329  	case http.StatusBadRequest:
   330  		return r.Header, body, status.Errorf(codes.InvalidArgument, "%s", string(body))
   331  
   332  	case http.StatusForbidden:
   333  		return r.Header, body, status.Errorf(codes.PermissionDenied, "permission denied")
   334  
   335  	case http.StatusNotFound:
   336  		return r.Header, body, status.Errorf(codes.NotFound, "not found")
   337  
   338  	case http.StatusTooManyRequests:
   339  		logging.Errorf(ctx, "Gitiles quota error.\nResponse headers: %v\nResponse body: %s", r.Header, body)
   340  		return r.Header, body, status.Errorf(codes.ResourceExhausted, "insufficient Gitiles quota")
   341  
   342  	case http.StatusBadGateway:
   343  		return r.Header, body, status.Errorf(codes.Unavailable, "bad gateway")
   344  
   345  	case http.StatusServiceUnavailable:
   346  		return r.Header, body, status.Errorf(codes.Unavailable, "service unavailable")
   347  
   348  	default:
   349  		logging.Errorf(ctx, "Gitiles: unexpected HTTP %d response.\nResponse headers: %v\nResponse body: %s", r.StatusCode, r.Header, body)
   350  		return r.Header, body, status.Errorf(codes.Internal, "unexpected HTTP %d from Gitiles", r.StatusCode)
   351  	}
   352  }
   353  
   354  type validatable interface {
   355  	Validate() error
   356  }
   357  
   358  func checkArgs(opts []grpc.CallOption, req validatable) error {
   359  	if len(opts) > 0 {
   360  		return status.Errorf(codes.Internal, "gitiles.client does not support grpc options")
   361  	}
   362  	if err := req.Validate(); err != nil {
   363  		return status.Errorf(codes.InvalidArgument, "request is invalid: %s", err)
   364  	}
   365  	return nil
   366  }