go-forge/client.go

298 lines
7.6 KiB
Go
Raw Normal View History

package forge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
coreerr "dappco.re/go/core/log"
)
// 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 }
}
// RateLimit represents the rate limit information from the Forgejo API.
type RateLimit struct {
Limit int
Remaining int
Reset int64
}
// Client is a low-level HTTP client for the Forgejo API.
type Client struct {
baseURL string
token string
httpClient *http.Client
userAgent string
rateLimit RateLimit
}
// RateLimit returns the last known rate limit information.
func (c *Client) RateLimit() RateLimit {
return c.rateLimit
}
// 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.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
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 {
_, err := c.doJSON(ctx, http.MethodGet, path, nil, out)
return err
}
// Post performs a POST request.
func (c *Client) Post(ctx context.Context, path string, body, out any) error {
_, err := c.doJSON(ctx, http.MethodPost, path, body, out)
return err
}
// Patch performs a PATCH request.
func (c *Client) Patch(ctx context.Context, path string, body, out any) error {
_, err := c.doJSON(ctx, http.MethodPatch, path, body, out)
return err
}
// Put performs a PUT request.
func (c *Client) Put(ctx context.Context, path string, body, out any) error {
_, err := c.doJSON(ctx, http.MethodPut, path, body, out)
return err
}
// Delete performs a DELETE request.
func (c *Client) Delete(ctx context.Context, path string) error {
_, err := c.doJSON(ctx, http.MethodDelete, path, nil, nil)
return err
}
// DeleteWithBody performs a DELETE request with a JSON body.
func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error {
_, err := c.doJSON(ctx, http.MethodDelete, path, body, nil)
return err
}
// PostRaw performs a POST request with a JSON body and returns the raw
// response body as bytes instead of JSON-decoding. Useful for endpoints
// such as /markdown that return raw HTML text.
func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error) {
url := c.baseURL + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, coreerr.E("Client.PostRaw", "forge: marshal body", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader)
if err != nil {
return nil, coreerr.E("Client.PostRaw", "forge: create request", err)
}
req.Header.Set("Authorization", "token "+c.token)
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 nil, coreerr.E("Client.PostRaw", "forge: request POST "+path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, c.parseError(resp, path)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, coreerr.E("Client.PostRaw", "forge: read response body", err)
}
return data, nil
}
// GetRaw performs a GET request and returns the raw response body as bytes
// instead of JSON-decoding. Useful for endpoints that return raw file content.
func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) {
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, coreerr.E("Client.GetRaw", "forge: create request", err)
}
req.Header.Set("Authorization", "token "+c.token)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, coreerr.E("Client.GetRaw", "forge: request GET "+path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, c.parseError(resp, path)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, coreerr.E("Client.GetRaw", "forge: read response body", err)
}
return data, nil
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
_, err := c.doJSON(ctx, method, path, body, out)
return err
}
func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) (*http.Response, error) {
url := c.baseURL + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, coreerr.E("Client.doJSON", "forge: marshal body", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, coreerr.E("Client.doJSON", "forge: create request", 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 nil, coreerr.E("Client.doJSON", "forge: request "+method+" "+path, err)
}
defer resp.Body.Close()
c.updateRateLimit(resp)
if resp.StatusCode >= 400 {
return nil, c.parseError(resp, path)
}
if out != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return nil, coreerr.E("Client.doJSON", "forge: decode response", err)
}
}
return resp, nil
}
func (c *Client) parseError(resp *http.Response, path string) error {
var errBody struct {
Message string `json:"message"`
}
// Read a bit of the body to see if we can get a message
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
_ = json.Unmarshal(data, &errBody)
msg := errBody.Message
if msg == "" && len(data) > 0 {
msg = string(data)
}
if msg == "" {
msg = http.StatusText(resp.StatusCode)
}
return &APIError{
StatusCode: resp.StatusCode,
Message: msg,
URL: path,
}
}
func (c *Client) updateRateLimit(resp *http.Response) {
if limit := resp.Header.Get("X-RateLimit-Limit"); limit != "" {
c.rateLimit.Limit, _ = strconv.Atoi(limit)
}
if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" {
c.rateLimit.Remaining, _ = strconv.Atoi(remaining)
}
if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" {
c.rateLimit.Reset, _ = strconv.ParseInt(reset, 10, 64)
}
}