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}