225 lines
6.6 KiB
Go
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
|
|
}
|
|
}
|