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
+137
View File
@@ -0,0 +1,137 @@
// Topics: create (POST /posts.json — Discourse creates topics by posting
// the first post with a title), list per category (GET /c/<...>.json) and
// latest (GET /latest.json), fetch one (GET /t/<id>.json).
package discourse
import (
"context"
"fmt"
"strings"
)
// TopicSummary is one row of a topic list.
type TopicSummary struct {
ID int `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
CategoryID int `json:"category_id"`
PostsCount int `json:"posts_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Closed bool `json:"closed"`
Archived bool `json:"archived"`
}
// Topic is the full topic payload (GET /t/<id>.json); posts live under
// PostStream.Posts.
type Topic struct {
ID int `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
CategoryID int `json:"category_id"`
CreatedAt string `json:"created_at"`
Closed bool `json:"closed"`
Archived bool `json:"archived"`
PostStream struct {
Posts []Post `json:"posts"`
} `json:"post_stream"`
}
// CreateTopicRequest creates a topic: title + markdown body + category.
type CreateTopicRequest struct {
Title string `json:"title"`
Raw string `json:"raw"`
Category int `json:"category"`
Tags []string `json:"tags,omitempty"`
}
// CreateTopicResult is the POST /posts.json response for a topic
// creation: the first post's id plus the new topic's id and slug.
type CreateTopicResult struct {
PostID int `json:"id"`
TopicID int `json:"topic_id"`
TopicSlug string `json:"topic_slug"`
}
// URL renders the topic's canonical URL on this instance.
func (r CreateTopicResult) URL(baseURL string) string {
if r.TopicID == 0 {
return ""
}
slug := r.TopicSlug
if slug == "" {
slug = "topic"
}
return strings.TrimSuffix(baseURL, "/") + "/t/" + slug + "/" + fmt.Sprint(r.TopicID)
}
// CreateTopic posts a new topic into the category and returns its ids.
func (c *Client) CreateTopic(ctx context.Context, req CreateTopicRequest) (*CreateTopicResult, error) {
if strings.TrimSpace(req.Title) == "" {
return nil, fmt.Errorf("%w: topic title is required", ErrInvalidRequest)
}
if req.Category == 0 {
return nil, fmt.Errorf("%w: topic category id is required", ErrInvalidRequest)
}
var out CreateTopicResult
if err := c.Post(ctx, "/posts.json", req, &out); err != nil {
return nil, err
}
if out.TopicID == 0 {
return nil, fmt.Errorf("%w: topic created but response carries no topic_id", ErrMalformedResponse)
}
return &out, nil
}
// ListTopics returns the topic list of a category, addressed by id
// (GET /c/<id>.json) or slug (GET /c/<slug>.json). Pass slug="" with id>0
// or id=0 with a slug.
func (c *Client) ListTopics(ctx context.Context, id int, slug string) ([]TopicSummary, error) {
if id == 0 && slug == "" {
return nil, fmt.Errorf("%w: category id or slug is required", ErrInvalidRequest)
}
var path string
if id > 0 {
if slug != "" {
path = "/c/" + quotePathSegment(slug) + "/" + fmt.Sprint(id) + ".json"
} else {
path = "/c/" + fmt.Sprint(id) + ".json"
}
} else {
path = "/c/" + quotePathSegment(slug) + ".json"
}
var payload struct {
TopicList struct {
Topics []TopicSummary `json:"topics"`
} `json:"topic_list"`
}
if err := c.Get(ctx, path, &payload); err != nil {
return nil, err
}
return payload.TopicList.Topics, nil
}
// LatestTopics returns the instance's latest topic list (GET /latest.json).
func (c *Client) LatestTopics(ctx context.Context) ([]TopicSummary, error) {
var payload struct {
TopicList struct {
Topics []TopicSummary `json:"topics"`
} `json:"topic_list"`
}
if err := c.Get(ctx, "/latest.json", &payload); err != nil {
return nil, err
}
return payload.TopicList.Topics, nil
}
// GetTopic fetches one topic by id, posts included.
func (c *Client) GetTopic(ctx context.Context, id int) (*Topic, error) {
if id <= 0 {
return nil, fmt.Errorf("%w: topic id must be > 0", ErrInvalidRequest)
}
var topic Topic
if err := c.Get(ctx, "/t/"+fmt.Sprint(id)+".json", &topic); err != nil {
return nil, err
}
return &topic, nil
}