a love letter to tangled (android, iOS, and a search API)
at main 124 lines 3.1 kB view raw
1package backfill 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/http" 9 "net/url" 10 "strings" 11 "time" 12) 13 14const listReposByCollectionNSID = "com.atproto.sync.listReposByCollection" 15 16type lightrailRepoLister interface { 17 ListReposByCollection( 18 ctx context.Context, baseURL string, collections []string, limit int, 19 ) ([]string, error) 20} 21 22type listReposByCollectionResponse struct { 23 Cursor string `json:"cursor"` 24 Repos []listReposByCollection `json:"repos"` 25} 26 27type listReposByCollection struct { 28 DID string `json:"did"` 29} 30 31type HTTPLightrailClient struct { 32 client *http.Client 33} 34 35func NewHTTPLightrailClient() *HTTPLightrailClient { 36 return &HTTPLightrailClient{ 37 client: &http.Client{Timeout: 15 * time.Second}, 38 } 39} 40 41func (c *HTTPLightrailClient) ListReposByCollection( 42 ctx context.Context, baseURL string, collections []string, limit int, 43) ([]string, error) { 44 baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") 45 if baseURL == "" { 46 return nil, fmt.Errorf("lightrail url is required") 47 } 48 if limit <= 0 { 49 limit = DefaultPageLimit 50 } 51 52 seen := make(map[string]bool) 53 dids := make([]string, 0) 54 cursor := "" 55 for { 56 resp, err := c.listReposByCollectionPage(ctx, baseURL, collections, limit, cursor) 57 if err != nil { 58 return nil, err 59 } 60 for _, repo := range resp.Repos { 61 did := strings.TrimSpace(repo.DID) 62 if did == "" || seen[did] { 63 continue 64 } 65 seen[did] = true 66 dids = append(dids, did) 67 } 68 if resp.Cursor == "" { 69 return dids, nil 70 } 71 if resp.Cursor == cursor { 72 return nil, fmt.Errorf("listReposByCollection repeated cursor %q", cursor) 73 } 74 cursor = resp.Cursor 75 } 76} 77 78func (c *HTTPLightrailClient) listReposByCollectionPage( 79 ctx context.Context, baseURL string, collections []string, limit int, cursor string, 80) (*listReposByCollectionResponse, error) { 81 params := url.Values{} 82 for _, collection := range collections { 83 collection = strings.TrimSpace(collection) 84 if collection != "" { 85 params.Add("collection", collection) 86 } 87 } 88 if limit > 0 { 89 params.Set("limit", fmt.Sprintf("%d", limit)) 90 } 91 if cursor != "" { 92 params.Set("cursor", cursor) 93 } 94 95 endpoint := baseURL + "/xrpc/" + listReposByCollectionNSID 96 if encoded := params.Encode(); encoded != "" { 97 endpoint += "?" + encoded 98 } 99 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 100 if err != nil { 101 return nil, fmt.Errorf("build listReposByCollection request: %w", err) 102 } 103 104 resp, err := c.client.Do(req) 105 if err != nil { 106 return nil, fmt.Errorf("listReposByCollection request: %w", err) 107 } 108 defer resp.Body.Close() 109 110 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 111 body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) 112 return nil, fmt.Errorf( 113 "listReposByCollection failed: status %d: %s", 114 resp.StatusCode, 115 strings.TrimSpace(string(body)), 116 ) 117 } 118 119 var payload listReposByCollectionResponse 120 if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { 121 return nil, fmt.Errorf("decode listReposByCollection response: %w", err) 122 } 123 return &payload, nil 124}