go scratch code for atproto

flesh out netclient code

+198 -4
+156
atproto/heap/record.go
··· 1 + package heap 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + 11 + "github.com/bluesky-social/indigo/atproto/data" 12 + "github.com/bluesky-social/indigo/atproto/repo" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + type repoRecordResp struct { 17 + URI string `json:"uri"` 18 + CID syntax.CID `json:"cid"` 19 + Value json.RawMessage `json:"value"` 20 + } 21 + 22 + // Fetches record JSON using com.atproto.repo.getRecord, and returns record as [json.RawMessage] and the CID (as string). 23 + func (nc *NetClient) GetRecordUnverified(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey) (*json.RawMessage, syntax.CID, error) { 24 + ident, err := nc.Dir.LookupDID(ctx, did) 25 + if err != nil { 26 + return nil, "", err 27 + } 28 + host := ident.PDSEndpoint() 29 + if host == "" { 30 + return nil, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 31 + } 32 + // TODO: validate host 33 + // TODO: DID escaping (?) 34 + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", host, did, collection, rkey) 35 + 36 + slog.Debug("fetching record JSON", "did", did, "url", u) 37 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 38 + if err != nil { 39 + return nil, "", err 40 + } 41 + if nc.UserAgent != "" { 42 + req.Header.Set("User-Agent", nc.UserAgent) 43 + } 44 + req.Header.Set("Accept", "application/json") 45 + 46 + resp, err := nc.Client.Do(req) 47 + if err != nil { 48 + return nil, "", fmt.Errorf("fetching record JSON (%s): %w", did, err) 49 + } 50 + defer resp.Body.Close() 51 + 52 + if resp.StatusCode != http.StatusOK { 53 + return nil, "", fmt.Errorf("HTTP error fetching record JSON (%s): %d", did, resp.StatusCode) 54 + } 55 + 56 + var rrr repoRecordResp 57 + if err := json.NewDecoder(resp.Body).Decode(&rrr); err != nil { 58 + return nil, "", fmt.Errorf("failed decoding account status response: %w", err) 59 + } 60 + 61 + return &rrr.Value, rrr.CID, nil 62 + } 63 + 64 + // Fetches a record "proof" using com.atproto.sync.getRecord. Verifies signature and merkel chain. Copies record content in out 'out' parameter. 65 + // 66 + // If out is nil, record data is not returned. If it is [bytes.Buffer], the record CBOR is copied in. Otherwise, the record is transformed to JSON and Unmarshalled in to provided output, which could be a pointer to a struct, [json.RawMessage], `map[string]any`, etc. 67 + // 68 + // TODO: this might not be fully validating MST tree and record CID hashes or encoding yet 69 + func (nc *NetClient) GetRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey, out any) (syntax.CID, error) { 70 + // TODO: "GetRecordProof" variant, which just returns CAR as io.ReadCloser? 71 + ident, err := nc.Dir.LookupDID(ctx, did) 72 + if err != nil { 73 + return "", err 74 + } 75 + pub, err := ident.PublicKey() 76 + if err != nil { 77 + return "", err 78 + } 79 + host := ident.PDSEndpoint() 80 + if host == "" { 81 + return "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 82 + } 83 + // TODO: validate host 84 + // TODO: DID escaping (?) 85 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRecord?did=%s&collection=%s&rkey=%s", host, did, collection, rkey) 86 + 87 + slog.Debug("fetching record proof", "did", did, "url", u) 88 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 89 + if err != nil { 90 + return "", err 91 + } 92 + if nc.UserAgent != "" { 93 + req.Header.Set("User-Agent", nc.UserAgent) 94 + } 95 + req.Header.Set("Accept", "application/vnd.ipld.car") 96 + 97 + resp, err := nc.Client.Do(req) 98 + if err != nil { 99 + return "", fmt.Errorf("fetching record proof (%s): %w", did, err) 100 + } 101 + defer resp.Body.Close() 102 + 103 + if resp.StatusCode != http.StatusOK { 104 + return "", fmt.Errorf("HTTP error fetching record proof (%s): %d", did, resp.StatusCode) 105 + } 106 + 107 + // TODO: re-confirm if loading tree re-checks all CIDs; or if we need to re-compute the tree data CID 108 + commit, rp, err := repo.LoadRepoFromCAR(ctx, resp.Body) 109 + if err != nil { 110 + return "", fmt.Errorf("failed to parse record proof CAR (%s): %w", did, err) 111 + } 112 + 113 + // NOTE: LoadRepoFromCAR calls commit.VerifyStructure() internally 114 + 115 + if err := commit.VerifySignature(pub); err != nil { 116 + return "", fmt.Errorf("failed to verify record proof signature (%s): %w", did, err) 117 + } 118 + 119 + rbytes, rcid, err := rp.GetRecordBytes(ctx, collection, rkey) 120 + if err != nil { 121 + return "", fmt.Errorf("failed to read record from proof CAR (%s): %w", did, err) 122 + } 123 + cidStr := syntax.CID(rcid.String()) 124 + 125 + // TODO: `GetRecordBytes` does not currently verify record CID, but unpacking CAR file should have done that? but need to confirm CAR implementation does this 126 + 127 + // check that record CBOR is valid, even if we don't return it 128 + rdata, err := data.UnmarshalCBOR(rbytes) 129 + if err != nil { 130 + return "", fmt.Errorf("failed to parse record CBOR (%s): %w", did, err) 131 + } 132 + 133 + switch out := out.(type) { 134 + case nil: 135 + // if output isn't captured, bail out early 136 + return cidStr, nil 137 + case *bytes.Buffer: 138 + // simply copy data over 139 + out.Reset() 140 + _, err := out.Write(rbytes) 141 + if err != nil { 142 + return "", err 143 + } 144 + return cidStr, nil 145 + default: 146 + // attempt to unmarshal from json 147 + jsonBytes, err := json.Marshal(rdata) 148 + if err != nil { 149 + return "", err 150 + } 151 + if err := json.Unmarshal(jsonBytes, out); err != nil { 152 + return "", fmt.Errorf("failed unmarhsaling record (%s): %w", did, err) 153 + } 154 + return cidStr, nil 155 + } 156 + }
+1 -1
atproto/netclient/cid.go atproto/heap/cid.go
··· 1 - package netclient 1 + package heap 2 2 3 3 import ( 4 4 "github.com/ipfs/go-cid"
+40 -2
atproto/netclient/examples_test.go atproto/heap/examples_test.go
··· 1 - package netclient 1 + package heap 2 2 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/repo" ··· 66 67 } 67 68 68 69 fmt.Printf("active=%t status=%s\n", active, status) 69 - // Output: active=true status= 70 + // active=true status= 71 + } 72 + 73 + func ExampleNetClient_GetRecordUnverified() { 74 + 75 + ctx := context.Background() 76 + nc := NewNetClient() 77 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 78 + collection := syntax.NSID("app.bsky.actor.profile") 79 + rkey := syntax.RecordKey("self") 80 + 81 + raw, _, err := nc.GetRecordUnverified(ctx, did, collection, rkey) 82 + if err != nil { 83 + panic("failed to fetch record: " + err.Error()) 84 + } 85 + var record map[string]any 86 + _ = json.Unmarshal(*raw, &record) 87 + 88 + fmt.Println(record["displayName"]) 89 + // AT Protocol Developers 90 + } 91 + 92 + func ExampleNetClient_GetRecord() { 93 + 94 + ctx := context.Background() 95 + nc := NewNetClient() 96 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 97 + collection := syntax.NSID("app.bsky.actor.profile") 98 + rkey := syntax.RecordKey("self") 99 + 100 + var record map[string]any 101 + _, err := nc.GetRecord(ctx, did, collection, rkey, &record) 102 + if err != nil { 103 + panic("failed to fetch record: " + err.Error()) 104 + } 105 + 106 + fmt.Println(record["displayName"]) 107 + // Output: AT Protocol Developers 70 108 }
+1 -1
atproto/netclient/netclient.go atproto/heap/netclient.go
··· 1 - package netclient 1 + package heap 2 2 3 3 import ( 4 4 "bytes"