Something went wrong. Try again.
Go boilerplate library for building atproto apps
atproto go
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245package atp
import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http"
"github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax")
// Maximum PDS blob size (1 MB).const MaxBlobSize = 1024 * 1024
// Client wraps an authenticated atclient.APIClient for PDS CRUD operations.// Create one per-user session via `ResumeSession` or [NewClient].type Client struct { api *atclient.APIClient did syntax.DID}
// NewClient wraps an existing indigo APIClient. Use this when you manage// OAuth sessions yourself (e.g. CLI apps that call ResumeSession directly).func NewClient(api *atclient.APIClient, did syntax.DID) *Client { return &Client{api: api, did: did}}
func (c *Client) DID() syntax.DID { return c.did }
// APIClient returns the underlying indigo APIClient for advanced usage// (custom XRPC calls, service proxying, etc.).func (c *Client) APIClient() *atclient.APIClient { return c.api }
func (c *Client) CreateRecord(ctx context.Context, collection string, record any) (uri, cid string, err error) { body := map[string]any{ "repo": c.did.String(), "collection": collection, "record": record, }
var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := c.api.Post(ctx, "com.atproto.repo.createRecord", body, &result); err != nil { return "", "", WrapPDSError(fmt.Errorf("create record in %s: %w", collection, err)) } return result.URI, result.CID, nil}
func (c *Client) CreateRecordWithRKey(ctx context.Context, collection, rkey string, record any) (uri, cid string, err error) { body := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, "record": record, }
var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := c.api.Post(ctx, "com.atproto.repo.createRecord", body, &result); err != nil { return "", "", WrapPDSError(fmt.Errorf("create record %s/%s: %w", collection, rkey, err)) } return result.URI, result.CID, nil}
// GetRecord retrieves a single record by collection and record key.func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { params := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, }
var result struct { URI string `json:"uri"` CID string `json:"cid"` Value map[string]any `json:"value"` } if err := c.api.Get(ctx, "com.atproto.repo.getRecord", params, &result); err != nil { return nil, WrapPDSError(fmt.Errorf("get record %s/%s: %w", collection, rkey, err)) } return &Record{URI: result.URI, CID: result.CID, Value: result.Value}, nil}
// Pass limit <= 0 for the server default (usually 50).// Pass empty cursor for the first page.func (c *Client) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*ListResult, error) { params := map[string]any{ "repo": c.did.String(), "collection": collection, } if limit > 0 { params["limit"] = limit } if cursor != "" { params["cursor"] = cursor }
var result struct { Records []struct { URI string `json:"uri"` CID string `json:"cid"` Value map[string]any `json:"value"` } `json:"records"` Cursor *string `json:"cursor,omitempty"` } if err := c.api.Get(ctx, "com.atproto.repo.listRecords", params, &result); err != nil { return nil, WrapPDSError(fmt.Errorf("list records %s: %w", collection, err)) }
records := make([]Record, len(result.Records)) for i, r := range result.Records { records[i] = Record{URI: r.URI, CID: r.CID, Value: r.Value} }
out := &ListResult{Records: records} if result.Cursor != nil && *result.Cursor != "" { out.Cursor = *result.Cursor } return out, nil}
func (c *Client) ListAllRecords(ctx context.Context, collection string) ([]Record, error) { var all []Record cursor := ""
for { select { case <-ctx.Done(): return nil, ctx.Err() default: }
page, err := c.ListRecords(ctx, collection, 100, cursor) if err != nil { return nil, err } all = append(all, page.Records...)
if page.Cursor == "" { break } cursor = page.Cursor } return all, nil}
func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (uri, cid string, err error) { body := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, "record": record, }
var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := c.api.Post(ctx, "com.atproto.repo.putRecord", body, &result); err != nil { return "", "", WrapPDSError(fmt.Errorf("put record %s/%s: %w", collection, rkey, err)) } return result.URI, result.CID, nil}
func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { body := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, }
var result struct{} if err := c.api.Post(ctx, "com.atproto.repo.deleteRecord", body, &result); err != nil { return WrapPDSError(fmt.Errorf("delete record %s/%s: %w", collection, rkey, err)) } return nil}
// Data must be at most 1 MB (MaxBlobSize). The mimeType should match the blob content.func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*BlobRef, error) { if len(data) > MaxBlobSize { return nil, fmt.Errorf("blob size %d exceeds maximum %d bytes", len(data), MaxBlobSize) }
endpoint, err := syntax.ParseNSID("com.atproto.repo.uploadBlob") if err != nil { return nil, err }
reader := bytes.NewReader(data) req := atclient.NewAPIRequest(http.MethodPost, endpoint, reader) req.Headers = http.Header{} req.Headers.Set("Content-Type", mimeType) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }
resp, err := c.api.Do(ctx, req) if err != nil { return nil, WrapPDSError(fmt.Errorf("upload blob: %w", err)) } defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read upload response: %w", err) }
if resp.StatusCode != http.StatusOK { return nil, WrapPDSError(fmt.Errorf("upload blob: HTTP %d: %s", resp.StatusCode, string(respBody))) }
var output atproto.RepoUploadBlob_Output if err := json.Unmarshal(respBody, &output); err != nil { return nil, fmt.Errorf("unmarshal upload response: %w", err) }
if output.Blob == nil { return nil, fmt.Errorf("upload blob: missing blob in response") }
return &BlobRef{ Type: "blob", MimeType: output.Blob.MimeType, Size: int(output.Blob.Size), Ref: CIDLink{Link: output.Blob.Ref.String()}, }, nil}
func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) { data, err := atproto.SyncGetBlob(ctx, c.api, cid, c.did.String()) if err != nil { return nil, WrapPDSError(fmt.Errorf("get blob %s: %w", cid, err)) } return data, nil}