init
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user