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 }