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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user