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 // PutRecord creates or updates a record with optional optimistic locking.
35 // If swapRecord CID is provided, the operation fails if the current CID doesn't match.
36 PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error)
37
38 // DID returns the authenticated user's DID.
39 DID() string
40
41 // HostURL returns the PDS host URL.
42 HostURL() string
43}
44
45// ListRecordsResponse contains the result of a ListRecords call.
46type ListRecordsResponse struct {
47 Records []RecordEntry
48 Cursor string
49}
50
51// RecordEntry represents a single record from a list operation.
52type RecordEntry struct {
53 URI string
54 CID string
55 Value map[string]any
56}
57
58// RecordResponse contains a single record retrieved from the PDS.
59type RecordResponse struct {
60 URI string
61 CID string
62 Value map[string]any
63}
64
65// client implements the Client interface using indigo's APIClient.
66// This single implementation works for both OAuth (DPoP) and password (Bearer) auth
67// because APIClient handles the authentication details internally.
68type client struct {
69 apiClient *atclient.APIClient
70 did string
71 host string
72}
73
74// Ensure client implements Client interface.
75var _ Client = (*client)(nil)
76
77// wrapAPIError inspects an error from atclient and wraps it with our typed errors.
78// This allows callers to use errors.Is() for reliable error detection.
79func wrapAPIError(err error, operation string) error {
80 if err == nil {
81 return nil
82 }
83
84 // Check if it's an APIError from atclient
85 var apiErr *atclient.APIError
86 if errors.As(err, &apiErr) {
87 switch apiErr.StatusCode {
88 case 400:
89 return fmt.Errorf("%s: %w: %s", operation, ErrBadRequest, apiErr.Message)
90 case 401:
91 return fmt.Errorf("%s: %w: %s", operation, ErrUnauthorized, apiErr.Message)
92 case 403:
93 return fmt.Errorf("%s: %w: %s", operation, ErrForbidden, apiErr.Message)
94 case 404:
95 return fmt.Errorf("%s: %w: %s", operation, ErrNotFound, apiErr.Message)
96 case 409:
97 return fmt.Errorf("%s: %w: %s", operation, ErrConflict, apiErr.Message)
98 }
99 }
100
101 // For other errors, wrap with operation context
102 return fmt.Errorf("%s failed: %w", operation, err)
103}
104
105// DID returns the authenticated user's DID.
106func (c *client) DID() string {
107 return c.did
108}
109
110// HostURL returns the PDS host URL.
111func (c *client) HostURL() string {
112 return c.host
113}
114
115// CreateRecord creates a record in the user's repository.
116func (c *client) CreateRecord(ctx context.Context, collection string, rkey string, record any) (string, string, error) {
117 // Build request payload per com.atproto.repo.createRecord
118 payload := map[string]any{
119 "repo": c.did,
120 "collection": collection,
121 "record": record,
122 }
123
124 // Only include rkey if provided (PDS will generate TID if not)
125 if rkey != "" {
126 payload["rkey"] = rkey
127 }
128
129 var result struct {
130 URI string `json:"uri"`
131 CID string `json:"cid"`
132 }
133
134 err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.createRecord"), payload, &result)
135 if err != nil {
136 return "", "", wrapAPIError(err, "createRecord")
137 }
138
139 return result.URI, result.CID, nil
140}
141
142// DeleteRecord deletes a record from the user's repository.
143func (c *client) DeleteRecord(ctx context.Context, collection string, rkey string) error {
144 payload := map[string]any{
145 "repo": c.did,
146 "collection": collection,
147 "rkey": rkey,
148 }
149
150 // deleteRecord returns empty response on success
151 err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.deleteRecord"), payload, nil)
152 if err != nil {
153 return wrapAPIError(err, "deleteRecord")
154 }
155
156 return nil
157}
158
159// ListRecords lists records in a collection with pagination.
160func (c *client) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*ListRecordsResponse, error) {
161 params := map[string]any{
162 "repo": c.did,
163 "collection": collection,
164 "limit": limit,
165 }
166
167 if cursor != "" {
168 params["cursor"] = cursor
169 }
170
171 var result struct {
172 Cursor string `json:"cursor"`
173 Records []struct {
174 URI string `json:"uri"`
175 CID string `json:"cid"`
176 Value map[string]any `json:"value"`
177 } `json:"records"`
178 }
179
180 err := c.apiClient.Get(ctx, syntax.NSID("com.atproto.repo.listRecords"), params, &result)
181 if err != nil {
182 return nil, wrapAPIError(err, "listRecords")
183 }
184
185 // Convert to our response type
186 response := &ListRecordsResponse{
187 Cursor: result.Cursor,
188 Records: make([]RecordEntry, len(result.Records)),
189 }
190
191 for i, rec := range result.Records {
192 response.Records[i] = RecordEntry{
193 URI: rec.URI,
194 CID: rec.CID,
195 Value: rec.Value,
196 }
197 }
198
199 return response, nil
200}
201
202// GetRecord retrieves a single record by collection and rkey.
203func (c *client) GetRecord(ctx context.Context, collection string, rkey string) (*RecordResponse, error) {
204 params := map[string]any{
205 "repo": c.did,
206 "collection": collection,
207 "rkey": rkey,
208 }
209
210 var result struct {
211 URI string `json:"uri"`
212 CID string `json:"cid"`
213 Value map[string]any `json:"value"`
214 }
215
216 err := c.apiClient.Get(ctx, syntax.NSID("com.atproto.repo.getRecord"), params, &result)
217 if err != nil {
218 return nil, wrapAPIError(err, "getRecord")
219 }
220
221 return &RecordResponse{
222 URI: result.URI,
223 CID: result.CID,
224 Value: result.Value,
225 }, nil
226}
227
228// PutRecord creates or updates a record with optional optimistic locking.
229func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) {
230 payload := map[string]any{
231 "repo": c.did,
232 "collection": collection,
233 "rkey": rkey,
234 "record": record,
235 }
236
237 // Optional: optimistic locking via CID swap check
238 if swapRecord != "" {
239 payload["swapRecord"] = swapRecord
240 }
241
242 var result struct {
243 URI string `json:"uri"`
244 CID string `json:"cid"`
245 }
246
247 err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), payload, &result)
248 if err != nil {
249 return "", "", wrapAPIError(err, "putRecord")
250 }
251
252 return result.URI, result.CID, nil
253}