client.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /*
  2. * This Source Code Form is subject to the terms of the Mozilla Public
  3. * License, v. 2.0. If a copy of the MPL was not distributed with this
  4. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
  5. */
  6. package main
  7. import (
  8. "context"
  9. "crypto/tls"
  10. "encoding/json"
  11. "fmt"
  12. "io"
  13. "log"
  14. "net/http"
  15. "net/url"
  16. "strconv"
  17. "strings"
  18. "time"
  19. dp "idio.link/go/depager/v2"
  20. )
  21. // Per https://docs.gitlab.com/ee/api/rest/#offset-based-pagination
  22. const maxPageSize = 100
  23. func NewGitLabClient(
  24. ctx context.Context,
  25. accessToken string,
  26. ) *GitLabClient {
  27. t := &http.Transport{
  28. TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
  29. TLSHandshakeTimeout: 15 * time.Second,
  30. IdleConnTimeout: 30 * time.Minute,
  31. ResponseHeaderTimeout: 180 * time.Second,
  32. ExpectContinueTimeout: 10 * time.Second,
  33. }
  34. c := &http.Client{
  35. Transport: t,
  36. Timeout: 30 * time.Minute,
  37. }
  38. baseURI, _ := url.Parse("https://gitlab.com/api/v4/")
  39. return &GitLabClient{
  40. baseURI: baseURI,
  41. ctx: ctx,
  42. httpClient: c,
  43. retries: 3,
  44. retryWait: 10 * time.Second,
  45. accessToken: accessToken,
  46. }
  47. }
  48. type GitLabClient struct {
  49. ctx context.Context
  50. httpClient *http.Client
  51. baseURI *url.URL
  52. blockReqsBefore time.Time
  53. retries int
  54. retryWait time.Duration
  55. accessToken string
  56. }
  57. func (c *GitLabClient) expandResource(
  58. resource *url.URL,
  59. ) *url.URL {
  60. ex, err :=
  61. url.JoinPath(c.baseURI.String(), resource.EscapedPath())
  62. if err != nil {
  63. panic(fmt.Sprintf("BUG: gitlab client: expand resource: failed to join path: '%s' + '%s': %v", c.baseURI, resource.RequestURI(), err))
  64. }
  65. ex += "?" + resource.RawQuery
  66. next, err := url.Parse(ex)
  67. if err != nil {
  68. panic(fmt.Sprintf("BUG: gitlab client: expand resource: failed to parse expanded resource '%s': %v", ex, err))
  69. }
  70. return next
  71. }
  72. // retry when server rate limits are exceeded
  73. // See https://docs.gitlab.com/ee/api/rest/#status-codes
  74. func (c *GitLabClient) sendRequestWithRetry(
  75. req *http.Request,
  76. ) (resp *http.Response, err error) {
  77. for i := 0; i < c.retries+1; i++ {
  78. resp, err = c.httpClient.Do(req)
  79. if err != nil {
  80. err = fmt.Errorf("send request with retry: %w", err)
  81. return
  82. }
  83. if resp.StatusCode != http.StatusTooManyRequests {
  84. break
  85. }
  86. log.Printf("info: request throttled: %s '%s'", req.Method, req.RequestURI)
  87. // TODO Offer an async option in future, but this is
  88. // acceptable, for now.
  89. time.Sleep(c.retryWait / time.Millisecond)
  90. }
  91. if resp == nil {
  92. err = fmt.Errorf("send request with retry: unknown failure")
  93. return
  94. }
  95. return
  96. }
  97. func (c *GitLabClient) request(
  98. method string,
  99. resource *url.URL,
  100. body io.Reader,
  101. ) (respHead http.Header, respBody []byte, err error) {
  102. req, err := http.NewRequestWithContext(
  103. c.ctx,
  104. method,
  105. resource.String(),
  106. body,
  107. )
  108. if err != nil {
  109. err = fmt.Errorf("request %v: %w", resource, err)
  110. return
  111. }
  112. // Per https://docs.gitlab.com/ee/api/rest/#personalprojectgroup-access-tokens
  113. req.Header.Add(
  114. "Authorization",
  115. fmt.Sprintf("Bearer %s", c.accessToken),
  116. )
  117. resp, err := c.sendRequestWithRetry(req)
  118. if err != nil {
  119. err = fmt.Errorf("request '%s': %+v: %w", req.URL, req, err)
  120. return
  121. }
  122. defer resp.Body.Close()
  123. respHead = resp.Header
  124. respBody, err = io.ReadAll(resp.Body)
  125. if err != nil {
  126. err = fmt.Errorf("request %v: failed to read response body: %w", resource, err)
  127. return
  128. }
  129. err = c.ratelimitRequests(resp)
  130. if err != nil {
  131. err = fmt.Errorf("request %v: %w", resource, err)
  132. return
  133. }
  134. // Success response
  135. if 200 <= resp.StatusCode && resp.StatusCode <= 299 {
  136. return
  137. }
  138. err = fmt.Errorf("request %v: %s", resource, http.StatusText(resp.StatusCode))
  139. return
  140. }
  141. /*
  142. Throttle requests. See
  143. * https://gitlab.com/gitlab-com/runbooks/-/tree/master/docs/rate-limiting
  144. * https://docs.gitlab.com/ee/user/gitlab_com/index.html#gitlabcom-specific-rate-limits
  145. We postpone subsequent requests by at least win/rem
  146. */
  147. func (c *GitLabClient) ratelimitRequests(
  148. resp *http.Response,
  149. ) error {
  150. window := 60 * time.Second // window is not sent in headers
  151. remStr := resp.Header.Get("Rate-Limit-Remaining")
  152. remaining, err := strconv.ParseInt(remStr, 10, 64)
  153. if remStr != "" && err != nil {
  154. return fmt.Errorf("throttle requests: failed to parse header Rate-Limit-Remaining: %w", err)
  155. }
  156. if remaining != 0 && window != 0 {
  157. msDelay := int64(window/time.Millisecond) / remaining
  158. delay := time.Duration(msDelay) * time.Millisecond
  159. c.blockReqsBefore = time.Now().Add(delay)
  160. }
  161. return nil
  162. }
  163. func (c *GitLabClient) get(
  164. resource *url.URL,
  165. ) (http.Header, []byte, error) {
  166. return c.request(http.MethodGet, resource, nil)
  167. }
  168. func newSubclient[T any](
  169. c *GitLabClient,
  170. resource *url.URL,
  171. ) *Subclient[T] {
  172. expanded := c.expandResource(resource)
  173. return &Subclient[T]{
  174. GitLabClient: *c,
  175. uri: expanded,
  176. }
  177. }
  178. type Subclient[T any] struct {
  179. GitLabClient
  180. uri *url.URL
  181. }
  182. func (c *Subclient[T]) NextPage(
  183. offset uint64,
  184. ) (page dp.Page[T], cnt uint64, err error) {
  185. // TODO So slow... Need to adjust depager to buffer these
  186. // in order without waiting for each one. Keyset-based
  187. // pagination will ultimately make it impossible to do
  188. // this, but for now, we shouldn't suck this badly. Why
  189. // does everyone insist on making this horrible?
  190. aggr := make([]T, 0, 32)
  191. head, body, err := c.get(c.uri)
  192. if err != nil {
  193. err = fmt.Errorf("gitlab client: next page: %w", err)
  194. return
  195. }
  196. cnt, err = strconv.ParseUint(head.Get("x-total"), 10, 64)
  197. if err != nil {
  198. err = fmt.Errorf("gitlab client: next page: parse header 'x-total': %w", err)
  199. return
  200. }
  201. err = json.Unmarshal(body, &aggr)
  202. if err != nil {
  203. err = fmt.Errorf("gitlab client: next page: unmarshal response: %w", err)
  204. return
  205. }
  206. if next := getLinkNext(head.Get("link")); next != "" {
  207. c.uri, err = url.Parse(next)
  208. if err != nil {
  209. err = fmt.Errorf("gitlab client: next page: unable to parse next link '%s': %w", next, err)
  210. return
  211. }
  212. }
  213. page = GitLabAggregate[T](aggr)
  214. return
  215. }
  216. func getLinkNext(link string) (next string) {
  217. // this could be made faster, but doesn't seem necessary
  218. // See https://www.w3.org/wiki/LinkHeader for details.
  219. // basic format: `<meta.rdf>; rel=meta, ...`
  220. before, _, found := strings.Cut(link, `rel="next"`)
  221. if !found {
  222. return
  223. }
  224. idx := strings.LastIndex(before, "<")
  225. if idx == -1 {
  226. return
  227. }
  228. next = before[idx+1:]
  229. parts := strings.Split(next, ">")
  230. if len(parts) != 2 {
  231. return
  232. }
  233. next = parts[0]
  234. return
  235. }
  236. type GitLabAggregate[T any] []T
  237. func (a GitLabAggregate[T]) Elems() []T {
  238. return a
  239. }
  240. type GitLabProject struct {
  241. PathWithNamespace string `json:"path_with_namespace"`
  242. HTTPURLToRepo string `json:"http_url_to_repo"`
  243. WebURL string `json:"web_url"`
  244. DefaultBranch string `json:"default_branch"`
  245. Visibility string `json:"visibility"`
  246. }
  247. func FetchGitLabProjects[T *GitLabProject](
  248. c *GitLabClient,
  249. ) dp.Pager[T] {
  250. resStrFmt := "/projects?owned=true&per_page=%d"
  251. resStr := fmt.Sprintf(resStrFmt, maxPageSize)
  252. resource, err := url.Parse(resStr)
  253. if err != nil { // should only occur in case of bugs
  254. panic(err)
  255. }
  256. return dp.NewPager[T](
  257. newSubclient[T](c, resource),
  258. maxPageSize,
  259. )
  260. }
  261. func FetchGitLabGroupProjects[T *GitLabProject](
  262. c *GitLabClient,
  263. group string,
  264. ) dp.Pager[T] {
  265. resStrFmt := "/groups/%s/projects?simple=true&per_page=%d"
  266. escaped := url.PathEscape(group)
  267. resStr := fmt.Sprintf(resStrFmt, escaped, maxPageSize)
  268. resource, err := url.Parse(resStr)
  269. if err != nil { // should only occur in case of bugs
  270. panic(err)
  271. }
  272. return dp.NewPager[T](
  273. newSubclient[T](c, resource),
  274. maxPageSize,
  275. )
  276. }