feat: HTTP client with auth, context, error handling
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
40378d1e90
commit
ba0fa441f4
2 changed files with 296 additions and 0 deletions
158
client.go
Normal file
158
client.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
138
client_test.go
Normal file
138
client_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue