Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1package constellation
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "sync"
10 "time"
11)
12
13const (
14 DefaultBaseURL = "https://constellation.microcosm.blue"
15 DefaultTimeout = 5 * time.Second
16 UserAgent = "Margin (margin.at)"
17)
18
19type Client struct {
20 baseURL string
21 httpClient *http.Client
22}
23
24func NewClient() *Client {
25 return &Client{
26 baseURL: DefaultBaseURL,
27 httpClient: &http.Client{
28 Timeout: DefaultTimeout,
29 },
30 }
31}
32
33func NewClientWithURL(baseURL string) *Client {
34 return &Client{
35 baseURL: baseURL,
36 httpClient: &http.Client{
37 Timeout: DefaultTimeout,
38 },
39 }
40}
41
42type CountResponse struct {
43 Total int `json:"total"`
44}
45
46type Link struct {
47 URI string `json:"uri"`
48 Collection string `json:"collection"`
49 DID string `json:"did"`
50 Path string `json:"path"`
51}
52
53type LinksResponse struct {
54 Links []Link `json:"links"`
55 Cursor string `json:"cursor,omitempty"`
56}
57
58func (c *Client) GetLikeCount(ctx context.Context, subjectURI string) (int, error) {
59 params := url.Values{}
60 params.Set("target", subjectURI)
61 params.Set("collection", "at.margin.like")
62 params.Set("path", ".subject.uri")
63
64 endpoint := fmt.Sprintf("%s/links/count/distinct-dids?%s", c.baseURL, params.Encode())
65
66 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
67 if err != nil {
68 return 0, fmt.Errorf("failed to create request: %w", err)
69 }
70 req.Header.Set("User-Agent", UserAgent)
71
72 resp, err := c.httpClient.Do(req)
73 if err != nil {
74 return 0, fmt.Errorf("request failed: %w", err)
75 }
76 defer resp.Body.Close()
77
78 if resp.StatusCode != http.StatusOK {
79 return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
80 }
81
82 var countResp CountResponse
83 if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil {
84 return 0, fmt.Errorf("failed to decode response: %w", err)
85 }
86
87 return countResp.Total, nil
88}
89
90func (c *Client) GetReplyCount(ctx context.Context, rootURI string) (int, error) {
91 params := url.Values{}
92 params.Set("target", rootURI)
93 params.Set("collection", "at.margin.reply")
94 params.Set("path", ".root.uri")
95
96 endpoint := fmt.Sprintf("%s/links/count?%s", c.baseURL, params.Encode())
97
98 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
99 if err != nil {
100 return 0, fmt.Errorf("failed to create request: %w", err)
101 }
102 req.Header.Set("User-Agent", UserAgent)
103
104 resp, err := c.httpClient.Do(req)
105 if err != nil {
106 return 0, fmt.Errorf("request failed: %w", err)
107 }
108 defer resp.Body.Close()
109
110 if resp.StatusCode != http.StatusOK {
111 return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
112 }
113
114 var countResp CountResponse
115 if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil {
116 return 0, fmt.Errorf("failed to decode response: %w", err)
117 }
118
119 return countResp.Total, nil
120}
121
122type CountsResult struct {
123 LikeCount int
124 ReplyCount int
125}
126
127func (c *Client) GetCountsBatch(ctx context.Context, uris []string) (map[string]CountsResult, error) {
128 if len(uris) == 0 {
129 return map[string]CountsResult{}, nil
130 }
131
132 results := make(map[string]CountsResult)
133 var mu sync.Mutex
134 var wg sync.WaitGroup
135
136 semaphore := make(chan struct{}, 10)
137
138 for _, uri := range uris {
139 wg.Add(1)
140 go func(u string) {
141 defer wg.Done()
142 semaphore <- struct{}{}
143 defer func() { <-semaphore }()
144
145 likeCount, _ := c.GetLikeCount(ctx, u)
146 replyCount, _ := c.GetReplyCount(ctx, u)
147
148 mu.Lock()
149 results[u] = CountsResult{
150 LikeCount: likeCount,
151 ReplyCount: replyCount,
152 }
153 mu.Unlock()
154 }(uri)
155 }
156
157 wg.Wait()
158 return results, nil
159}
160
161func (c *Client) GetAnnotationsForURL(ctx context.Context, targetURL string) ([]Link, error) {
162 params := url.Values{}
163 params.Set("target", targetURL)
164 params.Set("collection", "at.margin.annotation")
165 params.Set("path", ".target.source")
166
167 endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode())
168
169 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
170 if err != nil {
171 return nil, fmt.Errorf("failed to create request: %w", err)
172 }
173 req.Header.Set("User-Agent", UserAgent)
174
175 resp, err := c.httpClient.Do(req)
176 if err != nil {
177 return nil, fmt.Errorf("request failed: %w", err)
178 }
179 defer resp.Body.Close()
180
181 if resp.StatusCode != http.StatusOK {
182 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
183 }
184
185 var linksResp LinksResponse
186 if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil {
187 return nil, fmt.Errorf("failed to decode response: %w", err)
188 }
189
190 return linksResp.Links, nil
191}
192
193func (c *Client) GetHighlightsForURL(ctx context.Context, targetURL string) ([]Link, error) {
194 params := url.Values{}
195 params.Set("target", targetURL)
196 params.Set("collection", "at.margin.highlight")
197 params.Set("path", ".target.source")
198
199 endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode())
200
201 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
202 if err != nil {
203 return nil, fmt.Errorf("failed to create request: %w", err)
204 }
205 req.Header.Set("User-Agent", UserAgent)
206
207 resp, err := c.httpClient.Do(req)
208 if err != nil {
209 return nil, fmt.Errorf("request failed: %w", err)
210 }
211 defer resp.Body.Close()
212
213 if resp.StatusCode != http.StatusOK {
214 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
215 }
216
217 var linksResp LinksResponse
218 if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil {
219 return nil, fmt.Errorf("failed to decode response: %w", err)
220 }
221
222 return linksResp.Links, nil
223}
224
225func (c *Client) GetBookmarksForURL(ctx context.Context, targetURL string) ([]Link, error) {
226 params := url.Values{}
227 params.Set("target", targetURL)
228 params.Set("collection", "at.margin.bookmark")
229 params.Set("path", ".source")
230
231 endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode())
232
233 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
234 if err != nil {
235 return nil, fmt.Errorf("failed to create request: %w", err)
236 }
237 req.Header.Set("User-Agent", UserAgent)
238
239 resp, err := c.httpClient.Do(req)
240 if err != nil {
241 return nil, fmt.Errorf("request failed: %w", err)
242 }
243 defer resp.Body.Close()
244
245 if resp.StatusCode != http.StatusOK {
246 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
247 }
248
249 var linksResp LinksResponse
250 if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil {
251 return nil, fmt.Errorf("failed to decode response: %w", err)
252 }
253
254 return linksResp.Links, nil
255}
256
257func (c *Client) GetAllItemsForURL(ctx context.Context, targetURL string) (annotations, highlights, bookmarks []Link, err error) {
258 var wg sync.WaitGroup
259 var mu sync.Mutex
260 var errs []error
261
262 wg.Add(3)
263
264 go func() {
265 defer wg.Done()
266 links, e := c.GetAnnotationsForURL(ctx, targetURL)
267 mu.Lock()
268 defer mu.Unlock()
269 if e != nil {
270 errs = append(errs, e)
271 } else {
272 annotations = links
273 }
274 }()
275
276 go func() {
277 defer wg.Done()
278 links, e := c.GetHighlightsForURL(ctx, targetURL)
279 mu.Lock()
280 defer mu.Unlock()
281 if e != nil {
282 errs = append(errs, e)
283 } else {
284 highlights = links
285 }
286 }()
287
288 go func() {
289 defer wg.Done()
290 links, e := c.GetBookmarksForURL(ctx, targetURL)
291 mu.Lock()
292 defer mu.Unlock()
293 if e != nil {
294 errs = append(errs, e)
295 } else {
296 bookmarks = links
297 }
298 }()
299
300 wg.Wait()
301
302 if len(errs) > 0 {
303 return annotations, highlights, bookmarks, errs[0]
304 }
305
306 return annotations, highlights, bookmarks, nil
307}
308
309func (c *Client) GetLikers(ctx context.Context, subjectURI string) ([]string, error) {
310 params := url.Values{}
311 params.Set("target", subjectURI)
312 params.Set("collection", "at.margin.like")
313 params.Set("path", ".subject.uri")
314
315 endpoint := fmt.Sprintf("%s/links/distinct-dids?%s", c.baseURL, params.Encode())
316
317 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
318 if err != nil {
319 return nil, fmt.Errorf("failed to create request: %w", err)
320 }
321 req.Header.Set("User-Agent", UserAgent)
322
323 resp, err := c.httpClient.Do(req)
324 if err != nil {
325 return nil, fmt.Errorf("request failed: %w", err)
326 }
327 defer resp.Body.Close()
328
329 if resp.StatusCode != http.StatusOK {
330 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
331 }
332
333 var result struct {
334 DIDs []string `json:"dids"`
335 }
336 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
337 return nil, fmt.Errorf("failed to decode response: %w", err)
338 }
339
340 return result.DIDs, nil
341}