NewFromEnv now falls back to DISCOURSE_KEY when DISCOURSE_API_KEY is unset (the explicit name still wins), matching the shorter DISCOURSE_URL/DISCOURSE_KEY convention used across the MOPAC fleet scripts. Behavioral test proves both the alias and the precedence against the fake server. Part of Redmine 507 (Discourse Go client).
275 lines
8.8 KiB
Go
275 lines
8.8 KiB
Go
// Package discourse is a 100% Go, stdlib-only client for the Discourse
|
|
// REST API (admin API key auth). It covers what the MOPAC stack needs —
|
|
// categories, topics, posts — plus a raw JSON passthrough for everything
|
|
// else, so no future endpoint needs another release cycle here.
|
|
//
|
|
// Keys: the API key arrives via constructor or environment
|
|
// (DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME), never via
|
|
// flags or arguments, and is never logged, never rendered by Client.
|
|
// String, and never echoed in error strings (it only ever travels in the
|
|
// Api-Key header).
|
|
package discourse
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Sentinel error classes; use errors.Is. Every server failure is also an
|
|
// *APIError carrying status, path and the Discourse-reported reasons.
|
|
var (
|
|
// ErrForbidden: HTTP 403 — the key lacks the scope for the call (the
|
|
// known MOPAC case: the current key cannot create categories; an
|
|
// admin-scoped key is needed).
|
|
ErrForbidden = errors.New("discourse: forbidden (key lacks scope for this call)")
|
|
// ErrUnauthorized: HTTP 401 — bad key or unknown Api-Username.
|
|
ErrUnauthorized = errors.New("discourse: unauthorized (bad key or username)")
|
|
// ErrNotFound: HTTP 404.
|
|
ErrNotFound = errors.New("discourse: not found")
|
|
// ErrRateLimited: HTTP 429; APIError.RetryAfter holds the server hint.
|
|
ErrRateLimited = errors.New("discourse: rate limited")
|
|
// ErrServer: HTTP 5xx.
|
|
ErrServer = errors.New("discourse: server error")
|
|
// ErrUnreachable: transport-level failure (DNS, refused, timeout).
|
|
ErrUnreachable = errors.New("discourse: unreachable")
|
|
// ErrMalformedResponse: non-JSON or unexpected payload shape.
|
|
ErrMalformedResponse = errors.New("discourse: malformed response")
|
|
// ErrInvalidRequest: locally rejected call (bad args) — never sent.
|
|
ErrInvalidRequest = errors.New("discourse: invalid request")
|
|
)
|
|
|
|
// APIError is a non-2xx response. Errors list the Discourse-reported
|
|
// reasons (error_description / errors[] fields) when present.
|
|
type APIError struct {
|
|
Method string
|
|
Path string
|
|
Status int
|
|
Errors []string
|
|
RetryAfter time.Duration
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
msgs := e.Errors
|
|
if len(msgs) == 0 {
|
|
msgs = []string{"(no detail in response body)"}
|
|
}
|
|
return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.Path, e.Status, strings.Join(msgs, "; "))
|
|
}
|
|
|
|
// Unwrap maps the status onto the sentinel classes.
|
|
func (e *APIError) Unwrap() error {
|
|
switch e.Status {
|
|
case http.StatusForbidden:
|
|
return ErrForbidden
|
|
case http.StatusUnauthorized:
|
|
return ErrUnauthorized
|
|
case http.StatusNotFound:
|
|
return ErrNotFound
|
|
case http.StatusTooManyRequests:
|
|
return ErrRateLimited
|
|
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusInternalServerError:
|
|
return ErrServer
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Client talks to one Discourse instance with one API key.
|
|
type Client struct {
|
|
baseURL string // scheme + host, no trailing slash, no path
|
|
apiKey string
|
|
apiUsername string
|
|
http *http.Client
|
|
}
|
|
|
|
// New builds a client. baseURL is the instance root
|
|
// (https://forum.example.com); apiKey is a Discourse API key (global or
|
|
// user-scoped); apiUsername is the user the key acts as (e.g. "system").
|
|
func New(baseURL, apiKey, apiUsername string) (*Client, error) {
|
|
if apiKey == "" {
|
|
return nil, fmt.Errorf("%w: empty API key", ErrInvalidRequest)
|
|
}
|
|
if apiUsername == "" {
|
|
apiUsername = "system"
|
|
}
|
|
u := strings.TrimSuffix(strings.TrimSpace(baseURL), "/")
|
|
if u == "" || !strings.Contains(u, "://") {
|
|
return nil, fmt.Errorf("%w: base url %q must be scheme://host", ErrInvalidRequest, redact(baseURL))
|
|
}
|
|
parsed, err := url.Parse(u)
|
|
if err != nil || parsed.Host == "" {
|
|
return nil, fmt.Errorf("%w: base url %q does not parse", ErrInvalidRequest, redact(baseURL))
|
|
}
|
|
return &Client{
|
|
baseURL: u,
|
|
apiKey: apiKey,
|
|
apiUsername: apiUsername,
|
|
http: &http.Client{Timeout: 60 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
// NewFromEnv builds a client from DISCOURSE_URL, the API key
|
|
// (DISCOURSE_API_KEY, falling back to the shorter DISCOURSE_KEY alias)
|
|
// and DISCOURSE_API_USERNAME (default "system"). The intended source is
|
|
// a 0600 env file (see env.example), sourced before the process starts.
|
|
func NewFromEnv() (*Client, error) {
|
|
key := os.Getenv("DISCOURSE_API_KEY")
|
|
if key == "" {
|
|
key = os.Getenv("DISCOURSE_KEY")
|
|
}
|
|
return New(os.Getenv("DISCOURSE_URL"), key, os.Getenv("DISCOURSE_API_USERNAME"))
|
|
}
|
|
|
|
// String renders the client for logs: base url + acting username only.
|
|
// The key never appears.
|
|
func (c *Client) String() string {
|
|
return fmt.Sprintf("discourse.Client(%s as %s)", c.baseURL, c.apiUsername)
|
|
}
|
|
|
|
// BaseURL exposes the instance root (safe to log).
|
|
func (c *Client) BaseURL() string { return c.baseURL }
|
|
|
|
// Do is the raw JSON passthrough: method + path ("/categories.json",
|
|
// query included) + an optional JSON-marshalable body, decoded into out
|
|
// (when non-nil). Anything the typed helpers do not cover goes through
|
|
// here, so the client never blocks an upstream endpoint.
|
|
func (c *Client) Do(ctx context.Context, method, path string, body, out any) error {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: marshal body: %v", ErrInvalidRequest, err)
|
|
}
|
|
rd = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
|
}
|
|
resp, err := c.roundTrip(req, body != nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
if err != nil {
|
|
return fmt.Errorf("%w: read %s: %v", ErrUnreachable, path, err)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return fmt.Errorf("%w: decode %s: %v", ErrMalformedResponse, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get is Do with GET and no body.
|
|
func (c *Client) Get(ctx context.Context, path string, out any) error {
|
|
return c.Do(ctx, http.MethodGet, path, nil, out)
|
|
}
|
|
|
|
// Post is Do with POST and a JSON body.
|
|
func (c *Client) Post(ctx context.Context, path string, body, out any) error {
|
|
return c.Do(ctx, http.MethodPost, path, body, out)
|
|
}
|
|
|
|
// Put is Do with PUT and a JSON body.
|
|
func (c *Client) Put(ctx context.Context, path string, body, out any) error {
|
|
return c.Do(ctx, http.MethodPut, path, body, out)
|
|
}
|
|
|
|
// roundTrip sends req with the admin API key headers and classifies the
|
|
// response: 2xx returns the open response for the caller to drain; 4xx/5xx
|
|
// returns a closed-body *APIError; transport errors return ErrUnreachable.
|
|
func (c *Client) roundTrip(req *http.Request, hasBody bool) (*http.Response, error) {
|
|
req.Header.Set("Api-Key", c.apiKey)
|
|
req.Header.Set("Api-Username", c.apiUsername)
|
|
req.Header.Set("Accept", "application/json")
|
|
if hasBody {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnreachable, redact(err.Error()))
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
apiErr := &APIError{
|
|
Method: req.Method,
|
|
Path: req.URL.Path,
|
|
Status: resp.StatusCode,
|
|
RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")),
|
|
}
|
|
apiErr.Errors = responseErrors(raw)
|
|
if len(apiErr.Errors) == 0 {
|
|
apiErr.Errors = []string{truncate(strings.TrimSpace(string(raw)), 300)}
|
|
}
|
|
return nil, apiErr
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// responseErrors pulls the human-readable reasons out of a Discourse
|
|
// error body ("error" / "errors[]" / "error_description" fields).
|
|
func responseErrors(raw []byte) []string {
|
|
var payload struct {
|
|
Error string `json:"error"`
|
|
Errors []string `json:"errors"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
if payload.Error != "" {
|
|
out = append(out, payload.Error)
|
|
}
|
|
out = append(out, payload.Errors...)
|
|
if payload.ErrorDescription != "" {
|
|
out = append(out, payload.ErrorDescription)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseRetryAfter(v string) time.Duration {
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
// redact strips any query string and userinfo from url-ish strings so a
|
|
// misconfigured hop cannot echo key material into logs.
|
|
func redact(s string) string {
|
|
if i := strings.Index(s, "?"); i >= 0 {
|
|
s = s[:i] + "?..."
|
|
}
|
|
if at := strings.Index(s, "@"); at >= 0 && strings.Contains(s, "://") {
|
|
if slash := strings.Index(s, "://"); slash >= 0 && at > slash {
|
|
s = s[:slash+3] + s[at+1:]
|
|
}
|
|
}
|
|
return s
|
|
}
|