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:
+102
@@ -0,0 +1,102 @@
|
||||
// Categories: list (GET /categories.json) and create (POST /categories.json).
|
||||
// Creation needs an admin-scoped key — the current MOPAC key gets 403
|
||||
// there (ErrForbidden); listing works with a standard key.
|
||||
package discourse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Category is the subset of the Discourse category payload callers use.
|
||||
type Category struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
TextColor string `json:"text_color"`
|
||||
Position int `json:"position"`
|
||||
TopicCount int `json:"topic_count"`
|
||||
}
|
||||
|
||||
// CreateCategoryRequest is the POST /categories.json body. Permissions
|
||||
// maps a GROUP NAME to an access level: 1 = reply/see, 2 = create posts,
|
||||
// 3 = full (the security group numbers Discourse's admin UI uses). Empty
|
||||
// means "default: everyone full" — for restricted categories set it
|
||||
// explicitly, e.g. {"staff": 3, "trust_level_0": 1}.
|
||||
type CreateCategoryRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"` // 6 hex digits, no '#'
|
||||
TextColor string `json:"text_color"` // 6 hex digits, no '#'
|
||||
Permissions map[string]int `json:"permissions,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
ParentCategoryID int `json:"parent_category_id,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ListCategories returns the instance's categories (one request; the
|
||||
// endpoint returns the full list).
|
||||
func (c *Client) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
var payload struct {
|
||||
CategoryList struct {
|
||||
Categories []Category `json:"categories"`
|
||||
} `json:"category_list"`
|
||||
}
|
||||
if err := c.Get(ctx, "/categories.json", &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload.CategoryList.Categories, nil
|
||||
}
|
||||
|
||||
// FindCategory resolves a category by id or by slug (case-insensitive).
|
||||
// One of the two must be non-zero/non-empty.
|
||||
func (c *Client) FindCategory(ctx context.Context, id int, slug string) (*Category, error) {
|
||||
cats, err := c.ListCategories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cats {
|
||||
if (id != 0 && cats[i].ID == id) ||
|
||||
(slug != "" && strings.EqualFold(cats[i].Slug, slug)) {
|
||||
return &cats[i], nil
|
||||
}
|
||||
}
|
||||
return nil, &APIError{
|
||||
Method: "GET",
|
||||
Path: "/categories.json",
|
||||
Status: 404,
|
||||
Errors: []string{fmt.Sprintf("category id=%d slug=%q not found", id, slug)},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCategory creates a category and returns the created record.
|
||||
// Colors are normalized (uppercased, '#' stripped); Discourse requires
|
||||
// valid hex or it 422s.
|
||||
func (c *Client) CreateCategory(ctx context.Context, req CreateCategoryRequest) (*Category, error) {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return nil, fmt.Errorf("%w: category name is required", ErrInvalidRequest)
|
||||
}
|
||||
req.Color = normHex(req.Color, "3AB54A")
|
||||
req.TextColor = normHex(req.TextColor, "FFFFFF")
|
||||
var created Category
|
||||
if err := c.Post(ctx, "/categories.json", req, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
func normHex(v, fallback string) string {
|
||||
v = strings.TrimPrefix(strings.TrimSpace(strings.ToUpper(v)), "#")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// quotePathSegment escapes a path segment (slugs in /c/<slug>/<id>.json).
|
||||
func quotePathSegment(s string) string {
|
||||
return url.PathEscape(s)
|
||||
}
|
||||
Reference in New Issue
Block a user