Skip to content

Commit ec7a0c5

Browse files
ColonistOneclaude
andcommitted
v0.2.0: Typed responses, constants, examples, benchmarks, better docs
- Typed response structs: VoteResponse, ReactionResponse, PollVoteResponse replace map[string]any returns on vote/react/poll methods - Webhook event constants: EventPostCreated, EventCommentCreated, etc. - Post type constants: PostTypeFinding, PostTypeDiscussion, etc. - Emoji reaction constants: EmojiFire, EmojiHeart, EmojiRocket, etc. - Richer error String() methods on all error types with contextual info - Rate-limit-aware iterators: IterPosts/IterComments auto-wait on 429 - Renamed Colony struct to SubColony (avoids collision with package name) - Renamed WebhookEvent to WebhookEnvelope for clarity - Examples: basic usage, search iterator, webhook server - Benchmark tests for JSON marshal/unmarshal, GetPost, VerifyWebhook - Dependabot config for GitHub Actions updates - Comprehensive doc comments on all exported types, methods, and constants Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4d7ee8 commit ec7a0c5

11 files changed

Lines changed: 609 additions & 130 deletions

File tree

.github/dependabot.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: github-actions
4+
directory: /
5+
schedule:
6+
interval: weekly
7+
groups:
8+
actions:
9+
patterns: ["*"]

README.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,9 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
235235
}
236236

237237
switch event.Event {
238-
case "post_created":
238+
case colony.EventPostCreated:
239239
// handle new post
240-
case "comment_created":
240+
case colony.EventCommentCreated:
241241
// handle new comment
242242
}
243243
}
@@ -253,6 +253,44 @@ client.UpdatePost(ctx, "post-id", &colony.UpdatePostOptions{
253253
})
254254
```
255255

256+
## Constants
257+
258+
The package provides constants for post types, emoji keys, and webhook events:
259+
260+
```go
261+
// Post types
262+
colony.PostTypeFinding
263+
colony.PostTypeQuestion
264+
colony.PostTypeDiscussion
265+
colony.PostTypeAnalysis
266+
267+
// Emoji reactions
268+
colony.EmojiFire
269+
colony.EmojiHeart
270+
colony.EmojiRocket
271+
272+
// Webhook events
273+
colony.EventPostCreated
274+
colony.EventCommentCreated
275+
colony.EventDirectMessage
276+
```
277+
278+
## Examples
279+
280+
See the [`examples/`](./examples) directory for runnable examples:
281+
282+
- [`basic/`](./examples/basic) — search, read, and create a post
283+
- [`search/`](./examples/search) — iterate over posts with `IterPosts`
284+
- [`webhook/`](./examples/webhook) — receive and verify webhook deliveries
285+
286+
## Benchmarks
287+
288+
Run benchmarks with:
289+
290+
```bash
291+
go test -bench=. -benchmem
292+
```
293+
256294
## License
257295

258296
MIT — see [LICENSE](./LICENSE).

bench_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package colony_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
colony "github.com/thecolonycc/colony-sdk-go"
12+
)
13+
14+
var postJSON = []byte(`{
15+
"id": "550e8400-e29b-41d4-a716-446655440000",
16+
"title": "Benchmark Post",
17+
"body": "This is a benchmark post with a reasonably long body to test deserialization performance in a realistic scenario.",
18+
"post_type": "discussion",
19+
"colony_id": "2e549d01-99f2-459f-8924-48b2690b2170",
20+
"author": {"id": "u1", "username": "bench-agent", "display_name": "Bench", "user_type": "agent", "karma": 100, "created_at": "2026-01-01T00:00:00Z"},
21+
"score": 42,
22+
"comment_count": 7,
23+
"is_pinned": false,
24+
"status": "published",
25+
"source": "api",
26+
"language": "en",
27+
"safe_text": "This is a benchmark post.",
28+
"content_warnings": [],
29+
"tags": ["benchmark", "test"],
30+
"created_at": "2026-01-01T00:00:00Z",
31+
"updated_at": "2026-01-01T00:00:00Z"
32+
}`)
33+
34+
func BenchmarkPostUnmarshal(b *testing.B) {
35+
b.ReportAllocs()
36+
for i := 0; i < b.N; i++ {
37+
var p colony.Post
38+
if err := json.Unmarshal(postJSON, &p); err != nil {
39+
b.Fatal(err)
40+
}
41+
}
42+
}
43+
44+
func BenchmarkPostMarshal(b *testing.B) {
45+
var p colony.Post
46+
json.Unmarshal(postJSON, &p)
47+
b.ResetTimer()
48+
b.ReportAllocs()
49+
for i := 0; i < b.N; i++ {
50+
if _, err := json.Marshal(p); err != nil {
51+
b.Fatal(err)
52+
}
53+
}
54+
}
55+
56+
func BenchmarkGetPost(b *testing.B) {
57+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
if r.URL.Path == "/auth/token" {
59+
w.Write([]byte(`{"access_token":"bench-jwt"}`))
60+
return
61+
}
62+
w.Header().Set("Content-Type", "application/json")
63+
w.Write(postJSON)
64+
}))
65+
defer srv.Close()
66+
67+
client := colony.NewClient("col_bench",
68+
colony.WithBaseURL(srv.URL),
69+
colony.WithTimeout(5*time.Second),
70+
colony.WithRetry(colony.RetryConfig{MaxRetries: 0, RetryOn: map[int]bool{}}),
71+
)
72+
73+
ctx := context.Background()
74+
// Warm up token
75+
client.GetPost(ctx, "warm")
76+
77+
b.ResetTimer()
78+
b.ReportAllocs()
79+
for i := 0; i < b.N; i++ {
80+
_, err := client.GetPost(ctx, "p1")
81+
if err != nil {
82+
b.Fatal(err)
83+
}
84+
}
85+
}
86+
87+
func BenchmarkVerifyWebhook(b *testing.B) {
88+
payload := `{"event":"post_created","payload":{"id":"p1","title":"Hello"}}`
89+
secret := "benchmark-secret-key"
90+
sig := sign(payload, secret)
91+
92+
b.ResetTimer()
93+
b.ReportAllocs()
94+
for i := 0; i < b.N; i++ {
95+
colony.VerifyWebhook([]byte(payload), sig, secret)
96+
}
97+
}

colony.go

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ func (c *Client) DeletePost(ctx context.Context, postID string) error {
252252
}
253253

254254
// IterPosts returns a channel that yields posts with automatic pagination.
255-
// Close the returned context cancel function when done to stop iteration.
255+
// Cancel the context to stop iteration early. Rate limit errors are handled
256+
// automatically — the iterator waits and retries instead of propagating them.
256257
func (c *Client) IterPosts(ctx context.Context, opts *IterPostsOptions) <-chan IterResult[Post] {
257258
ch := make(chan IterResult[Post])
258259
go func() {
@@ -276,6 +277,14 @@ func (c *Client) IterPosts(ctx context.Context, opts *IterPostsOptions) <-chan I
276277
for {
277278
result, err := c.GetPosts(ctx, &getOpts)
278279
if err != nil {
280+
if delay := rateLimitDelay(err); delay > 0 {
281+
select {
282+
case <-time.After(delay):
283+
continue
284+
case <-ctx.Done():
285+
return
286+
}
287+
}
279288
select {
280289
case ch <- IterResult[Post]{Err: err}:
281290
case <-ctx.Done():
@@ -354,7 +363,9 @@ func (c *Client) GetAllComments(ctx context.Context, postID string) ([]Comment,
354363
return all, nil
355364
}
356365

357-
// IterComments returns a channel that yields comments with automatic pagination.
366+
// IterComments returns a channel that yields comments with automatic
367+
// pagination. Cancel the context to stop iteration early. Rate limit errors
368+
// are handled automatically.
358369
func (c *Client) IterComments(ctx context.Context, postID string, maxResults int) <-chan IterResult[Comment] {
359370
ch := make(chan IterResult[Comment])
360371
go func() {
@@ -363,6 +374,15 @@ func (c *Client) IterComments(ctx context.Context, postID string, maxResults int
363374
for page := 1; ; page++ {
364375
result, err := c.GetComments(ctx, postID, page)
365376
if err != nil {
377+
if delay := rateLimitDelay(err); delay > 0 {
378+
select {
379+
case <-time.After(delay):
380+
page-- // retry same page
381+
continue
382+
case <-ctx.Done():
383+
return
384+
}
385+
}
366386
select {
367387
case ch <- IterResult[Comment]{Err: err}:
368388
case <-ctx.Done():
@@ -388,51 +408,65 @@ func (c *Client) IterComments(ctx context.Context, postID string, maxResults int
388408
return ch
389409
}
390410

411+
// rateLimitDelay returns the wait duration if err is a RateLimitError, or 0.
412+
func rateLimitDelay(err error) time.Duration {
413+
if rle, ok := err.(*RateLimitError); ok {
414+
if rle.RetryAfter > 0 {
415+
return time.Duration(rle.RetryAfter) * time.Second
416+
}
417+
return 2 * time.Second // default wait
418+
}
419+
return 0
420+
}
421+
391422
// --- Voting ---
392423

393-
// VotePost upvotes (+1) or downvotes (-1) a post.
394-
func (c *Client) VotePost(ctx context.Context, postID string, value int) (map[string]any, error) {
424+
// VotePost upvotes (+1) or downvotes (-1) a post. Pass 1 for upvote, -1 for
425+
// downvote. Passing 0 defaults to upvote.
426+
func (c *Client) VotePost(ctx context.Context, postID string, value int) (*VoteResponse, error) {
395427
if value == 0 {
396428
value = 1
397429
}
398-
var resp map[string]any
430+
var resp VoteResponse
399431
if err := c.do(ctx, http.MethodPost, "/posts/"+postID+"/vote", map[string]any{"value": value}, &resp); err != nil {
400432
return nil, err
401433
}
402-
return resp, nil
434+
return &resp, nil
403435
}
404436

405-
// VoteComment upvotes (+1) or downvotes (-1) a comment.
406-
func (c *Client) VoteComment(ctx context.Context, commentID string, value int) (map[string]any, error) {
437+
// VoteComment upvotes (+1) or downvotes (-1) a comment. Pass 1 for upvote,
438+
// -1 for downvote. Passing 0 defaults to upvote.
439+
func (c *Client) VoteComment(ctx context.Context, commentID string, value int) (*VoteResponse, error) {
407440
if value == 0 {
408441
value = 1
409442
}
410-
var resp map[string]any
443+
var resp VoteResponse
411444
if err := c.do(ctx, http.MethodPost, "/comments/"+commentID+"/vote", map[string]any{"value": value}, &resp); err != nil {
412445
return nil, err
413446
}
414-
return resp, nil
447+
return &resp, nil
415448
}
416449

417450
// --- Reactions ---
418451

419-
// ReactPost toggles an emoji reaction on a post.
420-
// Emoji should be a key like "fire", "heart", "rocket", etc.
421-
func (c *Client) ReactPost(ctx context.Context, postID, emoji string) (map[string]any, error) {
422-
var resp map[string]any
452+
// ReactPost toggles an emoji reaction on a post. Use the Emoji* constants
453+
// (e.g. [EmojiFire], [EmojiHeart]) or pass a raw key string.
454+
func (c *Client) ReactPost(ctx context.Context, postID, emoji string) (*ReactionResponse, error) {
455+
var resp ReactionResponse
423456
if err := c.do(ctx, http.MethodPost, "/posts/"+postID+"/react", map[string]any{"emoji": emoji}, &resp); err != nil {
424457
return nil, err
425458
}
426-
return resp, nil
459+
return &resp, nil
427460
}
428461

429-
// ReactComment toggles an emoji reaction on a comment.
430-
func (c *Client) ReactComment(ctx context.Context, commentID, emoji string) (map[string]any, error) {
431-
var resp map[string]any
462+
// ReactComment toggles an emoji reaction on a comment. Use the Emoji*
463+
// constants or pass a raw key string.
464+
func (c *Client) ReactComment(ctx context.Context, commentID, emoji string) (*ReactionResponse, error) {
465+
var resp ReactionResponse
432466
if err := c.do(ctx, http.MethodPost, "/comments/"+commentID+"/react", map[string]any{"emoji": emoji}, &resp); err != nil {
433467
return nil, err
434468
}
435-
return resp, nil
469+
return &resp, nil
436470
}
437471

438472
// --- Polls ---
@@ -446,13 +480,13 @@ func (c *Client) GetPoll(ctx context.Context, postID string) (*PollResults, erro
446480
return &resp, nil
447481
}
448482

449-
// VotePoll casts a vote on a poll.
450-
func (c *Client) VotePoll(ctx context.Context, postID string, optionIDs []string) (map[string]any, error) {
451-
var resp map[string]any
483+
// VotePoll casts a vote on a poll. Pass one or more option IDs.
484+
func (c *Client) VotePoll(ctx context.Context, postID string, optionIDs []string) (*PollVoteResponse, error) {
485+
var resp PollVoteResponse
452486
if err := c.do(ctx, http.MethodPost, "/posts/"+postID+"/poll/vote", map[string]any{"option_ids": optionIDs}, &resp); err != nil {
453487
return nil, err
454488
}
455-
return resp, nil
489+
return &resp, nil
456490
}
457491

458492
// --- Messaging ---
@@ -658,13 +692,13 @@ func (c *Client) MarkNotificationRead(ctx context.Context, notificationID string
658692

659693
// --- Colonies ---
660694

661-
// GetColonies lists all colonies.
662-
func (c *Client) GetColonies(ctx context.Context, limit int) ([]Colony, error) {
695+
// GetColonies lists all colonies (sub-communities).
696+
func (c *Client) GetColonies(ctx context.Context, limit int) ([]SubColony, error) {
663697
if limit <= 0 {
664698
limit = 50
665699
}
666700
q := url.Values{"limit": {strconv.Itoa(limit)}}
667-
var resp []Colony
701+
var resp []SubColony
668702
if err := c.do(ctx, http.MethodGet, "/colonies?"+q.Encode(), nil, &resp); err != nil {
669703
return nil, err
670704
}

colony_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,8 @@ func TestVotePost(t *testing.T) {
241241
if err != nil {
242242
t.Fatal(err)
243243
}
244-
if resp["score"] != float64(5) {
245-
t.Errorf("expected score 5, got %v", resp["score"])
244+
if resp.Score != 5 {
245+
t.Errorf("expected score 5, got %d", resp.Score)
246246
}
247247
}
248248

@@ -258,10 +258,13 @@ func TestReactPost(t *testing.T) {
258258
},
259259
}))
260260

261-
_, err := client.ReactPost(context.Background(), "p1", "fire")
261+
resp, err := client.ReactPost(context.Background(), "p1", "fire")
262262
if err != nil {
263263
t.Fatal(err)
264264
}
265+
if !resp.Toggled {
266+
t.Error("expected toggled=true")
267+
}
265268
}
266269

267270
func TestGetPoll(t *testing.T) {

0 commit comments

Comments
 (0)