internal/gitea:API 用戶端、認證與輸出基礎(#5)

- internal/gitea:僅標準庫的 HTTP 用戶端;ListAll 以 limit=50 逐頁抓滿
- 認證選序 --token → TEAI_TOKEN → tea 組態(--config/TEA_CONFIG/預設路徑);
  token 不入輸出、日誌與錯err誤訊息
- 共用輸出 --output json|table(預設 json)、--timeout(預設 30s)
- CLI 結束碼 0/2/3:用法錯誤 → 2、API 錯誤(*gitea.ErrAPI)→ 3
- 單元測試:認證選序、tea 組態解析、分颁頁、逾時/401/5xx 錯誤映射、輸出

實作 issue #5
This commit is contained in:
max
2026-09-10 08:20:02 +08:00
parent 14edef066b
commit acf8cce68c
8 changed files with 1442 additions and 16 deletions
+183
View File
@@ -0,0 +1,183 @@
// 套件 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
}
}
}