From 497339f055f865ce1a6a3f3ed565da793d08f5ea Mon Sep 17 00:00:00 2001 From: jellyfish-p Date: Sun, 25 Jan 2026 11:36:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(kiro):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E4=BB=A4=E7=89=8C=E9=A2=9D=E5=BA=A6=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=9A=84api-call=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 + go.sum | 4 + internal/api/handlers/management/api_tools.go | 53 +++++-- .../management/api_tools_cbor_test.go | 149 ++++++++++++++++++ 4 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 internal/api/handlers/management/api_tools_cbor_test.go diff --git a/go.mod b/go.mod index b734874e..f3af54be 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect @@ -69,6 +70,7 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/go.sum b/go.sum index d4a4cb9d..3c0b5ac5 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -157,6 +159,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index c7846a75..2318a2c8 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/fxamacker/cbor/v2" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" @@ -70,7 +71,7 @@ type apiCallResponse struct { // - Authorization: Bearer // - X-Management-Key: // -// Request JSON: +// Request JSON (supports both application/json and application/cbor): // - auth_index / authIndex / AuthIndex (optional): // The credential "auth_index" from GET /v0/management/auth-files (or other endpoints returning it). // If omitted or not found, credential-specific proxy/token substitution is skipped. @@ -90,10 +91,12 @@ type apiCallResponse struct { // 2. Global config proxy-url // 3. Direct connect (environment proxies are not used) // -// Response JSON (returned with HTTP 200 when the APICall itself succeeds): -// - status_code: Upstream HTTP status code. -// - header: Upstream response headers. -// - body: Upstream response body as string. +// Response (returned with HTTP 200 when the APICall itself succeeds): +// +// Format matches request Content-Type (application/json or application/cbor) +// - status_code: Upstream HTTP status code. +// - header: Upstream response headers. +// - body: Upstream response body as string. // // Example: // @@ -107,10 +110,28 @@ type apiCallResponse struct { // -H "Content-Type: application/json" \ // -d '{"auth_index":"","method":"POST","url":"https://api.example.com/v1/fetchAvailableModels","header":{"Authorization":"Bearer $TOKEN$","Content-Type":"application/json","User-Agent":"cliproxyapi"},"data":"{}"}' func (h *Handler) APICall(c *gin.Context) { + // Detect content type + contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))) + isCBOR := strings.Contains(contentType, "application/cbor") + var body apiCallRequest - if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) - return + + // Parse request body based on content type + if isCBOR { + rawBody, errRead := io.ReadAll(c.Request.Body) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return + } + if errUnmarshal := cbor.Unmarshal(rawBody, &body); errUnmarshal != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cbor body"}) + return + } + } else { + if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } } method := strings.ToUpper(strings.TrimSpace(body.Method)) @@ -209,11 +230,23 @@ func (h *Handler) APICall(c *gin.Context) { return } - c.JSON(http.StatusOK, apiCallResponse{ + response := apiCallResponse{ StatusCode: resp.StatusCode, Header: resp.Header, Body: string(respBody), - }) + } + + // Return response in the same format as the request + if isCBOR { + cborData, errMarshal := cbor.Marshal(response) + if errMarshal != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode cbor response"}) + return + } + c.Data(http.StatusOK, "application/cbor", cborData) + } else { + c.JSON(http.StatusOK, response) + } } func firstNonEmptyString(values ...*string) string { diff --git a/internal/api/handlers/management/api_tools_cbor_test.go b/internal/api/handlers/management/api_tools_cbor_test.go new file mode 100644 index 00000000..8b7570a9 --- /dev/null +++ b/internal/api/handlers/management/api_tools_cbor_test.go @@ -0,0 +1,149 @@ +package management + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/gin-gonic/gin" +) + +func TestAPICall_CBOR_Support(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Create a test handler + h := &Handler{} + + // Create test request data + reqData := apiCallRequest{ + Method: "GET", + URL: "https://httpbin.org/get", + Header: map[string]string{ + "User-Agent": "test-client", + }, + } + + t.Run("JSON request and response", func(t *testing.T) { + // Marshal request as JSON + jsonData, err := json.Marshal(reqData) + if err != nil { + t.Fatalf("Failed to marshal JSON: %v", err) + } + + // Create HTTP request + req := httptest.NewRequest(http.MethodPost, "/v0/management/api-call", bytes.NewReader(jsonData)) + req.Header.Set("Content-Type", "application/json") + + // Create response recorder + w := httptest.NewRecorder() + + // Create Gin context + c, _ := gin.CreateTestContext(w) + c.Request = req + + // Call handler + h.APICall(c) + + // Verify response + if w.Code != http.StatusOK && w.Code != http.StatusBadGateway { + t.Logf("Response status: %d", w.Code) + t.Logf("Response body: %s", w.Body.String()) + } + + // Check content type + contentType := w.Header().Get("Content-Type") + if w.Code == http.StatusOK && !contains(contentType, "application/json") { + t.Errorf("Expected JSON response, got: %s", contentType) + } + }) + + t.Run("CBOR request and response", func(t *testing.T) { + // Marshal request as CBOR + cborData, err := cbor.Marshal(reqData) + if err != nil { + t.Fatalf("Failed to marshal CBOR: %v", err) + } + + // Create HTTP request + req := httptest.NewRequest(http.MethodPost, "/v0/management/api-call", bytes.NewReader(cborData)) + req.Header.Set("Content-Type", "application/cbor") + + // Create response recorder + w := httptest.NewRecorder() + + // Create Gin context + c, _ := gin.CreateTestContext(w) + c.Request = req + + // Call handler + h.APICall(c) + + // Verify response + if w.Code != http.StatusOK && w.Code != http.StatusBadGateway { + t.Logf("Response status: %d", w.Code) + t.Logf("Response body: %s", w.Body.String()) + } + + // Check content type + contentType := w.Header().Get("Content-Type") + if w.Code == http.StatusOK && !contains(contentType, "application/cbor") { + t.Errorf("Expected CBOR response, got: %s", contentType) + } + + // Try to decode CBOR response + if w.Code == http.StatusOK { + var response apiCallResponse + if err := cbor.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Errorf("Failed to unmarshal CBOR response: %v", err) + } else { + t.Logf("CBOR response decoded successfully: status_code=%d", response.StatusCode) + } + } + }) + + t.Run("CBOR encoding and decoding consistency", func(t *testing.T) { + // Test data + testReq := apiCallRequest{ + Method: "POST", + URL: "https://example.com/api", + Header: map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + }, + Data: `{"key":"value"}`, + } + + // Encode to CBOR + cborData, err := cbor.Marshal(testReq) + if err != nil { + t.Fatalf("Failed to marshal to CBOR: %v", err) + } + + // Decode from CBOR + var decoded apiCallRequest + if err := cbor.Unmarshal(cborData, &decoded); err != nil { + t.Fatalf("Failed to unmarshal from CBOR: %v", err) + } + + // Verify fields + if decoded.Method != testReq.Method { + t.Errorf("Method mismatch: got %s, want %s", decoded.Method, testReq.Method) + } + if decoded.URL != testReq.URL { + t.Errorf("URL mismatch: got %s, want %s", decoded.URL, testReq.URL) + } + if decoded.Data != testReq.Data { + t.Errorf("Data mismatch: got %s, want %s", decoded.Data, testReq.Data) + } + if len(decoded.Header) != len(testReq.Header) { + t.Errorf("Header count mismatch: got %d, want %d", len(decoded.Header), len(testReq.Header)) + } + }) +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && s[:len(substr)] == substr || bytes.Contains([]byte(s), []byte(substr))) +}