Files
2026-08-15 07:13:00 +00:00

225 lines
6.6 KiB
Go

// Package client is the HTTP client for the pages management API.
//
// It depends on the standard library and github.com/iceBear67/simplepages/api
// only — see cmd/pages/deps_test.go, which fails the build if a server-side
// package ever reaches the CLI through here.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/version"
)
// DefaultTimeout bounds a single management request.
const DefaultTimeout = 30 * time.Second
// maxErrorBody caps how much of a non-2xx body is read before giving up on
// finding an error envelope in it. A misrouted request can land on something
// that answers with a megabyte of HTML.
const maxErrorBody = 64 << 10
// Config configures a Client.
type Config struct {
// BaseURL is the management API root, e.g. "https://pages.example.com".
// Any trailing slash is trimmed.
BaseURL string
// Token is the bearer credential. It is sent in the Authorization header
// and must never appear in a URL, a log line or an error message.
Token string
// Timeout bounds each request. Zero means DefaultTimeout.
Timeout time.Duration
// HTTP overrides the underlying client. Its Timeout field is ignored:
// deadlines come from the request context so the deploy path can give a
// large blob upload longer than a management call.
HTTP *http.Client
// UserAgent overrides the default "pages/<version>".
UserAgent string
}
// Client talks to the management API.
type Client struct {
base string
token string
timeout time.Duration
http *http.Client
agent string
}
// New validates cfg and returns a client.
func New(cfg Config) (*Client, error) {
raw := strings.TrimSpace(cfg.BaseURL)
if raw == "" {
return nil, errors.New("no server URL: pass --server or set PAGES_SERVER")
}
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid server URL %q: %w", raw, err)
}
switch u.Scheme {
case "http", "https":
case "":
return nil, fmt.Errorf("invalid server URL %q: missing scheme, try https://%s", raw, raw)
default:
return nil, fmt.Errorf("invalid server URL %q: scheme must be http or https", raw)
}
if u.Host == "" {
return nil, fmt.Errorf("invalid server URL %q: missing host", raw)
}
if cfg.Token == "" {
return nil, errors.New("no token: pass --token-file or set PAGES_TOKEN")
}
hc := cfg.HTTP
if hc == nil {
tr := http.DefaultTransport.(*http.Transport).Clone()
// The deploy path uploads blobs concurrently to one host.
tr.MaxIdleConnsPerHost = 32
hc = &http.Client{Transport: tr}
}
// A redirect is a misconfigured --server, and following it silently would
// send the bearer token somewhere the operator did not name. Report it and
// let them fix the URL. (Go strips Authorization across hosts anyway, which
// would turn the redirect into a confusing 401 instead.)
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return fmt.Errorf("server redirected to %s — use that as --server", req.URL.Redacted())
}
agent := cfg.UserAgent
if agent == "" {
agent = "pages/" + version.Version
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
return &Client{
base: strings.TrimRight(u.String(), "/"),
token: cfg.Token,
timeout: timeout,
http: hc,
agent: agent,
}, nil
}
// BaseURL returns the server root the client was configured with.
func (c *Client) BaseURL() string { return c.base }
// do performs a request with a JSON body and decodes a JSON response.
//
// body may be nil for a bodyless request; out may be nil to discard the
// response (204, or a response the caller does not need).
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
rdr = bytes.NewReader(buf)
}
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", c.agent)
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return requestError(method, path, err)
}
defer func() {
// Drain a little so the connection can be reused; a huge unread body is
// not worth keeping the connection for.
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return responseError(resp)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode %s %s response: %w", method, path, err)
}
return nil
}
// requestError describes a transport-level failure. The URL is included; the
// token is not, and cannot be — it never leaves the Authorization header.
func requestError(method, path string, err error) error {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("%s %s: timed out; raise --timeout if the server is slow", method, path)
}
return fmt.Errorf("%s %s: %w", method, path, err)
}
// responseError turns a non-2xx response into an *api.Error, so callers can
// switch on api.CodeOf and the CLI can print "project_exists: ...".
func responseError(resp *http.Response) error {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
var env api.ErrorEnvelope
if err := json.Unmarshal(raw, &env); err == nil && env.Error.Code != "" {
return &env.Error
}
// Not an envelope: a proxy, a wrong --server, or a bug. Say what arrived
// instead of pretending to a code we did not receive.
msg := strings.TrimSpace(string(raw))
if len(msg) > 200 {
msg = msg[:200] + "…"
}
if msg == "" {
msg = http.StatusText(resp.StatusCode)
}
return &api.Error{Code: codeForStatus(resp.StatusCode), Message: msg}
}
func codeForStatus(status int) api.Code {
switch status {
case http.StatusBadRequest:
return api.CodeBadRequest
case http.StatusUnauthorized:
return api.CodeUnauthorized
case http.StatusForbidden:
return api.CodeForbidden
case http.StatusNotFound:
return api.CodeNotFound
case http.StatusMethodNotAllowed:
return api.CodeMethodNotAllowed
case http.StatusConflict:
return api.CodeConflict
case http.StatusRequestEntityTooLarge:
return api.CodePayloadTooLarge
case http.StatusTooManyRequests:
return api.CodeRateLimited
case http.StatusServiceUnavailable:
return api.CodeUnavailable
default:
return api.CodeInternal
}
}