Files
mopac-discourse-go/discourse.go
T
mrcharles 5b95ac3c7e feat: accept DISCOURSE_KEY as env alias for the API key
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).
2026-08-29 16:47:17 -05:00

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
}