gitlink-cli/internal/client/client.go

228 lines
6.1 KiB
Go

package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/config"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool
}
type APIError struct {
StatusCode int
Code interface{}
Message string
Kind clierrors.ErrorKind
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) {
// Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val)
if !c.SkipJSONSuffix {
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
if !strings.HasSuffix(basePath, ".json") {
path = basePath + ".json" + queryStr
}
} else if !strings.HasSuffix(path, ".json") {
path += ".json"
}
}
fullURL := c.BaseURL + path
if query != nil && 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)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", 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 {
info := lookupStatusInfo(resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Kind: info.kind,
Suggestion: info.suggestion,
}
}
// Parse JSON
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
// Not JSON, return as-is
return output.SuccessEnvelope(string(respData), nil), nil
}
// Check GitLink error-in-body pattern
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
case int:
statusCode = float64(v)
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
info := lookupStatusInfo(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Kind: info.kind,
Suggestion: info.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 (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)
}
type statusInfo struct {
kind clierrors.ErrorKind
message string
suggestion string
}
var statusMessages = map[int]statusInfo{
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
"运行 gitlink-cli auth login 重新登录,或检查 GITLINK_TOKEN 环境变量"},
-1: {clierrors.KindInput, "参数校验失败",
"检查必填参数是否缺失、参数格式是否正确,运行 gitlink-cli <命令> --help 查看用法"},
0: {clierrors.KindUnknown, "操作失败", ""},
// Standard HTTP codes
401: {clierrors.KindAuth, "认证失败",
"运行 gitlink-cli auth login 登录,或检查 GITLINK_TOKEN 环境变量"},
403: {clierrors.KindForbidden, "权限不足",
"请确认账号有此仓库的访问权限,或联系项目管理员"},
404: {clierrors.KindNotFound, "资源不存在",
"检查 owner/repo/id 是否正确,资源可能已被删除"},
422: {clierrors.KindInput, "参数校验失败",
"检查请求参数格式,运行 gitlink-cli <命令> --help 查看用法"},
429: {clierrors.KindServer, "请求过于频繁",
"稍等片刻后重试"},
500: {clierrors.KindServer, "服务器内部错误",
"稍等后重试,如持续出现请联系平台管理员"},
502: {clierrors.KindServer, "网关错误",
"服务器暂时不可用,稍等后重试"},
503: {clierrors.KindServer, "服务暂时不可用",
"服务器正在维护,稍等后重试"},
}
func lookupStatusInfo(code int) statusInfo {
if info, ok := statusMessages[code]; ok {
return info
}
return statusInfo{
kind: clierrors.KindUnknown,
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}