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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user