a love letter to tangled (android, iOS, and a search API)
at main 201 lines 6.1 kB view raw
1// Package constellation provides a client for the Constellation backlink API. 2// Constellation is a public AT Protocol backlink index at https://constellation.microcosm.blue. 3// It answers "who linked to this?" across the network, providing social signal counts 4// (stars, followers, reactions) without requiring us to maintain our own counters. 5package constellation 6 7import ( 8 "context" 9 "encoding/json" 10 "fmt" 11 "io" 12 "net/http" 13 "net/url" 14 "strings" 15 "time" 16) 17 18const ( 19 DefaultBaseURL string = "https://constellation.microcosm.blue" 20 defaultTimeout time.Duration = 10 * time.Second 21 defaultCacheTTL time.Duration = 5 * time.Minute 22) 23 24// Source constants for common Tangled collections. 25// Format: "collection:path" where path uses Constellation dot-notation (e.g. ".subject"). 26const ( 27 SourceStarURI string = "sh.tangled.feed.star:.subject" 28 SourceFollowDID string = "sh.tangled.graph.follow:.subject" 29 SourceReactionURI string = "sh.tangled.feed.reaction:.subject" 30) 31 32// Client is an HTTP client for the Constellation backlink API. 33type Client struct { 34 http *http.Client 35 baseURL string 36 userAgent string 37 countCache *ttlCache[int] 38} 39 40// Option configures a Client. 41type Option func(*Client) 42 43// WithBaseURL overrides the Constellation base URL. 44func WithBaseURL(u string) Option { 45 return func(c *Client) { c.baseURL = u } 46} 47 48// WithUserAgent sets the User-Agent header sent with every request. 49// Constellation requires a user-agent identifying the project and a contact. 50func WithUserAgent(ua string) Option { 51 return func(c *Client) { c.userAgent = ua } 52} 53 54// WithTimeout sets the HTTP request timeout. 55func WithTimeout(d time.Duration) Option { 56 return func(c *Client) { c.http.Timeout = d } 57} 58 59// WithCacheTTL sets the TTL for cached count responses. 60func WithCacheTTL(d time.Duration) Option { 61 return func(c *Client) { c.countCache = newTTLCache[int](d) } 62} 63 64// NewClient creates a Constellation client with sensible defaults. 65func NewClient(opts ...Option) *Client { 66 c := &Client{ 67 http: &http.Client{Timeout: defaultTimeout}, 68 baseURL: DefaultBaseURL, 69 userAgent: "twister/1.0", 70 countCache: newTTLCache[int](defaultCacheTTL), 71 } 72 for _, o := range opts { 73 o(c) 74 } 75 return c 76} 77 78// BacklinksParams holds query parameters for backlink endpoints. 79type BacklinksParams struct { 80 // Subject is the target AT-URI or DID being linked to (required). 81 Subject string 82 // Source is the collection path, e.g. "sh.tangled.feed.star:subject.uri" (required). 83 Source string 84 // DID optionally filters results to a specific actor (repeatable in the raw API; here single-value). 85 DID string 86 // Limit is the max results to return (default 16, max 100). 87 Limit int 88 // Reverse reverses the ordering. 89 Reverse bool 90} 91 92// BacklinkRecord is one entry returned by GetBacklinks. 93type BacklinkRecord struct { 94 DID string `json:"did"` 95 Collection string `json:"collection"` 96 RKey string `json:"rkey"` 97} 98 99// BacklinksResponse is returned by GetBacklinks. 100type BacklinksResponse struct { 101 Total int `json:"total"` 102 LinkingRecords []BacklinkRecord `json:"linking_records"` 103 Cursor *string `json:"cursor,omitempty"` 104} 105 106// GetBacklinksCount returns the count of records linking to the given subject. 107// Results are cached with the configured TTL. Errors are returned without caching. 108// p.Source must be in "collection:path" format, e.g. "sh.tangled.feed.star:.subject". 109func (c *Client) GetBacklinksCount(ctx context.Context, p BacklinksParams) (int, error) { 110 cacheKey := "count\x00" + p.Subject + "\x00" + p.Source 111 if n, ok := c.countCache.Get(cacheKey); ok { 112 return n, nil 113 } 114 115 collection, path, ok := strings.Cut(p.Source, ":") 116 if !ok { 117 return 0, fmt.Errorf("constellation: invalid source %q: expected collection:path", p.Source) 118 } 119 120 params := url.Values{} 121 params.Set("target", p.Subject) 122 params.Set("collection", collection) 123 params.Set("path", path) 124 125 var resp struct { 126 Total int `json:"total"` 127 } 128 if err := c.getLinks(ctx, params, &resp); err != nil { 129 return 0, err 130 } 131 132 c.countCache.Set(cacheKey, resp.Total) 133 return resp.Total, nil 134} 135 136// GetBacklinks returns records linking to the given subject. 137// Results are not cached because paginated lists change frequently. 138// p.Source must be in "collection:path" format, e.g. "sh.tangled.feed.star:.subject". 139func (c *Client) GetBacklinks(ctx context.Context, p BacklinksParams) (*BacklinksResponse, error) { 140 collection, path, ok := strings.Cut(p.Source, ":") 141 if !ok { 142 return nil, fmt.Errorf("constellation: invalid source %q: expected collection:path", p.Source) 143 } 144 145 params := url.Values{} 146 params.Set("target", p.Subject) 147 params.Set("collection", collection) 148 params.Set("path", path) 149 if p.DID != "" { 150 params.Set("did", p.DID) 151 } 152 if p.Limit > 0 { 153 params.Set("limit", fmt.Sprintf("%d", p.Limit)) 154 } 155 if p.Reverse { 156 params.Set("reverse", "true") 157 } 158 159 var resp BacklinksResponse 160 if err := c.getLinks(ctx, params, &resp); err != nil { 161 return nil, err 162 } 163 return &resp, nil 164} 165 166func (c *Client) getLinks(ctx context.Context, params url.Values, out any) error { 167 u := c.baseURL + "/links" 168 if len(params) > 0 { 169 u += "?" + params.Encode() 170 } 171 172 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 173 if err != nil { 174 return fmt.Errorf("constellation: build request: %w", err) 175 } 176 if c.userAgent != "" { 177 req.Header.Set("User-Agent", c.userAgent) 178 } 179 180 resp, err := c.http.Do(req) 181 if err != nil { 182 return fmt.Errorf("constellation: request /links: %w", err) 183 } 184 defer resp.Body.Close() 185 186 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 187 body, _ := io.ReadAll(resp.Body) 188 var errResp struct { 189 Message string `json:"message"` 190 } 191 if json.Unmarshal(body, &errResp) == nil && errResp.Message != "" { 192 return fmt.Errorf("constellation: /links: status %d: %s", resp.StatusCode, errResp.Message) 193 } 194 return fmt.Errorf("constellation: /links: status %d", resp.StatusCode) 195 } 196 197 if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 198 return fmt.Errorf("constellation: /links: decode response: %w", err) 199 } 200 return nil 201}