commit 823a0659215cc8f1b1277dab06161462457fdc31
parent dc15f71d5b581d239ad02d3a8087f617e9f8a80d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 25 Aug 2026 21:32:24 -0700
Implement Textlog API client
Diffstat:
4 files changed, 1167 insertions(+), 0 deletions(-)
diff --git a/internal/textlog/client.go b/internal/textlog/client.go
@@ -0,0 +1,500 @@
+package textlog
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+type APIError struct {
+ Code string
+ Message string
+ Status int
+ RetryAfter int
+ cause error
+}
+
+func (e *APIError) Error() string {
+ return e.Message
+}
+
+func (e *APIError) Unwrap() error {
+ return e.cause
+}
+
+func ErrorMessage(err error) string {
+ if err == nil {
+ return "Something went wrong"
+ }
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.RetryAfter > 0 {
+ return fmt.Sprintf("%s (retry in %ds)", apiErr.Message, apiErr.RetryAfter)
+ }
+ return err.Error()
+}
+
+type Client struct {
+ BaseURL string
+ Token string
+ HTTPClient *http.Client
+}
+
+func NewClient(baseURL, token string, client *http.Client) *Client {
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return &Client{
+ BaseURL: origin(baseURL),
+ Token: token,
+ HTTPClient: client,
+ }
+}
+
+func origin(baseURL string) string {
+ parsed, err := url.Parse(baseURL)
+ if err != nil {
+ return strings.TrimRight(baseURL, "/")
+ }
+ parsed.Path = ""
+ parsed.RawPath = ""
+ parsed.RawQuery = ""
+ parsed.ForceQuery = false
+ parsed.Fragment = ""
+ return strings.TrimRight(parsed.String(), "/")
+}
+
+func pathSegment(value string) string {
+ return url.PathEscape(value)
+}
+
+func paginationQuery(limit int, cursor string) string {
+ query := url.Values{"limit": {strconv.Itoa(limit)}}
+ if cursor != "" {
+ query.Set("cursor", cursor)
+ }
+ return "?" + query.Encode()
+}
+
+func repliesQuery(depth, limit int, cursor string) string {
+ query := url.Values{
+ "depth": {strconv.Itoa(depth)},
+ "limit": {strconv.Itoa(limit)},
+ }
+ if cursor != "" {
+ query.Set("cursor", cursor)
+ }
+ return "?" + query.Encode()
+}
+
+func searchQuery(term string, limit int, cursor string) string {
+ query := url.Values{
+ "q": {term},
+ "limit": {strconv.Itoa(limit)},
+ }
+ if cursor != "" {
+ query.Set("cursor", cursor)
+ }
+ return "?" + query.Encode()
+}
+
+func (c *Client) request(ctx context.Context, method, path string, body, result any) error {
+ var requestBody io.Reader
+ if body != nil {
+ encoded, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ requestBody = bytes.NewReader(encoded)
+ }
+
+ request, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api/v1"+path, requestBody)
+ if err != nil {
+ return &APIError{Code: "network_error", Message: err.Error(), cause: err}
+ }
+ request.Header.Set("Accept", "application/json")
+ if body != nil {
+ request.Header.Set("Content-Type", "application/json")
+ }
+ if c.Token != "" {
+ request.Header.Set("Authorization", "Bearer "+c.Token)
+ }
+
+ response, err := c.HTTPClient.Do(request)
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return ctxErr
+ }
+ return &APIError{Code: "network_error", Message: err.Error(), cause: err}
+ }
+ defer response.Body.Close()
+
+ data, err := io.ReadAll(response.Body)
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return ctxErr
+ }
+ return err
+ }
+ if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
+ return responseError(response, data, "http_error")
+ }
+ if result == nil || len(bytes.TrimSpace(data)) == 0 {
+ return nil
+ }
+ return json.Unmarshal(data, result)
+}
+
+func responseError(response *http.Response, data []byte, fallbackCode string) *APIError {
+ var body APIErrorBody
+ _ = json.Unmarshal(data, &body)
+ code := body.Error.Code
+ if code == "" {
+ code = fallbackCode
+ }
+ message := body.Error.Message
+ if message == "" {
+ message = fmt.Sprintf("HTTP %d", response.StatusCode)
+ }
+ retryAfter, _ := strconv.Atoi(strings.TrimSpace(response.Header.Get("Retry-After")))
+ if retryAfter < 0 {
+ retryAfter = 0
+ }
+ return &APIError{
+ Code: code,
+ Message: message,
+ Status: response.StatusCode,
+ RetryAfter: retryAfter,
+ }
+}
+
+func getCollection[T any](c *Client, ctx context.Context, path string) (Collection[T], error) {
+ var result Collection[T]
+ err := c.request(ctx, http.MethodGet, path, nil, &result)
+ return result, err
+}
+
+func getEnvelope[T any](c *Client, ctx context.Context, path string) (Envelope[T], error) {
+ var result Envelope[T]
+ err := c.request(ctx, http.MethodGet, path, nil, &result)
+ return result, err
+}
+
+func (c *Client) Latest(ctx context.Context, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/feeds/latest"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) Hot(ctx context.Context, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/feeds/hot"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) ForYou(ctx context.Context, limit int, cursor string) (ActivityCollection, error) {
+ var result ActivityCollection
+ err := c.request(ctx, http.MethodGet, "/activities/for-you"+paginationQuery(limit, cursor), nil, &result)
+ return result, err
+}
+
+func (c *Client) ToMe(ctx context.Context, limit int, cursor string) (ActivityCollection, error) {
+ var result ActivityCollection
+ err := c.request(ctx, http.MethodGet, "/activities/to-me"+paginationQuery(limit, cursor), nil, &result)
+ return result, err
+}
+
+func (c *Client) Search(ctx context.Context, term string, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/search"+searchQuery(term, limit, cursor))
+}
+
+func (c *Client) Post(ctx context.Context, id int) (Envelope[Post], error) {
+ return getEnvelope[Post](c, ctx, fmt.Sprintf("/posts/%d", id))
+}
+
+func (c *Client) Replies(ctx context.Context, id, depth, limit int, cursor string) (ReplyCollection, error) {
+ return getCollection[Reply](c, ctx, fmt.Sprintf("/posts/%d/replies", id)+repliesQuery(depth, limit, cursor))
+}
+
+func (c *Client) Profile(ctx context.Context, handle string) (Envelope[Profile], error) {
+ return getEnvelope[Profile](c, ctx, "/users/"+pathSegment(handle))
+}
+
+func (c *Client) UserNotes(ctx context.Context, handle string, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/users/"+pathSegment(handle)+"/notes"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) UserPosts(ctx context.Context, handle string, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/users/"+pathSegment(handle)+"/posts"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) UserReplies(ctx context.Context, handle string, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/users/"+pathSegment(handle)+"/replies"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) UserFollowing(ctx context.Context, handle string, limit int, cursor string) (Collection[UserReference], error) {
+ return getCollection[UserReference](c, ctx, "/users/"+pathSegment(handle)+"/following/users"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) UserFollowingTags(ctx context.Context, handle string, limit int, cursor string) (Collection[TagReference], error) {
+ return getCollection[TagReference](c, ctx, "/users/"+pathSegment(handle)+"/following/tags"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) UserFollowers(ctx context.Context, handle string, limit int, cursor string) (Collection[UserReference], error) {
+ return getCollection[UserReference](c, ctx, "/users/"+pathSegment(handle)+"/followers"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) UserBlocks(ctx context.Context, handle string, limit int, cursor string) (Collection[UserReference], error) {
+ return getCollection[UserReference](c, ctx, "/users/"+pathSegment(handle)+"/blocks"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) Tag(ctx context.Context, tag string) (Envelope[TagReference], error) {
+ return getEnvelope[TagReference](c, ctx, "/tags/"+pathSegment(tag))
+}
+
+func (c *Client) TagPosts(ctx context.Context, tag string, limit int, cursor string) (Collection[Post], error) {
+ return getCollection[Post](c, ctx, "/tags/"+pathSegment(tag)+"/posts"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) TagFollowers(ctx context.Context, tag string, limit int, cursor string) (Collection[UserReference], error) {
+ return getCollection[UserReference](c, ctx, "/tags/"+pathSegment(tag)+"/followers"+paginationQuery(limit, cursor))
+}
+
+func (c *Client) RequestCode(ctx context.Context, email string) (Envelope[SentResult], error) {
+ var result Envelope[SentResult]
+ err := c.request(ctx, http.MethodPost, "/auth/request", map[string]string{"email": email}, &result)
+ return result, err
+}
+
+func (c *Client) VerifyCode(ctx context.Context, email, code string) (Envelope[Session], error) {
+ var result Envelope[Session]
+ err := c.request(ctx, http.MethodPost, "/auth/verify", map[string]string{"email": email, "code": code}, &result)
+ return result, err
+}
+
+func (c *Client) Revoke(ctx context.Context) (Envelope[RevokedResult], error) {
+ var result Envelope[RevokedResult]
+ err := c.request(ctx, http.MethodDelete, "/auth/session", nil, &result)
+ return result, err
+}
+
+func (c *Client) Me(ctx context.Context) (Envelope[CurrentUser], error) {
+ return getEnvelope[CurrentUser](c, ctx, "/me")
+}
+
+func (c *Client) UpdateBio(ctx context.Context, bio string) (Envelope[CurrentUser], error) {
+ var result Envelope[CurrentUser]
+ err := c.request(ctx, http.MethodPatch, "/me", map[string]string{"bio": bio}, &result)
+ return result, err
+}
+
+func (c *Client) CreatePost(ctx context.Context, body string, parentID *int) (Envelope[Post], error) {
+ payload := struct {
+ Body string `json:"body"`
+ ParentID *int `json:"parent_id,omitempty"`
+ }{Body: body, ParentID: parentID}
+ var result Envelope[Post]
+ err := c.request(ctx, http.MethodPost, "/posts", payload, &result)
+ return result, err
+}
+
+func (c *Client) EditPost(ctx context.Context, id int, body string) (Envelope[Post], error) {
+ var result Envelope[Post]
+ err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/posts/%d", id), map[string]string{"body": body}, &result)
+ return result, err
+}
+
+func (c *Client) DeletePost(ctx context.Context, id int) (Envelope[DeletedResult], error) {
+ var result Envelope[DeletedResult]
+ err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/posts/%d", id), nil, &result)
+ return result, err
+}
+
+func (c *Client) Follow(ctx context.Context, handle string, enabled bool) (Envelope[FollowingResult], error) {
+ method := http.MethodPost
+ if !enabled {
+ method = http.MethodDelete
+ }
+ var result Envelope[FollowingResult]
+ err := c.request(ctx, method, "/users/"+pathSegment(handle)+"/follow", nil, &result)
+ return result, err
+}
+
+func (c *Client) Block(ctx context.Context, handle string, enabled bool) (Envelope[BlockedResult], error) {
+ method := http.MethodPost
+ if !enabled {
+ method = http.MethodDelete
+ }
+ var result Envelope[BlockedResult]
+ err := c.request(ctx, method, "/users/"+pathSegment(handle)+"/block", nil, &result)
+ return result, err
+}
+
+func (c *Client) Report(ctx context.Context, id int, reason ReportReason) (Envelope[ReportedResult], error) {
+ var result Envelope[ReportedResult]
+ err := c.request(ctx, http.MethodPost, fmt.Sprintf("/posts/%d/report", id), map[string]ReportReason{"reason": reason}, &result)
+ return result, err
+}
+
+func (c *Client) Firehose(ctx context.Context) (<-chan Post, <-chan error) {
+ posts := make(chan Post)
+ errors := make(chan error, 1)
+ go func() {
+ defer close(posts)
+ defer close(errors)
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/v1/firehose", nil)
+ if err != nil {
+ errors <- &APIError{Code: "network_error", Message: err.Error(), cause: err}
+ return
+ }
+ request.Header.Set("Accept", "text/event-stream")
+ if c.Token != "" {
+ request.Header.Set("Authorization", "Bearer "+c.Token)
+ }
+
+ response, err := c.HTTPClient.Do(request)
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ errors <- ctxErr
+ return
+ }
+ errors <- &APIError{Code: "network_error", Message: err.Error(), cause: err}
+ return
+ }
+ defer response.Body.Close()
+ if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
+ errors <- responseError(response, nil, "stream_error")
+ return
+ }
+
+ err = consumeSSE(ctx, response.Body, func(post Post) bool {
+ select {
+ case posts <- post:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+ })
+ if err != nil {
+ errors <- err
+ }
+ }()
+ return posts, errors
+}
+
+func consumeSSE(ctx context.Context, source io.Reader, emit func(Post) bool) error {
+ scanner := bufio.NewScanner(source)
+ scanner.Buffer(make([]byte, 4096), 1024*1024)
+ eventType := "message"
+ dataLines := make([]string, 0, 1)
+ firstLine := true
+
+ dispatch := func() bool {
+ defer func() {
+ eventType = "message"
+ dataLines = dataLines[:0]
+ }()
+ if eventType != "post" || len(dataLines) == 0 {
+ return true
+ }
+ post, ok := decodePostCandidate([]byte(strings.Join(dataLines, "\n")))
+ return !ok || emit(post)
+ }
+
+ for scanner.Scan() {
+ line := strings.TrimSuffix(scanner.Text(), "\r")
+ if firstLine {
+ line = strings.TrimPrefix(line, "\uFEFF")
+ firstLine = false
+ }
+ if line == "" {
+ if !dispatch() {
+ return ctx.Err()
+ }
+ continue
+ }
+ if strings.HasPrefix(line, ":") {
+ continue
+ }
+ field, value, found := strings.Cut(line, ":")
+ if !found {
+ value = ""
+ }
+ value = strings.TrimPrefix(value, " ")
+ switch field {
+ case "event":
+ eventType = value
+ case "data":
+ dataLines = append(dataLines, value)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return ctxErr
+ }
+ return err
+ }
+ if !dispatch() {
+ return ctx.Err()
+ }
+ return nil
+}
+
+func decodePostCandidate(raw []byte) (Post, bool) {
+ if post, ok := decodePost(raw); ok {
+ return post, true
+ }
+ var envelope struct {
+ Data json.RawMessage `json:"data"`
+ }
+ if json.Unmarshal(raw, &envelope) != nil || len(envelope.Data) == 0 {
+ return Post{}, false
+ }
+ return decodePost(envelope.Data)
+}
+
+func decodePost(raw []byte) (Post, bool) {
+ var required struct {
+ ID *int `json:"id"`
+ Body *string `json:"body"`
+ CreatedAt *string `json:"created_at"`
+ Author *struct {
+ Handle *string `json:"handle"`
+ } `json:"author"`
+ }
+ if json.Unmarshal(raw, &required) != nil || required.ID == nil || required.Body == nil ||
+ required.CreatedAt == nil || required.Author == nil || required.Author.Handle == nil {
+ return Post{}, false
+ }
+ var post Post
+ if json.Unmarshal(raw, &post) != nil {
+ return Post{}, false
+ }
+ return post, true
+}
+
+func IsPost(value any) bool {
+ switch typed := value.(type) {
+ case Post:
+ return !typed.CreatedAt.IsZero()
+ case *Post:
+ return typed != nil && !typed.CreatedAt.IsZero()
+ case json.RawMessage:
+ _, ok := decodePostCandidate(typed)
+ return ok
+ case []byte:
+ _, ok := decodePostCandidate(typed)
+ return ok
+ default:
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return false
+ }
+ _, ok := decodePostCandidate(raw)
+ return ok
+ }
+}
diff --git a/internal/textlog/client_test.go b/internal/textlog/client_test.go
@@ -0,0 +1,354 @@
+package textlog
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+type expectedRequest struct {
+ method string
+ path string
+ query url.Values
+ body string
+ reply string
+}
+
+func TestClientAPIContract(t *testing.T) {
+ post := `{"id":9,"top_id":1,"body":"hi","created_at":"2026-01-01T00:00:00Z","parent_id":1,"reply_count":2,"tags":["go"],"mentions":["david"],"url":"https://text.test/@me/9","api_url":"https://text.test/api/v1/posts/9","author":{"handle":"me","url":"https://text.test/@me","api_url":"https://text.test/api/v1/users/me"}}`
+ collection := `{"data":[],"pagination":{"next_cursor":null}}`
+ expectations := []expectedRequest{
+ {http.MethodGet, "/api/v1/feeds/latest", values("limit", "2", "cursor", "next one"), "", collection},
+ {http.MethodGet, "/api/v1/feeds/hot", values("limit", "3"), "", collection},
+ {http.MethodGet, "/api/v1/activities/for-you", values("limit", "4", "cursor", "c"), "", `{"data":[],"pagination":{"next_cursor":"n"},"has_unread":true}`},
+ {http.MethodGet, "/api/v1/activities/to-me", values("limit", "5"), "", `{"data":[],"pagination":{"next_cursor":null},"has_unread":false}`},
+ {http.MethodGet, "/api/v1/search", values("q", "quiet thoughts", "limit", "6", "cursor", "s"), "", collection},
+ {http.MethodGet, "/api/v1/posts/9", nil, "", `{"data":` + post + `}`},
+ {http.MethodGet, "/api/v1/posts/9/replies", values("depth", "5", "limit", "100", "cursor", "r"), "", collection},
+ {http.MethodGet, "/api/v1/users/a%2Fb%20c", nil, "", `{"data":{"handle":"a/b c","bio":"bio","created_at":"2026-01-01T00:00:00Z","post_count":1,"replies_count":2,"follower_count":3,"following_user_count":4,"following_tag_count":5,"following_count":9,"blocked_user_count":6,"blocked_tag_count":7,"url":"u","api_url":"a"}}`},
+ {http.MethodGet, "/api/v1/users/me/notes", values("limit", "7", "cursor", "n"), "", collection},
+ {http.MethodGet, "/api/v1/users/me/posts", values("limit", "8"), "", collection},
+ {http.MethodGet, "/api/v1/users/me/replies", values("limit", "9", "cursor", "r"), "", collection},
+ {http.MethodGet, "/api/v1/users/me/following/users", values("limit", "10"), "", collection},
+ {http.MethodGet, "/api/v1/users/me/following/tags", values("limit", "11", "cursor", "t"), "", collection},
+ {http.MethodGet, "/api/v1/users/me/followers", values("limit", "12"), "", collection},
+ {http.MethodGet, "/api/v1/users/me/blocks", values("limit", "13", "cursor", "b"), "", collection},
+ {http.MethodGet, "/api/v1/tags/go%2Flang", nil, "", `{"data":{"tag":"go/lang","post_count":1,"follower_count":2,"url":"u","api_url":"a"}}`},
+ {http.MethodGet, "/api/v1/tags/go/posts", values("limit", "14", "cursor", "p"), "", collection},
+ {http.MethodGet, "/api/v1/tags/go/followers", values("limit", "15"), "", collection},
+ {http.MethodPost, "/api/v1/auth/request", nil, `{"email":"me@example.test"}`, `{"data":{"sent":true}}`},
+ {http.MethodPost, "/api/v1/auth/verify", nil, `{"code":"123456","email":"me@example.test"}`, `{"data":{"token":"new-token","expires_at":"2026-02-01T00:00:00Z","user":{"handle":"me","email":"me@example.test","bio":"bio","email_verified":true,"can_post":true}}}`},
+ {http.MethodDelete, "/api/v1/auth/session", nil, "", `{"data":{"revoked":true}}`},
+ {http.MethodGet, "/api/v1/me", nil, "", `{"data":{"handle":"me","email":"me@example.test","bio":"bio","email_verified":true,"can_post":true}}`},
+ {http.MethodPatch, "/api/v1/me", nil, `{"bio":"new bio"}`, `{"data":{"handle":"me","email":"me@example.test","bio":"new bio","email_verified":true,"can_post":true}}`},
+ {http.MethodPost, "/api/v1/posts", nil, `{"body":"hi","parent_id":1}`, `{"data":` + post + `}`},
+ {http.MethodPatch, "/api/v1/posts/9", nil, `{"body":"edited"}`, `{"data":` + post + `}`},
+ {http.MethodDelete, "/api/v1/posts/9", nil, "", `{"data":{"deleted":true}}`},
+ {http.MethodPost, "/api/v1/users/a%2Fb/follow", nil, "", `{"data":{"following":true}}`},
+ {http.MethodDelete, "/api/v1/users/a%2Fb/follow", nil, "", `{"data":{"following":false}}`},
+ {http.MethodPost, "/api/v1/users/a%2Fb/block", nil, "", `{"data":{"blocked":true}}`},
+ {http.MethodDelete, "/api/v1/users/a%2Fb/block", nil, "", `{"data":{"blocked":false}}`},
+ {http.MethodPost, "/api/v1/posts/9/report", nil, `{"reason":"spam"}`, `{"data":{"reported":true}}`},
+ }
+
+ requestIndex := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if requestIndex >= len(expectations) {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.RequestURI())
+ http.Error(w, "unexpected request", http.StatusInternalServerError)
+ return
+ }
+ want := expectations[requestIndex]
+ requestIndex++
+ if got := r.Method; got != want.method {
+ t.Errorf("request %d method = %q, want %q", requestIndex, got, want.method)
+ }
+ if got := r.URL.EscapedPath(); got != want.path {
+ t.Errorf("request %d path = %q, want %q", requestIndex, got, want.path)
+ }
+ if got := r.URL.Query(); len(got) != len(want.query) || (len(got) > 0 && !reflect.DeepEqual(got, want.query)) {
+ t.Errorf("request %d query = %#v, want %#v", requestIndex, got, want.query)
+ }
+ if got := r.Header.Get("Accept"); got != "application/json" {
+ t.Errorf("request %d Accept = %q", requestIndex, got)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer secret" {
+ t.Errorf("request %d Authorization = %q", requestIndex, got)
+ }
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("request %d body: %v", requestIndex, err)
+ }
+ if got := strings.TrimSpace(string(body)); got != want.body {
+ t.Errorf("request %d body = %q, want %q", requestIndex, got, want.body)
+ }
+ wantContentType := ""
+ if want.body != "" {
+ wantContentType = "application/json"
+ }
+ if got := r.Header.Get("Content-Type"); got != wantContentType {
+ t.Errorf("request %d Content-Type = %q, want %q", requestIndex, got, wantContentType)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(w, want.reply)
+ }))
+ defer server.Close()
+
+ ctx := context.Background()
+ api := NewClient(server.URL+"/ignored/base/path", "secret", server.Client())
+ _, err := api.Latest(ctx, 2, "next one")
+ mustSucceed(t, err)
+ _, err = api.Hot(ctx, 3, "")
+ mustSucceed(t, err)
+ forYou, err := api.ForYou(ctx, 4, "c")
+ mustSucceed(t, err)
+ if !forYou.HasUnread || forYou.Pagination.NextCursor == nil || *forYou.Pagination.NextCursor != "n" {
+ t.Fatalf("for-you response = %#v", forYou)
+ }
+ _, err = api.ToMe(ctx, 5, "")
+ mustSucceed(t, err)
+ _, err = api.Search(ctx, "quiet thoughts", 6, "s")
+ mustSucceed(t, err)
+ gotPost, err := api.Post(ctx, 9)
+ mustSucceed(t, err)
+ if gotPost.Data.ID != 9 || !gotPost.Data.CreatedAt.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
+ t.Fatalf("post response = %#v", gotPost)
+ }
+ _, err = api.Replies(ctx, 9, 5, 100, "r")
+ mustSucceed(t, err)
+ _, err = api.Profile(ctx, "a/b c")
+ mustSucceed(t, err)
+ _, err = api.UserNotes(ctx, "me", 7, "n")
+ mustSucceed(t, err)
+ _, err = api.UserPosts(ctx, "me", 8, "")
+ mustSucceed(t, err)
+ _, err = api.UserReplies(ctx, "me", 9, "r")
+ mustSucceed(t, err)
+ _, err = api.UserFollowing(ctx, "me", 10, "")
+ mustSucceed(t, err)
+ _, err = api.UserFollowingTags(ctx, "me", 11, "t")
+ mustSucceed(t, err)
+ _, err = api.UserFollowers(ctx, "me", 12, "")
+ mustSucceed(t, err)
+ _, err = api.UserBlocks(ctx, "me", 13, "b")
+ mustSucceed(t, err)
+ _, err = api.Tag(ctx, "go/lang")
+ mustSucceed(t, err)
+ _, err = api.TagPosts(ctx, "go", 14, "p")
+ mustSucceed(t, err)
+ _, err = api.TagFollowers(ctx, "go", 15, "")
+ mustSucceed(t, err)
+ sent, err := api.RequestCode(ctx, "me@example.test")
+ mustSucceed(t, err)
+ if !sent.Data.Sent {
+ t.Fatal("request-code response did not decode")
+ }
+ session, err := api.VerifyCode(ctx, "me@example.test", "123456")
+ mustSucceed(t, err)
+ if session.Data.Token != "new-token" || session.Data.User.Handle != "me" {
+ t.Fatalf("verify response = %#v", session)
+ }
+ revoked, err := api.Revoke(ctx)
+ mustSucceed(t, err)
+ if !revoked.Data.Revoked {
+ t.Fatal("revoke response did not decode")
+ }
+ _, err = api.Me(ctx)
+ mustSucceed(t, err)
+ _, err = api.UpdateBio(ctx, "new bio")
+ mustSucceed(t, err)
+ parentID := 1
+ _, err = api.CreatePost(ctx, "hi", &parentID)
+ mustSucceed(t, err)
+ _, err = api.EditPost(ctx, 9, "edited")
+ mustSucceed(t, err)
+ deleted, err := api.DeletePost(ctx, 9)
+ mustSucceed(t, err)
+ if !deleted.Data.Deleted {
+ t.Fatal("delete response did not decode")
+ }
+ following, err := api.Follow(ctx, "a/b", true)
+ mustSucceed(t, err)
+ if !following.Data.Following {
+ t.Fatal("follow response did not decode")
+ }
+ _, err = api.Follow(ctx, "a/b", false)
+ mustSucceed(t, err)
+ blocked, err := api.Block(ctx, "a/b", true)
+ mustSucceed(t, err)
+ if !blocked.Data.Blocked {
+ t.Fatal("block response did not decode")
+ }
+ _, err = api.Block(ctx, "a/b", false)
+ mustSucceed(t, err)
+ reported, err := api.Report(ctx, 9, ReportSpam)
+ mustSucceed(t, err)
+ if !reported.Data.Reported {
+ t.Fatal("report response did not decode")
+ }
+
+ if requestIndex != len(expectations) {
+ t.Fatalf("received %d requests, want %d", requestIndex, len(expectations))
+ }
+}
+
+func TestClientErrorsAndContext(t *testing.T) {
+ t.Run("structured HTTP error", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Retry-After", "12")
+ w.WriteHeader(http.StatusTooManyRequests)
+ fmt.Fprint(w, `{"error":{"code":"rate_limited","message":"Slow down"}}`)
+ }))
+ defer server.Close()
+
+ _, err := NewClient(server.URL, "", server.Client()).Latest(context.Background(), 20, "")
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("error = %T %v, want *APIError", err, err)
+ }
+ if apiErr.Code != "rate_limited" || apiErr.Message != "Slow down" || apiErr.Status != http.StatusTooManyRequests || apiErr.RetryAfter != 12 {
+ t.Fatalf("API error = %#v", apiErr)
+ }
+ if got := ErrorMessage(err); got != "Slow down (retry in 12s)" {
+ t.Fatalf("ErrorMessage = %q", got)
+ }
+ })
+
+ t.Run("invalid error response", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ fmt.Fprint(w, "not json")
+ }))
+ defer server.Close()
+
+ _, err := NewClient(server.URL, "", server.Client()).Hot(context.Background(), 20, "")
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) || apiErr.Code != "http_error" || apiErr.Message != "HTTP 502" {
+ t.Fatalf("error = %#v", err)
+ }
+ })
+
+ t.Run("network and cancellation errors", func(t *testing.T) {
+ api := NewClient("http://example.test", "", &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("dial failed")
+ })})
+ _, err := api.Me(context.Background())
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) || apiErr.Code != "network_error" || apiErr.Status != 0 {
+ t.Fatalf("network error = %#v", err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err = NewClient("http://example.test", "", http.DefaultClient).Me(ctx)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("canceled request error = %T %v", err, err)
+ }
+ })
+}
+
+func TestFirehoseParsesChunkedSSEAndSkipsMalformedEvents(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Accept"); got != "text/event-stream" {
+ t.Errorf("Accept = %q", got)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer secret" {
+ t.Errorf("Authorization = %q", got)
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher := w.(http.Flusher)
+ fmt.Fprint(w, ": welcome\r\nevent: ready\r\ndata: {}\r\n\r\nevent: post\r\ndata: {\"id\":1,")
+ flusher.Flush()
+ fmt.Fprint(w, "\"body\":\"first\",\r\ndata: \"created_at\":\"2026-01-01T00:00:00Z\",\"author\":{\"handle\":\"david\"}}\r\n\r\n")
+ fmt.Fprint(w, "event: post\ndata: {bad}\n\nevent: post\ndata: {\"data\":{\"id\":2,\"body\":\"second\",\"created_at\":\"2026-01-02T00:00:00Z\",\"author\":{\"handle\":\"amy\"}}}\n\n")
+ fmt.Fprint(w, "data: {\"id\":3,\"body\":\"wrong event\",\"created_at\":\"2026-01-03T00:00:00Z\",\"author\":{\"handle\":\"x\"}}\n\n")
+ fmt.Fprint(w, "event: post\ndata: {\"id\":4,\"body\":\"final\",\"created_at\":\"2026-01-04T00:00:00Z\",\"author\":{\"handle\":\"zoe\"}}")
+ }))
+ defer server.Close()
+
+ posts, errs := NewClient(server.URL, "secret", server.Client()).Firehose(context.Background())
+ var got []Post
+ for post := range posts {
+ got = append(got, post)
+ }
+ if err := <-errs; err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("posts = %#v", got)
+ }
+ if gotIDs := []int{got[0].ID, got[1].ID, got[2].ID}; !reflect.DeepEqual(gotIDs, []int{1, 2, 4}) {
+ t.Fatalf("post IDs = %v", gotIDs)
+ }
+}
+
+func TestFirehoseReportsHTTPAndCancellationErrors(t *testing.T) {
+ t.Run("HTTP error", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer server.Close()
+
+ posts, errs := NewClient(server.URL, "", server.Client()).Firehose(context.Background())
+ if _, ok := <-posts; ok {
+ t.Fatal("unexpected post")
+ }
+ var apiErr *APIError
+ if err := <-errs; !errors.As(err, &apiErr) || apiErr.Code != "stream_error" || apiErr.Status != http.StatusServiceUnavailable {
+ t.Fatalf("stream error = %#v", err)
+ }
+ })
+
+ t.Run("cancellation", func(t *testing.T) {
+ started := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ close(started)
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ posts, errs := NewClient(server.URL, "", server.Client()).Firehose(ctx)
+ <-started
+ cancel()
+ for range posts {
+ }
+ if err := <-errs; !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation error = %T %v", err, err)
+ }
+ })
+}
+
+func values(entries ...string) url.Values {
+ result := make(url.Values, len(entries)/2)
+ for index := 0; index < len(entries); index += 2 {
+ result.Set(entries[index], entries[index+1])
+ }
+ return result
+}
+
+func mustSucceed(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return fn(request)
+}
+
+var _ http.RoundTripper = roundTripFunc(nil)
diff --git a/internal/textlog/types.go b/internal/textlog/types.go
@@ -0,0 +1,230 @@
+package textlog
+
+import (
+ "encoding/json"
+ "time"
+)
+
+type ThemeName string
+
+const (
+ ThemeAuto ThemeName = "auto"
+ ThemeLight ThemeName = "light"
+ ThemeDark ThemeName = "dark"
+)
+
+type Post struct {
+ ID int `json:"id"`
+ TopID *int `json:"top_id"`
+ Body string `json:"body"`
+ CreatedAt time.Time `json:"created_at"`
+ ParentID *int `json:"parent_id"`
+ ReplyCount int `json:"reply_count"`
+ Tags []string `json:"tags"`
+ Mentions []string `json:"mentions"`
+ URL string `json:"url"`
+ APIURL string `json:"api_url"`
+ Author UserReference `json:"author"`
+ Parent *Post `json:"parent,omitempty"`
+}
+
+type Reply struct {
+ Post
+ Depth int `json:"depth"`
+}
+
+type Profile struct {
+ Handle string `json:"handle"`
+ Bio string `json:"bio"`
+ CreatedAt time.Time `json:"created_at"`
+ PostCount int `json:"post_count"`
+ RepliesCount int `json:"replies_count"`
+ FollowerCount int `json:"follower_count"`
+ FollowingUserCount int `json:"following_user_count"`
+ FollowingTagCount int `json:"following_tag_count"`
+ FollowingCount int `json:"following_count"`
+ BlockedUserCount *int `json:"blocked_user_count,omitempty"`
+ BlockedTagCount *int `json:"blocked_tag_count,omitempty"`
+ URL string `json:"url"`
+ APIURL string `json:"api_url"`
+}
+
+type UserReference struct {
+ Handle string `json:"handle"`
+ URL string `json:"url"`
+ APIURL string `json:"api_url"`
+}
+
+type TagReference struct {
+ Tag string `json:"tag"`
+ PostCount int `json:"post_count"`
+ FollowerCount int `json:"follower_count"`
+ URL string `json:"url"`
+ APIURL string `json:"api_url"`
+}
+
+type CurrentUser struct {
+ Handle string `json:"handle"`
+ Email string `json:"email"`
+ Bio string `json:"bio"`
+ EmailVerified bool `json:"email_verified"`
+ CanPost bool `json:"can_post"`
+}
+
+type Pagination struct {
+ NextCursor *string `json:"next_cursor"`
+}
+
+type Collection[T any] struct {
+ Data []T `json:"data"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type ActivityType string
+
+const (
+ ActivityPost ActivityType = "post"
+ ActivityReply ActivityType = "reply"
+ ActivityMention ActivityType = "mention"
+ ActivityUserFollow ActivityType = "user_follow"
+ ActivityTagFollow ActivityType = "tag_follow"
+ ActivitySignup ActivityType = "signup"
+)
+
+type Activity struct {
+ ID string `json:"id"`
+ Type ActivityType `json:"type"`
+ CreatedAt time.Time `json:"created_at"`
+ Unread bool `json:"unread"`
+ Payload json.RawMessage `json:"payload"`
+}
+
+func (a Activity) Post() (Post, bool) {
+ return decodePostCandidate(a.Payload)
+}
+
+type ActivityCollection struct {
+ Data []Activity `json:"data"`
+ Pagination Pagination `json:"pagination"`
+ HasUnread bool `json:"has_unread"`
+}
+
+type ReplyCollection = Collection[Reply]
+
+type Envelope[T any] struct {
+ Data T `json:"data"`
+}
+
+type Session struct {
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
+ User CurrentUser `json:"user"`
+}
+
+type ReportReason string
+
+const (
+ ReportHarassment ReportReason = "harassment"
+ ReportSpam ReportReason = "spam"
+ ReportImpersonation ReportReason = "impersonation"
+ ReportOther ReportReason = "other"
+)
+
+type SentResult struct {
+ Sent bool `json:"sent"`
+}
+
+type RevokedResult struct {
+ Revoked bool `json:"revoked"`
+}
+
+type DeletedResult struct {
+ Deleted bool `json:"deleted"`
+}
+
+type FollowingResult struct {
+ Following bool `json:"following"`
+}
+
+type BlockedResult struct {
+ Blocked bool `json:"blocked"`
+}
+
+type ReportedResult struct {
+ Reported bool `json:"reported"`
+}
+
+type APIErrorBody struct {
+ Error APIErrorDetail `json:"error"`
+}
+
+type APIErrorDetail struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+type FeedKind string
+
+const (
+ FeedForYou FeedKind = "for-you"
+ FeedToMe FeedKind = "to-me"
+ FeedHot FeedKind = "hot"
+ FeedLatest FeedKind = "latest"
+)
+
+type ScreenKind string
+
+const (
+ ScreenFeed ScreenKind = "feed"
+ ScreenSearch ScreenKind = "search"
+ ScreenPost ScreenKind = "post"
+ ScreenProfile ScreenKind = "profile"
+ ScreenTag ScreenKind = "tag"
+ ScreenLive ScreenKind = "live"
+ ScreenCompose ScreenKind = "compose"
+ ScreenLogin ScreenKind = "login"
+ ScreenAccount ScreenKind = "account"
+ ScreenSettings ScreenKind = "settings"
+ ScreenHelp ScreenKind = "help"
+)
+
+// Screen contains the fields used by the corresponding discriminated union
+// member in the reference client. Kind determines which optional fields apply.
+type Screen struct {
+ Kind ScreenKind `json:"kind"`
+ Feed FeedKind `json:"feed,omitempty"`
+ Query string `json:"query,omitempty"`
+ ID int `json:"id,omitempty"`
+ Handle string `json:"handle,omitempty"`
+ Tag string `json:"tag,omitempty"`
+ Parent *Post `json:"parent,omitempty"`
+ Edit *Post `json:"edit,omitempty"`
+}
+
+type Status struct {
+ Text string `json:"text"`
+ Error bool `json:"error,omitempty"`
+}
+
+type AppState struct {
+ Stack []Screen `json:"stack"`
+ Status *Status `json:"status,omitempty"`
+}
+
+type AppActionType string
+
+const (
+ ActionPush AppActionType = "push"
+ ActionReplace AppActionType = "replace"
+ ActionBack AppActionType = "back"
+ ActionStatus AppActionType = "status"
+)
+
+// AppAction contains the fields used by the corresponding discriminated union
+// member in the reference client. Type determines which optional fields apply.
+type AppAction struct {
+ Type AppActionType `json:"type"`
+ Screen *Screen `json:"screen,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Error bool `json:"error,omitempty"`
+}
diff --git a/internal/textlog/types_test.go b/internal/textlog/types_test.go
@@ -0,0 +1,83 @@
+package textlog
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestWireTypesDecodeReferenceShapes(t *testing.T) {
+ raw := `{
+ "data": [{
+ "id": "activity-1",
+ "type": "mention",
+ "created_at": "2026-01-02T03:04:05Z",
+ "unread": true,
+ "payload": {"id": 7, "body": "hello", "created_at": "2026-01-01T00:00:00Z", "author": {"handle": "david"}}
+ }],
+ "pagination": {"next_cursor": "next"},
+ "has_unread": true
+ }`
+ var collection ActivityCollection
+ if err := json.Unmarshal([]byte(raw), &collection); err != nil {
+ t.Fatal(err)
+ }
+ if len(collection.Data) != 1 || collection.Data[0].Type != ActivityMention || !collection.Data[0].Unread {
+ t.Fatalf("activity collection = %#v", collection)
+ }
+ if got := collection.Data[0].CreatedAt; !got.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
+ t.Fatalf("created_at = %v", got)
+ }
+ post, ok := collection.Data[0].Post()
+ if !ok || post.ID != 7 || post.Author.Handle != "david" {
+ t.Fatalf("activity post = %#v, %v", post, ok)
+ }
+ if got := collection.Pagination.NextCursor; got == nil || *got != "next" || !collection.HasUnread {
+ t.Fatalf("pagination = %#v, has_unread = %v", collection.Pagination, collection.HasUnread)
+ }
+
+ replyRaw := `{"id":8,"body":"reply","created_at":"2026-01-01T00:00:00Z","author":{"handle":"amy"},"depth":3}`
+ var reply Reply
+ if err := json.Unmarshal([]byte(replyRaw), &reply); err != nil {
+ t.Fatal(err)
+ }
+ if reply.ID != 8 || reply.Depth != 3 {
+ t.Fatalf("reply = %#v", reply)
+ }
+
+ postRaw, err := json.Marshal(Post{ID: 9, ParentID: intPointer(1), CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]any
+ if err := json.Unmarshal(postRaw, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(fields["parent_id"], float64(1)) || fields["created_at"] != "2026-01-01T00:00:00Z" {
+ t.Fatalf("marshaled post = %s", postRaw)
+ }
+}
+
+func TestReferenceEnumValues(t *testing.T) {
+ if got := []ThemeName{ThemeAuto, ThemeLight, ThemeDark}; !reflect.DeepEqual(got, []ThemeName{"auto", "light", "dark"}) {
+ t.Fatalf("themes = %v", got)
+ }
+ if got := []FeedKind{FeedForYou, FeedToMe, FeedHot, FeedLatest}; !reflect.DeepEqual(got, []FeedKind{"for-you", "to-me", "hot", "latest"}) {
+ t.Fatalf("feeds = %v", got)
+ }
+ if got := []ReportReason{ReportHarassment, ReportSpam, ReportImpersonation, ReportOther}; !reflect.DeepEqual(got, []ReportReason{"harassment", "spam", "impersonation", "other"}) {
+ t.Fatalf("report reasons = %v", got)
+ }
+ if got := []ActivityType{ActivityPost, ActivityReply, ActivityMention, ActivityUserFollow, ActivityTagFollow, ActivitySignup}; !reflect.DeepEqual(got, []ActivityType{"post", "reply", "mention", "user_follow", "tag_follow", "signup"}) {
+ t.Fatalf("activity types = %v", got)
+ }
+ if got := []ScreenKind{ScreenFeed, ScreenSearch, ScreenPost, ScreenProfile, ScreenTag, ScreenLive, ScreenCompose, ScreenLogin, ScreenAccount, ScreenSettings, ScreenHelp}; len(got) != 11 {
+ t.Fatalf("screen kinds = %v", got)
+ }
+ if got := []AppActionType{ActionPush, ActionReplace, ActionBack, ActionStatus}; len(got) != 4 {
+ t.Fatalf("action types = %v", got)
+ }
+}
+
+func intPointer(value int) *int { return &value }