From ba0fa441f42b3d979a923036c86c153ce2cc342f Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 15:22:50 +0000 Subject: [PATCH] feat: HTTP client with auth, context, error handling Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- client.go | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ client_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 client.go create mode 100644 client_test.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..e7f29dc --- /dev/null +++ b/client.go @@ -0,0 +1,158 @@ +package forge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// APIError represents an error response from the Forgejo API. +type APIError struct { + StatusCode int + Message string + URL string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("forge: %s %d: %s", e.URL, e.StatusCode, e.Message) +} + +// IsNotFound returns true if the error is a 404 response. +func IsNotFound(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound +} + +// IsForbidden returns true if the error is a 403 response. +func IsForbidden(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden +} + +// IsConflict returns true if the error is a 409 response. +func IsConflict(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict +} + +// Option configures the Client. +type Option func(*Client) + +// WithHTTPClient sets a custom http.Client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { c.httpClient = hc } +} + +// WithUserAgent sets the User-Agent header. +func WithUserAgent(ua string) Option { + return func(c *Client) { c.userAgent = ua } +} + +// Client is a low-level HTTP client for the Forgejo API. +type Client struct { + baseURL string + token string + httpClient *http.Client + userAgent string +} + +// NewClient creates a new Forgejo API client. +func NewClient(url, token string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(url, "/"), + token: token, + httpClient: http.DefaultClient, + userAgent: "go-forge/0.1", + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Get performs a GET request. +func (c *Client) Get(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodGet, path, nil, out) +} + +// Post performs a POST request. +func (c *Client) Post(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPost, path, body, out) +} + +// Patch performs a PATCH request. +func (c *Client) Patch(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPatch, path, body, out) +} + +// Put performs a PUT request. +func (c *Client) Put(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPut, path, body, out) +} + +// Delete performs a DELETE request. +func (c *Client) Delete(ctx context.Context, path string) error { + return c.do(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + url := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("forge: marshal body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return fmt.Errorf("forge: create request: %w", err) + } + + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("forge: request %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.parseError(resp, path) + } + + if out != nil && resp.StatusCode != http.StatusNoContent { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("forge: decode response: %w", err) + } + } + + return nil +} + +func (c *Client) parseError(resp *http.Response, path string) error { + var errBody struct { + Message string `json:"message"` + } + _ = json.NewDecoder(resp.Body).Decode(&errBody) + return &APIError{ + StatusCode: resp.StatusCode, + Message: errBody.Message, + URL: path, + } +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..e0d581b --- /dev/null +++ b/client_test.go @@ -0,0 +1,138 @@ +package forge + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_Good_Get(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("missing auth header") + } + if r.URL.Path != "/api/v1/user" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]string{"login": "virgil"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + var out map[string]string + err := c.Get(context.Background(), "/api/v1/user", &out) + if err != nil { + t.Fatal(err) + } + if out["login"] != "virgil" { + t.Errorf("got login=%q", out["login"]) + } +} + +func TestClient_Good_Post(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["name"] != "test-repo" { + t.Errorf("wrong body: %v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "test-repo"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + body := map[string]string{"name": "test-repo"} + var out map[string]any + err := c.Post(context.Background(), "/api/v1/orgs/core/repos", body, &out) + if err != nil { + t.Fatal(err) + } + if out["name"] != "test-repo" { + t.Errorf("got name=%v", out["name"]) + } +} + +func TestClient_Good_Delete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + err := c.Delete(context.Background(), "/api/v1/repos/core/test") + if err != nil { + t.Fatal(err) + } +} + +func TestClient_Bad_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"message": "internal error"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + err := c.Get(context.Background(), "/api/v1/user", nil) + if err == nil { + t.Fatal("expected error") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 500 { + t.Errorf("got status=%d", apiErr.StatusCode) + } +} + +func TestClient_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + err := c.Get(context.Background(), "/api/v1/repos/x/y", nil) + if !IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestClient_Good_ContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + err := c.Get(ctx, "/api/v1/user", nil) + if err == nil { + t.Fatal("expected error from cancelled context") + } +} + +func TestClient_Good_Options(t *testing.T) { + c := NewClient("https://forge.lthn.ai", "tok", + WithUserAgent("go-forge/1.0"), + ) + if c.userAgent != "go-forge/1.0" { + t.Errorf("got user agent=%q", c.userAgent) + } +}