a love letter to tangled (android, iOS, and a search API)
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}