Covers the MOPAC briefing/report surface (Redmine 495 Part A): category
list/create (create needs the admin-scoped key; current key 403s, typed
as ErrForbidden), topic create/list/latest/get, post create/update/get,
current-user identity probe, and a Do() JSON passthrough so unmodeled
endpoints need no client release. Key is env/constructor-only, never a
flag, never logged; errors classify via errors.Is. Fake-server unit
tests + containerized end-to-end smoke (redaction sweep included); live
reads verified against community.turnsys.com.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
270 lines
8.7 KiB
Go
270 lines
8.7 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, DISCOURSE_API_KEY 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) {
|
|
return New(os.Getenv("DISCOURSE_URL"), os.Getenv("DISCOURSE_API_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
|
|
}
|