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:
2026-08-29 06:12:22 -05:00
commit f9cf30bc72
16 changed files with 2559 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
// Posts: create (POST /posts.json — replies or topic creation, the same
// endpoint), update (PUT /posts/<id>.json), and the session identity
// check (GET /session/current.json) used by smoke tests and health probes.
package discourse
import (
"context"
"fmt"
"strings"
)
// Post is the post payload subset callers use.
type Post struct {
ID int `json:"id"`
TopicID int `json:"topic_id"`
PostNumber int `json:"post_number"`
Raw string `json:"raw"`
Cooked string `json:"cooked"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CreatePostRequest posts into an existing topic (topic_id set) or
// creates a topic (topic_id zero + title + category — prefer
// CreateTopic for that path).
type CreatePostRequest struct {
TopicID int `json:"topic_id"`
Raw string `json:"raw"`
Title string `json:"title,omitempty"`
Category int `json:"category,omitempty"`
}
// CreatePostResult is the POST /posts.json response.
type CreatePostResult struct {
PostID int `json:"id"`
TopicID int `json:"topic_id"`
TopicSlug string `json:"topic_slug"`
}
// CreatePost posts markdown into a topic and returns the ids.
func (c *Client) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResult, error) {
if req.TopicID <= 0 {
return nil, fmt.Errorf("%w: topic_id is required (use CreateTopic for new topics)", ErrInvalidRequest)
}
if strings.TrimSpace(req.Raw) == "" {
return nil, fmt.Errorf("%w: post raw body is required", ErrInvalidRequest)
}
var out CreatePostResult
if err := c.Post(ctx, "/posts.json", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// UpdatePost replaces a post's markdown body (PUT /posts/<id>.json) with
// an optional edit reason.
func (c *Client) UpdatePost(ctx context.Context, postID int, raw, editReason string) (*Post, error) {
if postID <= 0 {
return nil, fmt.Errorf("%w: post id must be > 0", ErrInvalidRequest)
}
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("%w: post raw body is required", ErrInvalidRequest)
}
body := map[string]any{
"post": map[string]any{
"raw": raw,
},
}
if editReason != "" {
body["post"].(map[string]any)["edit_reason"] = editReason
}
var post struct {
Post Post `json:"post"`
}
if err := c.Put(ctx, "/posts/"+fmt.Sprint(postID)+".json", body, &post); err != nil {
return nil, err
}
if post.Post.ID == 0 {
return nil, fmt.Errorf("%w: post updated but response carries no id", ErrMalformedResponse)
}
return &post.Post, nil
}
// GetPost fetches one post by id (GET /posts/<id>.json).
func (c *Client) GetPost(ctx context.Context, postID int) (*Post, error) {
if postID <= 0 {
return nil, fmt.Errorf("%w: post id must be > 0", ErrInvalidRequest)
}
var payload struct {
Post Post `json:"post"`
}
if err := c.Get(ctx, "/posts/"+fmt.Sprint(postID)+".json", &payload); err != nil {
return nil, err
}
if payload.Post.ID == 0 {
return nil, fmt.Errorf("%w: post response carries no id", ErrMalformedResponse)
}
return &payload.Post, nil
}
// CurrentUser is the identity the key acts as — the cheapest live
// verification of url + key + username (any 2xx means the trio works).
type CurrentUser struct {
ID int `json:"id"`
Username string `json:"username"`
Admin bool `json:"admin"`
}
// CurrentUser runs GET /session/current.json.
func (c *Client) CurrentUser(ctx context.Context) (*CurrentUser, error) {
var payload struct {
CurrentUser CurrentUser `json:"current_user"`
}
if err := c.Get(ctx, "/session/current.json", &payload); err != nil {
return nil, err
}
if payload.CurrentUser.Username == "" {
return nil, fmt.Errorf("%w: no current_user in response", ErrMalformedResponse)
}
return &payload.CurrentUser, nil
}