gitlink-cli/internal/client/client.go

325 lines
7.8 KiB
Go

package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"sort"
"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
// NoProgress suppresses per-byte transfer progress on stderr; used when
// several transfers run concurrently and interleaved lines would garble.
NoProgress bool
}
type APIError struct {
StatusCode int
Code interface{}
Message string
}
type MultipartFile struct {
FieldName string
FileName string
ContentType string
Reader io.Reader
}
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) {
var bodyReader io.Reader
contentType := ""
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
contentType = "application/json"
}
return c.doRequest(method, path, bodyReader, contentType, query)
}
func (c *Client) PostMultipart(path string, fields map[string]string, files []MultipartFile) (*output.Envelope, error) {
if len(files) == 0 {
return nil, fmt.Errorf("at least one multipart file is required")
}
reader, writer := io.Pipe()
form := multipart.NewWriter(writer)
go func() {
writeErr := writeMultipartBody(form, fields, files)
closeErr := form.Close()
if writeErr == nil {
writeErr = closeErr
}
if writeErr != nil {
_ = writer.CloseWithError(writeErr)
return
}
_ = writer.Close()
}()
return c.doRequest(http.MethodPost, path, reader, form.FormDataContentType(), nil)
}
func (c *Client) doRequest(method, path string, body io.Reader, contentType string, query url.Values) (*output.Envelope, error) {
fullURL := c.apiURL(path, query)
req, err := http.NewRequest(method, fullURL, body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
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 {
previewLen := len(respData)
if previewLen > 200 {
previewLen = 200
}
fmt.Printf("<- %d %s\n", resp.StatusCode, string(respData[:previewLen]))
}
return parseResponseEnvelope(resp.StatusCode, respData)
}
func writeMultipartBody(form *multipart.Writer, fields map[string]string, files []MultipartFile) error {
fieldNames := make([]string, 0, len(fields))
for name := range fields {
fieldNames = append(fieldNames, name)
}
sort.Strings(fieldNames)
for _, name := range fieldNames {
if err := form.WriteField(name, fields[name]); err != nil {
return err
}
}
for _, file := range files {
if file.Reader == nil {
return fmt.Errorf("multipart file reader is required")
}
part, err := createMultipartPart(form, file)
if err != nil {
return err
}
if _, err := io.Copy(part, file.Reader); err != nil {
return err
}
}
return nil
}
func createMultipartPart(form *multipart.Writer, file MultipartFile) (io.Writer, error) {
fieldName := strings.TrimSpace(file.FieldName)
if fieldName == "" {
fieldName = "file"
}
fileName := strings.TrimSpace(file.FileName)
if fileName == "" {
fileName = fieldName
}
if strings.TrimSpace(file.ContentType) == "" {
return form.CreateFormFile(fieldName, fileName)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, fieldName, fileName))
header.Set("Content-Type", file.ContentType)
return form.CreatePart(header)
}
func (c *Client) apiURL(path string, query url.Values) string {
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()
}
return fullURL
}
func parseResponseEnvelope(statusCode int, respData []byte) (*output.Envelope, error) {
// Check HTTP-level errors
if statusCode >= 400 {
return nil, &APIError{
StatusCode: statusCode,
Code: statusCode,
Message: fmt.Sprintf("HTTP %d: %s", statusCode, strings.TrimSpace(string(respData))),
}
}
// 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)
suggestion := suggestFix(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
}
}
}
// 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 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
}
}
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 ""
}
}