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