// 套件 gitea 提供存取 Gitea API 的 HTTP 用戶端基礎: // 認證、分頁、逾時與錯誤映射。 // // 僅使用標準庫(net/http 等),離線可建置。token 由呼叫端注入, // 本套件不負責認證來源的解析(見 auth.go),也不會把 token 寫進 // 任何輸出或錯誤訊息。 package gitea import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "reflect" "strconv" "strings" "time" ) // ErrAPI 表示與 Gitea API 溝通失敗(連線、認證、權限或伺服器錯誤)。 // 行為上對應結束碼 3(API 錯誤)。 type ErrAPI struct { // StatusCode 是 HTTP 回應狀態碼;0 表示請求未完成(連線失敗等)。 StatusCode int // Op 是失敗的步驟描述(如 "GET /api/v1/user"),僅供診斷。 Op string // Err 是底層錯誤。 Err error } // Error 實作 error 介面;不包含 token 或完整回應內容。 func (e *ErrAPI) Error() string { if e.StatusCode == 0 { return fmt.Sprintf("gitea: %s: %v", e.Op, e.Err) } return fmt.Sprintf("gitea: %s: HTTP %d", e.Op, e.StatusCode) } // Unwrap 支援 errors.Is / errors.As。 func (e *ErrAPI) Unwrap() error { return e.Err } // Client 是 Gitea API 的最小用戶端。零值不可用,請用 New。 type Client struct { // baseURL 是 API 根(如 https://gitea.alterminal.com/api/v1), // 不帶尾斜線。 baseURL *url.URL // token 是 API token;空字串表示匿名存取(公開端點)。 token string // hc 是底層 HTTP 用戶端(帶逾時)。 hc *http.Client } // DefaultTimeout 是未指定逾時時的預設值。 const DefaultTimeout = 30 * time.Second // New 建立用戶端。baseURL 為站點網址(如 https://gitea.alterminal.com), // 函式內部自行補上 /api/v1 路徑。timeout <= 0 時採 DefaultTimeout。 func New(baseURL, token string, timeout time.Duration) (*Client, error) { u, err := url.Parse(strings.TrimSuffix(baseURL, "/")) if err != nil || u.Scheme == "" || u.Host == "" { return nil, fmt.Errorf("gitea: invalid base URL %q", baseURL) } u = u.JoinPath("api", "v1") if timeout <= 0 { timeout = DefaultTimeout } return &Client{ baseURL: u, token: token, hc: &http.Client{Timeout: timeout}, }, nil } // MaxLimit 是 Gitea 清單端點單次回傳上限(服務端硬上限 50)。 const MaxLimit = 50 // getJSON 對 path(相對 baseURL,以 / 開頭)發 GET,把 JSON 回應解到 out。 // 失敗時回傳 *ErrAPI。rawQuery 為組好的查詢字串(見 ListOptions.query)。 func (c *Client) getJSON(ctx context.Context, path, rawQuery string, out any) error { u := *c.baseURL u.Path += path u.RawQuery = rawQuery op := "GET " + u.Path req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return &ErrAPI{Op: op, Err: err} } req.Header.Set("Accept", "application/json") if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } resp, err := c.hc.Do(req) if err != nil { return &ErrAPI{Op: op, Err: err} } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { // 把內容讀掉再丟棄,讓連線可重用;錯誤訊息不含回應內容。 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return &ErrAPI{Op: op, StatusCode: resp.StatusCode} } if out == nil { return nil } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return &ErrAPI{Op: op, Err: fmt.Errorf("decode response: %w", err)} } return nil } // GetJSON 是 getJSON 的公開介面,供非清單端點(如 /user)使用。 func (c *Client) GetJSON(ctx context.Context, path string, out any) error { return c.getJSON(ctx, path, "", out) } // ListOptions 描述清單端點的分頁參數。 type ListOptions struct { // Limit 是單頁筆數;<= 0 或 > MaxLimit 時設為 MaxLimit。 Limit int // Page 是頁碼(1 起);僅供單頁抓取,ListAll 一律從第 1 頁開始。 Page int // Extra 是額外查詢參數(如 state、type)。 Extra url.Values } // query 把分頁參數組成查詢字串。 func (o ListOptions) query() string { q := url.Values{} limit := o.Limit if limit <= 0 || limit > MaxLimit { limit = MaxLimit } q.Set("limit", strconv.Itoa(limit)) if o.Page > 1 { q.Set("page", strconv.Itoa(o.Page)) } for k, vs := range o.Extra { for _, v := range vs { q.Set(k, v) } } return q.Encode() } // ListAll 逐頁抓取清單端點直到抓完,把結果存進 out(指向 slice 的指標, // 初始須為空 slice)。每頁固定使用 limit=MaxLimit(Gitea 上限 50); // opts.Extra 照常帶入。頁數在服務端回傳未滿頁或空頁時結束。 func (c *Client) ListAll(ctx context.Context, path string, opts ListOptions, out any) error { rv := reflect.ValueOf(out) if rv.Kind() != reflect.Pointer || rv.IsNil() { return fmt.Errorf("gitea: ListAll: out must be non-nil pointer to slice") } sv := rv.Elem() if sv.Kind() != reflect.Slice { return fmt.Errorf("gitea: ListAll: out must be non-nil pointer to slice") } if sv.Len() != 0 { return fmt.Errorf("gitea: ListAll: out slice must be empty") } for page := 1; ; page++ { opts := opts opts.Limit = MaxLimit opts.Page = page pageOut := reflect.MakeSlice(sv.Type(), 0, MaxLimit) pp := reflect.New(sv.Type()) pp.Elem().Set(pageOut) if err := c.getJSON(ctx, path, opts.query(), pp.Interface()); err != nil { return err } got := pp.Elem() n := got.Len() if n == 0 { return nil } sv.Set(reflect.AppendSlice(sv, got)) if n < MaxLimit { return nil } } }