gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs

commit 823a0659215cc8f1b1277dab06161462457fdc31
parent dc15f71d5b581d239ad02d3a8087f617e9f8a80d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 25 Aug 2026 21:32:24 -0700

Implement Textlog API client

Diffstat:
Ainternal/textlog/client.go | 500+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/textlog/client_test.go | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/textlog/types.go | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/textlog/types_test.go | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 }