gitlink-cli/internal/client/client.go

335 lines
8.7 KiB
Go

package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
}
// maxGetRetries is the number of extra attempts made for idempotent GET
// requests that fail with a transient network error or a retryable
// gateway status (429/502/503/504).
const maxGetRetries = 2
// retryBaseDelay is the initial backoff delay, doubled on each retry.
var retryBaseDelay = 300 * time.Millisecond
// maxRetryAfter caps how long a server-provided Retry-After header can
// extend the backoff, keeping the CLI responsive.
const maxRetryAfter = 5 * time.Second
// retryDelay returns the exponential backoff for the given attempt, honoring
// a Retry-After header (in seconds) when the server provides one.
func retryDelay(attempt int, resp *http.Response) time.Duration {
delay := retryBaseDelay << attempt
if resp == nil {
return delay
}
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(strings.TrimSpace(ra)); err == nil && secs > 0 {
d := time.Duration(secs) * time.Second
if d > maxRetryAfter {
d = maxRetryAfter
}
if d > delay {
delay = d
}
}
}
return delay
}
func retryableStatus(code int) bool {
switch code {
case http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
}
return false
}
type APIError struct {
StatusCode int
Code interface{}
Message string
Suggestion string
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
func New() (*Client, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
return &Client{
HTTP: auth.NewHTTPClient(),
BaseURL: cfg.BaseURL,
}, nil
}
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
path = normalizeAPIPath(c.BaseURL, path)
// Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val)
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
if shouldAppendJSONSuffix(basePath) {
path = basePath + ".json" + queryStr
}
} else if shouldAppendJSONSuffix(path) {
path += ".json"
}
fullURL := c.BaseURL + path
if len(query) > 0 {
sep := "?"
if strings.Contains(fullURL, "?") {
sep = "&"
}
fullURL += sep + query.Encode()
}
// Replace path params
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, err
}
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
var resp *http.Response
var respData []byte
for attempt := 0; ; attempt++ {
resp, err = c.HTTP.Do(req)
if err == nil {
respData, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
err = fmt.Errorf("failed to read response: %w", err)
}
} else {
err = fmt.Errorf("request failed: %w", err)
}
transient := err != nil || retryableStatus(resp.StatusCode)
if method != http.MethodGet || !transient || attempt >= maxGetRetries {
break
}
var respForDelay *http.Response
if err == nil {
respForDelay = resp
}
delay := retryDelay(attempt, respForDelay)
if c.Debug {
if err != nil {
fmt.Printf("↻ retry %d/%d in %v after error: %v\n", attempt+1, maxGetRetries, delay, err)
} else {
fmt.Printf("↻ retry %d/%d in %v after HTTP %d\n", attempt+1, maxGetRetries, delay, resp.StatusCode)
}
}
time.Sleep(delay)
}
if err != nil {
return nil, err
}
if c.Debug {
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
}
// Check HTTP-level errors
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Suggestion: suggestFix(resp.StatusCode),
}
}
// Parse JSON
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
// Some legacy endpoints (e.g. /:owner/:repo/branches) answer with a
// top-level JSON array or scalar; keep the decoded value instead of
// degrading it to an escaped string.
var nonObject interface{}
if jsonErr := json.Unmarshal(respData, &nonObject); jsonErr == nil {
return output.SuccessEnvelope(nonObject, nil), nil
}
// Unknown API paths fall through to the web frontend, which answers
// 200 with an HTML page; surface that as an error instead of data.
if isHTMLResponse(resp, respData) {
suggestion := "接口路径不存在或不是 API 端点,请检查路径是否正确"
return output.ErrorEnvelope("non_api_response", "endpoint returned an HTML page instead of API data", suggestion), &APIError{
StatusCode: resp.StatusCode,
Code: "non_api_response",
Message: "endpoint returned an HTML page instead of API data",
Suggestion: suggestion,
}
}
// Not JSON, return as-is
return output.SuccessEnvelope(string(respData), nil), nil
}
// Check GitLink error-in-body pattern
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
var bodyCode float64
var bodyMsg string
if status, ok := raw["status"]; ok {
switch v := status.(type) {
case float64:
bodyCode = v
case int:
bodyCode = float64(v)
}
bodyMsg, _ = raw["message"].(string)
} else if code, ok := raw["code"]; ok {
switch v := code.(type) {
case float64:
bodyCode = v
case int:
bodyCode = float64(v)
}
bodyMsg, _ = raw["msg"].(string)
if bodyMsg == "" {
bodyMsg, _ = raw["message"].(string)
}
}
if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 {
suggestion := suggestFix(int(bodyCode))
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
StatusCode: int(bodyCode),
Code: int(bodyCode),
Message: bodyMsg,
Suggestion: suggestion,
}
}
// Auto-parse JSON string data (GitLink API quirk: some endpoints return data as JSON string)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
raw["data"] = json.RawMessage(dataStr)
}
}
// Build meta from pagination info
var meta *output.Meta
if tc, ok := raw["total_count"]; ok {
meta = &output.Meta{}
if v, ok := tc.(float64); ok {
meta.TotalCount = int(v)
}
if v, ok := raw["page"].(float64); ok {
meta.Page = int(v)
}
if v, ok := raw["limit"].(float64); ok {
meta.Limit = int(v)
}
}
return output.SuccessEnvelope(raw, meta), nil
}
func isHTMLResponse(resp *http.Response, body []byte) bool {
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
return true
}
trimmed := strings.TrimSpace(string(body))
return strings.HasPrefix(trimmed, "<!doctype") || strings.HasPrefix(trimmed, "<!DOCTYPE") || strings.HasPrefix(trimmed, "<html")
}
func shouldAppendJSONSuffix(path string) bool {
if strings.HasSuffix(path, ".json") {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
// Wiki open API endpoints do not use .json suffix
if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" {
return false
}
return true
}
func normalizeAPIPath(baseURL, path string) string {
if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") {
switch {
case path == "/api":
return ""
case strings.HasPrefix(path, "/api/"):
return strings.TrimPrefix(path, "/api")
}
}
return path
}
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
func suggestFix(code int) string {
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
case 422:
return "参数校验失败,请检查请求参数"
default:
return ""
}
}