A community based topic aggregation platform built on atproto
1// Package pds provides an abstraction layer for authenticated interactions with AT Protocol PDSs. 2// It wraps indigo's atclient.APIClient to provide a consistent interface regardless of 3// authentication method (OAuth with DPoP or password-based Bearer tokens). 4package pds 5 6import ( 7 "context" 8 "errors" 9 "fmt" 10 11 "github.com/bluesky-social/indigo/atproto/atclient" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13) 14 15// Client provides authenticated access to a user's PDS repository. 16// It abstracts the underlying authentication mechanism (OAuth/DPoP or password/Bearer) 17// so services can make PDS calls without knowing how auth works. 18type Client interface { 19 // CreateRecord creates a record in the user's repository. 20 // If rkey is empty, a TID will be generated. 21 // Returns the record URI and CID. 22 CreateRecord(ctx context.Context, collection string, rkey string, record any) (uri string, cid string, err error) 23 24 // DeleteRecord deletes a record from the user's repository. 25 DeleteRecord(ctx context.Context, collection string, rkey string) error 26 27 // ListRecords lists records in a collection with pagination. 28 // Returns records, next cursor (empty if no more), and error. 29 ListRecords(ctx context.Context, collection string, limit int, cursor string) (*ListRecordsResponse, error) 30 31 // GetRecord retrieves a single record by collection and rkey. 32 GetRecord(ctx context.Context, collection string, rkey string) (*RecordResponse, error) 33 34 // DID returns the authenticated user's DID. 35 DID() string 36 37 // HostURL returns the PDS host URL. 38 HostURL() string 39} 40 41// ListRecordsResponse contains the result of a ListRecords call. 42type ListRecordsResponse struct { 43 Records []RecordEntry 44 Cursor string 45} 46 47// RecordEntry represents a single record from a list operation. 48type RecordEntry struct { 49 URI string 50 CID string 51 Value map[string]any 52} 53 54// RecordResponse contains a single record retrieved from the PDS. 55type RecordResponse struct { 56 URI string 57 CID string 58 Value map[string]any 59} 60 61// client implements the Client interface using indigo's APIClient. 62// This single implementation works for both OAuth (DPoP) and password (Bearer) auth 63// because APIClient handles the authentication details internally. 64type client struct { 65 apiClient *atclient.APIClient 66 did string 67 host string 68} 69 70// Ensure client implements Client interface. 71var _ Client = (*client)(nil) 72 73// wrapAPIError inspects an error from atclient and wraps it with our typed errors. 74// This allows callers to use errors.Is() for reliable error detection. 75func wrapAPIError(err error, operation string) error { 76 if err == nil { 77 return nil 78 } 79 80 // Check if it's an APIError from atclient 81 var apiErr *atclient.APIError 82 if errors.As(err, &apiErr) { 83 switch apiErr.StatusCode { 84 case 400: 85 return fmt.Errorf("%s: %w: %s", operation, ErrBadRequest, apiErr.Message) 86 case 401: 87 return fmt.Errorf("%s: %w: %s", operation, ErrUnauthorized, apiErr.Message) 88 case 403: 89 return fmt.Errorf("%s: %w: %s", operation, ErrForbidden, apiErr.Message) 90 case 404: 91 return fmt.Errorf("%s: %w: %s", operation, ErrNotFound, apiErr.Message) 92 } 93 } 94 95 // For other errors, wrap with operation context 96 return fmt.Errorf("%s failed: %w", operation, err) 97} 98 99// DID returns the authenticated user's DID. 100func (c *client) DID() string { 101 return c.did 102} 103 104// HostURL returns the PDS host URL. 105func (c *client) HostURL() string { 106 return c.host 107} 108 109// CreateRecord creates a record in the user's repository. 110func (c *client) CreateRecord(ctx context.Context, collection string, rkey string, record any) (string, string, error) { 111 // Build request payload per com.atproto.repo.createRecord 112 payload := map[string]any{ 113 "repo": c.did, 114 "collection": collection, 115 "record": record, 116 } 117 118 // Only include rkey if provided (PDS will generate TID if not) 119 if rkey != "" { 120 payload["rkey"] = rkey 121 } 122 123 var result struct { 124 URI string `json:"uri"` 125 CID string `json:"cid"` 126 } 127 128 err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.createRecord"), payload, &result) 129 if err != nil { 130 return "", "", wrapAPIError(err, "createRecord") 131 } 132 133 return result.URI, result.CID, nil 134} 135 136// DeleteRecord deletes a record from the user's repository. 137func (c *client) DeleteRecord(ctx context.Context, collection string, rkey string) error { 138 payload := map[string]any{ 139 "repo": c.did, 140 "collection": collection, 141 "rkey": rkey, 142 } 143 144 // deleteRecord returns empty response on success 145 err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.deleteRecord"), payload, nil) 146 if err != nil { 147 return wrapAPIError(err, "deleteRecord") 148 } 149 150 return nil 151} 152 153// ListRecords lists records in a collection with pagination. 154func (c *client) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*ListRecordsResponse, error) { 155 params := map[string]any{ 156 "repo": c.did, 157 "collection": collection, 158 "limit": limit, 159 } 160 161 if cursor != "" { 162 params["cursor"] = cursor 163 } 164 165 var result struct { 166 Cursor string `json:"cursor"` 167 Records []struct { 168 URI string `json:"uri"` 169 CID string `json:"cid"` 170 Value map[string]any `json:"value"` 171 } `json:"records"` 172 } 173 174 err := c.apiClient.Get(ctx, syntax.NSID("com.atproto.repo.listRecords"), params, &result) 175 if err != nil { 176 return nil, wrapAPIError(err, "listRecords") 177 } 178 179 // Convert to our response type 180 response := &ListRecordsResponse{ 181 Cursor: result.Cursor, 182 Records: make([]RecordEntry, len(result.Records)), 183 } 184 185 for i, rec := range result.Records { 186 response.Records[i] = RecordEntry{ 187 URI: rec.URI, 188 CID: rec.CID, 189 Value: rec.Value, 190 } 191 } 192 193 return response, nil 194} 195 196// GetRecord retrieves a single record by collection and rkey. 197func (c *client) GetRecord(ctx context.Context, collection string, rkey string) (*RecordResponse, error) { 198 params := map[string]any{ 199 "repo": c.did, 200 "collection": collection, 201 "rkey": rkey, 202 } 203 204 var result struct { 205 URI string `json:"uri"` 206 CID string `json:"cid"` 207 Value map[string]any `json:"value"` 208 } 209 210 err := c.apiClient.Get(ctx, syntax.NSID("com.atproto.repo.getRecord"), params, &result) 211 if err != nil { 212 return nil, wrapAPIError(err, "getRecord") 213 } 214 215 return &RecordResponse{ 216 URI: result.URI, 217 CID: result.CID, 218 Value: result.Value, 219 }, nil 220}