Files
teai/internal/gitea/client.go
T
max acf8cce68c 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
2026-09-10 08:20:02 +08:00

184 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 套件 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
}
}
}