// 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 }