diff --git a/internal/provider/rainyun/client.go b/internal/provider/rainyun/client.go new file mode 100644 index 0000000..6c48fd9 --- /dev/null +++ b/internal/provider/rainyun/client.go @@ -0,0 +1,132 @@ +package rainyun + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const defaultEndpoint = "https://api.v2.rainyun.com" + +type client struct { + httpClient *http.Client + baseURL string + apiKey string +} + +func newClient(endpoint, apiKey string) *client { + if endpoint == "" { + endpoint = defaultEndpoint + } + return &client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: endpoint, + apiKey: apiKey, + } +} + +type apiResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func (c *client) get(ctx context.Context, path string, result any) error { + return c.do(ctx, "GET", path, nil, result) +} + +func (c *client) post(ctx context.Context, path string, body, result any) error { + return c.do(ctx, "POST", path, body, result) +} + +func (c *client) patch(ctx context.Context, path string, body, result any) error { + return c.do(ctx, "PATCH", path, body, result) +} + +func (c *client) delete(ctx context.Context, path string, result any) error { + return c.do(ctx, "DELETE", path, nil, result) +} + +func (c *client) do(ctx context.Context, method, path string, body, result any) error { + url := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("X-Api-Key", c.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return &APIError{ + StatusCode: resp.StatusCode, + Method: method, + URL: url, + Body: respBody, + } + } + + var apiResp apiResponse + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + if apiResp.Code != 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Method: method, + URL: url, + Body: respBody, + APICode: apiResp.Code, + APIMsg: apiResp.Msg, + } + } + + if result != nil { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + } + return nil +} + +type APIError struct { + StatusCode int + Method string + URL string + Body []byte + APICode int + APIMsg string +} + +func (e *APIError) Error() string { + msg := e.APIMsg + if msg == "" { + msg = http.StatusText(e.StatusCode) + } + return msg +} diff --git a/internal/provider/rainyun/client_test.go b/internal/provider/rainyun/client_test.go new file mode 100644 index 0000000..f3a0d3b --- /dev/null +++ b/internal/provider/rainyun/client_test.go @@ -0,0 +1,59 @@ +package rainyun + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientAuthHeader(t *testing.T) { + var gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Api-Key") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":0,"data":{"id":1,"name":"test"}}`)) + })) + defer srv.Close() + + c := newClient(srv.URL, "sk-test") + var result struct { + Code int `json:"code"` + Data struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"data"` + } + if err := c.get(context.Background(), "/test", &result); err != nil { + t.Fatalf("get() error: %v", err) + } + if gotKey != "sk-test" { + t.Errorf("X-Api-Key = %q, want %q", gotKey, "sk-test") + } + if result.Data.Name != "test" { + t.Errorf("Data.Name = %q, want %q", result.Data.Name, "test") + } +} + +func TestClientAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":1002,"msg":"not found"}`)) + })) + defer srv.Close() + + c := newClient(srv.URL, "sk-test") + var result any + err := c.get(context.Background(), "/error", &result) + if err == nil { + t.Fatal("expected error for 404") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("error code = %d, want 404", apiErr.StatusCode) + } +}