v0: stdlib-only Discourse client — categories/topics/posts + raw passthrough
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
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user