client.go (15626B)
1 package textlog 2 3 import ( 4 "bufio" 5 "bytes" 6 "context" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "net/http" 12 "net/url" 13 "strconv" 14 "strings" 15 ) 16 17 type APIError struct { 18 Code string 19 Message string 20 Status int 21 RetryAfter int 22 cause error 23 } 24 25 func (e *APIError) Error() string { 26 return e.Message 27 } 28 29 func (e *APIError) Unwrap() error { 30 return e.cause 31 } 32 33 func ErrorMessage(err error) string { 34 if err == nil { 35 return "Something went wrong" 36 } 37 var apiErr *APIError 38 if errors.As(err, &apiErr) && apiErr.RetryAfter > 0 { 39 return fmt.Sprintf("%s (retry in %ds)", apiErr.Message, apiErr.RetryAfter) 40 } 41 return err.Error() 42 } 43 44 type Client struct { 45 BaseURL string 46 Token string 47 HTTPClient *http.Client 48 } 49 50 func NewClient(baseURL, token string, client *http.Client) *Client { 51 if client == nil { 52 client = http.DefaultClient 53 } 54 return &Client{ 55 BaseURL: origin(baseURL), 56 Token: token, 57 HTTPClient: client, 58 } 59 } 60 61 func origin(baseURL string) string { 62 parsed, err := url.Parse(baseURL) 63 if err != nil { 64 return strings.TrimRight(baseURL, "/") 65 } 66 parsed.Path = "" 67 parsed.RawPath = "" 68 parsed.RawQuery = "" 69 parsed.ForceQuery = false 70 parsed.Fragment = "" 71 return strings.TrimRight(parsed.String(), "/") 72 } 73 74 func pathSegment(value string) string { 75 return url.PathEscape(value) 76 } 77 78 func paginationQuery(limit int, cursor string) string { 79 query := url.Values{"limit": {strconv.Itoa(limit)}} 80 if cursor != "" { 81 query.Set("cursor", cursor) 82 } 83 return "?" + query.Encode() 84 } 85 86 func repliesQuery(depth, limit int, cursor string) string { 87 query := url.Values{ 88 "depth": {strconv.Itoa(depth)}, 89 "limit": {strconv.Itoa(limit)}, 90 } 91 if cursor != "" { 92 query.Set("cursor", cursor) 93 } 94 return "?" + query.Encode() 95 } 96 97 func searchQuery(term string, limit int, cursor string) string { 98 query := url.Values{ 99 "q": {term}, 100 "limit": {strconv.Itoa(limit)}, 101 } 102 if cursor != "" { 103 query.Set("cursor", cursor) 104 } 105 return "?" + query.Encode() 106 } 107 108 func (c *Client) request(ctx context.Context, method, path string, body, result any) error { 109 var requestBody io.Reader 110 if body != nil { 111 encoded, err := json.Marshal(body) 112 if err != nil { 113 return err 114 } 115 requestBody = bytes.NewReader(encoded) 116 } 117 118 request, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api/v1"+path, requestBody) 119 if err != nil { 120 return &APIError{Code: "network_error", Message: err.Error(), cause: err} 121 } 122 request.Header.Set("Accept", "application/json") 123 if body != nil { 124 request.Header.Set("Content-Type", "application/json") 125 } 126 if c.Token != "" { 127 request.Header.Set("Authorization", "Bearer "+c.Token) 128 } 129 130 response, err := c.HTTPClient.Do(request) 131 if err != nil { 132 if ctxErr := ctx.Err(); ctxErr != nil { 133 return ctxErr 134 } 135 return &APIError{Code: "network_error", Message: err.Error(), cause: err} 136 } 137 defer response.Body.Close() 138 139 data, err := io.ReadAll(response.Body) 140 if err != nil { 141 if ctxErr := ctx.Err(); ctxErr != nil { 142 return ctxErr 143 } 144 return err 145 } 146 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { 147 return responseError(response, data, "http_error") 148 } 149 if result == nil || len(bytes.TrimSpace(data)) == 0 { 150 return nil 151 } 152 return json.Unmarshal(data, result) 153 } 154 155 func responseError(response *http.Response, data []byte, fallbackCode string) *APIError { 156 var body APIErrorBody 157 _ = json.Unmarshal(data, &body) 158 code := body.Error.Code 159 if code == "" { 160 code = fallbackCode 161 } 162 message := body.Error.Message 163 if message == "" { 164 message = fmt.Sprintf("HTTP %d", response.StatusCode) 165 } 166 retryAfter, _ := strconv.Atoi(strings.TrimSpace(response.Header.Get("Retry-After"))) 167 if retryAfter < 0 { 168 retryAfter = 0 169 } 170 return &APIError{ 171 Code: code, 172 Message: message, 173 Status: response.StatusCode, 174 RetryAfter: retryAfter, 175 } 176 } 177 178 func getCollection[T any](c *Client, ctx context.Context, path string) (Collection[T], error) { 179 var result Collection[T] 180 err := c.request(ctx, http.MethodGet, path, nil, &result) 181 return result, err 182 } 183 184 func getEnvelope[T any](c *Client, ctx context.Context, path string) (Envelope[T], error) { 185 var result Envelope[T] 186 err := c.request(ctx, http.MethodGet, path, nil, &result) 187 return result, err 188 } 189 190 func (c *Client) Latest(ctx context.Context, limit int, cursor string) (Collection[Post], error) { 191 return getCollection[Post](c, ctx, "/feeds/latest"+paginationQuery(limit, cursor)) 192 } 193 194 func (c *Client) Hot(ctx context.Context, limit int, cursor string) (Collection[Post], error) { 195 return getCollection[Post](c, ctx, "/feeds/hot"+paginationQuery(limit, cursor)) 196 } 197 198 func (c *Client) ForYou(ctx context.Context, limit int, cursor string) (ActivityCollection, error) { 199 var result ActivityCollection 200 err := c.request(ctx, http.MethodGet, "/activities/for-you"+paginationQuery(limit, cursor), nil, &result) 201 return result, err 202 } 203 204 func (c *Client) ToMe(ctx context.Context, limit int, cursor string) (ActivityCollection, error) { 205 var result ActivityCollection 206 err := c.request(ctx, http.MethodGet, "/activities/to-me"+paginationQuery(limit, cursor), nil, &result) 207 return result, err 208 } 209 210 func (c *Client) Search(ctx context.Context, term string, limit int, cursor string) (Collection[Post], error) { 211 return getCollection[Post](c, ctx, "/search"+searchQuery(term, limit, cursor)) 212 } 213 214 func (c *Client) Post(ctx context.Context, id int) (Envelope[Post], error) { 215 return getEnvelope[Post](c, ctx, fmt.Sprintf("/posts/%d", id)) 216 } 217 218 func (c *Client) Replies(ctx context.Context, id, depth, limit int, cursor string) (ReplyCollection, error) { 219 return getCollection[Reply](c, ctx, fmt.Sprintf("/posts/%d/replies", id)+repliesQuery(depth, limit, cursor)) 220 } 221 222 func (c *Client) Profile(ctx context.Context, handle string) (Envelope[Profile], error) { 223 return getEnvelope[Profile](c, ctx, "/users/"+pathSegment(handle)) 224 } 225 226 func (c *Client) UserNotes(ctx context.Context, handle string, limit int, cursor string) (Collection[Post], error) { 227 return getCollection[Post](c, ctx, "/users/"+pathSegment(handle)+"/notes"+paginationQuery(limit, cursor)) 228 } 229 230 func (c *Client) UserPosts(ctx context.Context, handle string, limit int, cursor string) (Collection[Post], error) { 231 return getCollection[Post](c, ctx, "/users/"+pathSegment(handle)+"/posts"+paginationQuery(limit, cursor)) 232 } 233 234 func (c *Client) UserReplies(ctx context.Context, handle string, limit int, cursor string) (Collection[Post], error) { 235 return getCollection[Post](c, ctx, "/users/"+pathSegment(handle)+"/replies"+paginationQuery(limit, cursor)) 236 } 237 238 func (c *Client) UserFollowing(ctx context.Context, handle string, limit int, cursor string) (Collection[UserReference], error) { 239 return getCollection[UserReference](c, ctx, "/users/"+pathSegment(handle)+"/following/users"+paginationQuery(limit, cursor)) 240 } 241 242 func (c *Client) UserFollowingTags(ctx context.Context, handle string, limit int, cursor string) (Collection[TagReference], error) { 243 return getCollection[TagReference](c, ctx, "/users/"+pathSegment(handle)+"/following/tags"+paginationQuery(limit, cursor)) 244 } 245 246 func (c *Client) UserFollowers(ctx context.Context, handle string, limit int, cursor string) (Collection[UserReference], error) { 247 return getCollection[UserReference](c, ctx, "/users/"+pathSegment(handle)+"/followers"+paginationQuery(limit, cursor)) 248 } 249 250 func (c *Client) UserBlocks(ctx context.Context, handle string, limit int, cursor string) (Collection[UserReference], error) { 251 return getCollection[UserReference](c, ctx, "/users/"+pathSegment(handle)+"/blocks"+paginationQuery(limit, cursor)) 252 } 253 254 func (c *Client) Tag(ctx context.Context, tag string) (Envelope[TagReference], error) { 255 return getEnvelope[TagReference](c, ctx, "/tags/"+pathSegment(tag)) 256 } 257 258 func (c *Client) TagPosts(ctx context.Context, tag string, limit int, cursor string) (Collection[Post], error) { 259 return getCollection[Post](c, ctx, "/tags/"+pathSegment(tag)+"/posts"+paginationQuery(limit, cursor)) 260 } 261 262 func (c *Client) TagFollowers(ctx context.Context, tag string, limit int, cursor string) (Collection[UserReference], error) { 263 return getCollection[UserReference](c, ctx, "/tags/"+pathSegment(tag)+"/followers"+paginationQuery(limit, cursor)) 264 } 265 266 func (c *Client) RequestCode(ctx context.Context, email string) (Envelope[SentResult], error) { 267 var result Envelope[SentResult] 268 err := c.request(ctx, http.MethodPost, "/auth/request", map[string]string{"email": email}, &result) 269 return result, err 270 } 271 272 func (c *Client) VerifyCode(ctx context.Context, email, code string) (Envelope[Session], error) { 273 var result Envelope[Session] 274 err := c.request(ctx, http.MethodPost, "/auth/verify", map[string]string{"email": email, "code": code}, &result) 275 return result, err 276 } 277 278 func (c *Client) RequestKeyChallenge(ctx context.Context, publicKey, handle string) (Envelope[KeyChallenge], error) { 279 payload := struct { 280 PublicKey string `json:"public_key"` 281 Handle string `json:"handle,omitempty"` 282 }{PublicKey: publicKey, Handle: handle} 283 var result Envelope[KeyChallenge] 284 err := c.request(ctx, http.MethodPost, "/auth/key/challenge", payload, &result) 285 return result, err 286 } 287 288 func (c *Client) VerifyKey(ctx context.Context, challengeID string, signature []byte) (Envelope[Session], error) { 289 payload := struct { 290 ChallengeID string `json:"challenge_id"` 291 Signature []byte `json:"signature"` 292 }{ChallengeID: challengeID, Signature: signature} 293 var result Envelope[Session] 294 err := c.request(ctx, http.MethodPost, "/auth/key/verify", payload, &result) 295 return result, err 296 } 297 298 func (c *Client) Revoke(ctx context.Context) (Envelope[RevokedResult], error) { 299 var result Envelope[RevokedResult] 300 err := c.request(ctx, http.MethodDelete, "/auth/session", nil, &result) 301 return result, err 302 } 303 304 func (c *Client) Me(ctx context.Context) (Envelope[CurrentUser], error) { 305 return getEnvelope[CurrentUser](c, ctx, "/me") 306 } 307 308 func (c *Client) UpdateBio(ctx context.Context, bio string) (Envelope[CurrentUser], error) { 309 var result Envelope[CurrentUser] 310 err := c.request(ctx, http.MethodPatch, "/me", map[string]string{"bio": bio}, &result) 311 return result, err 312 } 313 314 func (c *Client) CreatePost(ctx context.Context, body string, parentID *int) (Envelope[Post], error) { 315 payload := struct { 316 Body string `json:"body"` 317 ParentID *int `json:"parent_id,omitempty"` 318 }{Body: body, ParentID: parentID} 319 var result Envelope[Post] 320 err := c.request(ctx, http.MethodPost, "/posts", payload, &result) 321 return result, err 322 } 323 324 func (c *Client) EditPost(ctx context.Context, id int, body string) (Envelope[Post], error) { 325 var result Envelope[Post] 326 err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/posts/%d", id), map[string]string{"body": body}, &result) 327 return result, err 328 } 329 330 func (c *Client) DeletePost(ctx context.Context, id int) (Envelope[DeletedResult], error) { 331 var result Envelope[DeletedResult] 332 err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/posts/%d", id), nil, &result) 333 return result, err 334 } 335 336 func (c *Client) Follow(ctx context.Context, handle string, enabled bool) (Envelope[FollowingResult], error) { 337 method := http.MethodPost 338 if !enabled { 339 method = http.MethodDelete 340 } 341 var result Envelope[FollowingResult] 342 err := c.request(ctx, method, "/users/"+pathSegment(handle)+"/follow", nil, &result) 343 return result, err 344 } 345 346 func (c *Client) Block(ctx context.Context, handle string, enabled bool) (Envelope[BlockedResult], error) { 347 method := http.MethodPost 348 if !enabled { 349 method = http.MethodDelete 350 } 351 var result Envelope[BlockedResult] 352 err := c.request(ctx, method, "/users/"+pathSegment(handle)+"/block", nil, &result) 353 return result, err 354 } 355 356 func (c *Client) Report(ctx context.Context, id int, reason ReportReason) (Envelope[ReportedResult], error) { 357 var result Envelope[ReportedResult] 358 err := c.request(ctx, http.MethodPost, fmt.Sprintf("/posts/%d/report", id), map[string]ReportReason{"reason": reason}, &result) 359 return result, err 360 } 361 362 func (c *Client) Firehose(ctx context.Context) (<-chan Post, <-chan error) { 363 posts := make(chan Post) 364 errors := make(chan error, 1) 365 go func() { 366 defer close(posts) 367 defer close(errors) 368 369 request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/v1/firehose", nil) 370 if err != nil { 371 errors <- &APIError{Code: "network_error", Message: err.Error(), cause: err} 372 return 373 } 374 request.Header.Set("Accept", "text/event-stream") 375 if c.Token != "" { 376 request.Header.Set("Authorization", "Bearer "+c.Token) 377 } 378 379 response, err := c.HTTPClient.Do(request) 380 if err != nil { 381 if ctxErr := ctx.Err(); ctxErr != nil { 382 errors <- ctxErr 383 return 384 } 385 errors <- &APIError{Code: "network_error", Message: err.Error(), cause: err} 386 return 387 } 388 defer response.Body.Close() 389 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { 390 errors <- responseError(response, nil, "stream_error") 391 return 392 } 393 394 err = consumeSSE(ctx, response.Body, func(post Post) bool { 395 select { 396 case posts <- post: 397 return true 398 case <-ctx.Done(): 399 return false 400 } 401 }) 402 if err != nil { 403 errors <- err 404 } 405 }() 406 return posts, errors 407 } 408 409 func consumeSSE(ctx context.Context, source io.Reader, emit func(Post) bool) error { 410 scanner := bufio.NewScanner(source) 411 scanner.Buffer(make([]byte, 4096), 1024*1024) 412 eventType := "message" 413 dataLines := make([]string, 0, 1) 414 firstLine := true 415 416 dispatch := func() bool { 417 defer func() { 418 eventType = "message" 419 dataLines = dataLines[:0] 420 }() 421 if eventType != "post" || len(dataLines) == 0 { 422 return true 423 } 424 post, ok := decodePostCandidate([]byte(strings.Join(dataLines, "\n"))) 425 return !ok || emit(post) 426 } 427 428 for scanner.Scan() { 429 line := strings.TrimSuffix(scanner.Text(), "\r") 430 if firstLine { 431 line = strings.TrimPrefix(line, "\uFEFF") 432 firstLine = false 433 } 434 if line == "" { 435 if !dispatch() { 436 return ctx.Err() 437 } 438 continue 439 } 440 if strings.HasPrefix(line, ":") { 441 continue 442 } 443 field, value, found := strings.Cut(line, ":") 444 if !found { 445 value = "" 446 } 447 value = strings.TrimPrefix(value, " ") 448 switch field { 449 case "event": 450 eventType = value 451 case "data": 452 dataLines = append(dataLines, value) 453 } 454 } 455 if err := scanner.Err(); err != nil { 456 if ctxErr := ctx.Err(); ctxErr != nil { 457 return ctxErr 458 } 459 return err 460 } 461 if !dispatch() { 462 return ctx.Err() 463 } 464 return nil 465 } 466 467 func decodePostCandidate(raw []byte) (Post, bool) { 468 if post, ok := decodePost(raw); ok { 469 return post, true 470 } 471 var envelope struct { 472 Data json.RawMessage `json:"data"` 473 } 474 if json.Unmarshal(raw, &envelope) != nil || len(envelope.Data) == 0 { 475 return Post{}, false 476 } 477 return decodePost(envelope.Data) 478 } 479 480 func decodePost(raw []byte) (Post, bool) { 481 var required struct { 482 ID *int `json:"id"` 483 Body *string `json:"body"` 484 CreatedAt *string `json:"created_at"` 485 Author *struct { 486 Handle *string `json:"handle"` 487 } `json:"author"` 488 } 489 if json.Unmarshal(raw, &required) != nil || required.ID == nil || required.Body == nil || 490 required.CreatedAt == nil || required.Author == nil || required.Author.Handle == nil { 491 return Post{}, false 492 } 493 var post Post 494 if json.Unmarshal(raw, &post) != nil { 495 return Post{}, false 496 } 497 return post, true 498 } 499 500 func IsPost(value any) bool { 501 switch typed := value.(type) { 502 case Post: 503 return !typed.CreatedAt.IsZero() 504 case *Post: 505 return typed != nil && !typed.CreatedAt.IsZero() 506 case json.RawMessage: 507 _, ok := decodePostCandidate(typed) 508 return ok 509 case []byte: 510 _, ok := decodePostCandidate(typed) 511 return ok 512 default: 513 raw, err := json.Marshal(value) 514 if err != nil { 515 return false 516 } 517 _, ok := decodePostCandidate(raw) 518 return ok 519 } 520 }