gotextlog

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

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 }