forked from Gitlink/gitlink-cli
323 lines
7.8 KiB
Go
323 lines
7.8 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
|
|
type APIError struct {
|
|
StatusCode int
|
|
Code interface{}
|
|
Message string
|
|
Kind ErrorKind
|
|
}
|
|
|
|
type ErrorKind string
|
|
|
|
const (
|
|
ErrorKindHTTP ErrorKind = "http"
|
|
ErrorKindBusiness ErrorKind = "business"
|
|
ErrorKindNotFound ErrorKind = "not_found"
|
|
ErrorKindAuthentication ErrorKind = "authentication"
|
|
ErrorKindPermission ErrorKind = "permission"
|
|
)
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
|
}
|
|
|
|
func IsNotFound(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && (apiErr.Kind == ErrorKindNotFound || apiErr.StatusCode == http.StatusNotFound || numericCode(apiErr.Code) == -2)
|
|
}
|
|
|
|
func IsAuthentication(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && apiErr.Kind == ErrorKindAuthentication
|
|
}
|
|
|
|
func IsPermission(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && apiErr.Kind == ErrorKindPermission
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 {
|
|
message := responseMessage(respData)
|
|
if message == "" {
|
|
message = strings.TrimSpace(string(respData))
|
|
}
|
|
return nil, &APIError{
|
|
StatusCode: resp.StatusCode,
|
|
Code: resp.StatusCode,
|
|
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, message),
|
|
Kind: classifyError(resp.StatusCode, resp.StatusCode),
|
|
}
|
|
}
|
|
|
|
if len(bytes.TrimSpace(respData)) == 0 {
|
|
return output.SuccessEnvelope(nil, nil), nil
|
|
}
|
|
|
|
// Decode the JSON root as any. GitLink endpoints legitimately return
|
|
// objects, arrays, scalars, plain text, and empty responses.
|
|
var decoded interface{}
|
|
if err := json.Unmarshal(respData, &decoded); err != nil {
|
|
// Not JSON, return as-is
|
|
return output.SuccessEnvelope(string(respData), nil), nil
|
|
}
|
|
|
|
raw, isObject := decoded.(map[string]interface{})
|
|
if !isObject {
|
|
return output.SuccessEnvelope(decoded, nil), nil
|
|
}
|
|
|
|
// Check GitLink error-in-body pattern
|
|
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
|
|
var bodyCode int
|
|
var bodyMsg string
|
|
if status, ok := raw["status"]; ok {
|
|
bodyCode = numericCode(status)
|
|
bodyMsg, _ = raw["message"].(string)
|
|
} else if code, ok := raw["code"]; ok {
|
|
bodyCode = numericCode(code)
|
|
bodyMsg, _ = raw["msg"].(string)
|
|
if bodyMsg == "" {
|
|
bodyMsg, _ = raw["message"].(string)
|
|
}
|
|
}
|
|
if !isSuccessCode(bodyCode) {
|
|
suggestion := suggestFix(bodyCode)
|
|
return output.ErrorEnvelope(bodyCode, bodyMsg, suggestion), &APIError{
|
|
StatusCode: resp.StatusCode,
|
|
Code: bodyCode,
|
|
Message: bodyMsg,
|
|
Kind: classifyError(resp.StatusCode, bodyCode),
|
|
}
|
|
}
|
|
|
|
// 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"] = parsedData
|
|
}
|
|
}
|
|
|
|
// 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 numericCode(value interface{}) int {
|
|
switch code := value.(type) {
|
|
case float64:
|
|
return int(code)
|
|
case float32:
|
|
return int(code)
|
|
case int:
|
|
return code
|
|
case int64:
|
|
return int(code)
|
|
case json.Number:
|
|
parsed, _ := code.Int64()
|
|
return int(parsed)
|
|
case string:
|
|
parsed, _ := strconv.Atoi(code)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func isSuccessCode(code int) bool {
|
|
return code == 0 || code == 1 || code == 200 || code == 201 || code == 204
|
|
}
|
|
|
|
func classifyError(httpStatus, code int) ErrorKind {
|
|
if httpStatus == http.StatusUnauthorized || code == http.StatusUnauthorized {
|
|
return ErrorKindAuthentication
|
|
}
|
|
if httpStatus == http.StatusForbidden || code == http.StatusForbidden {
|
|
return ErrorKindPermission
|
|
}
|
|
if httpStatus == http.StatusNotFound || code == http.StatusNotFound || code == -2 {
|
|
return ErrorKindNotFound
|
|
}
|
|
if httpStatus >= 400 {
|
|
return ErrorKindHTTP
|
|
}
|
|
return ErrorKindBusiness
|
|
}
|
|
|
|
func responseMessage(data []byte) string {
|
|
var payload map[string]interface{}
|
|
if json.Unmarshal(data, &payload) != nil {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"message", "msg", "error"} {
|
|
if value, ok := payload[key].(string); ok && value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 -2, 404:
|
|
return "资源不存在,请检查 owner/repo/id 是否正确"
|
|
case 422:
|
|
return "参数校验失败,请检查请求参数"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|