GoAT Site is library that implements Standard.site in Go.
atprotocol standard-site atproto library
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(atproto): replace indigo use anhgelus.world/xrpc

+221 -957
+1 -1
.gitignore
··· 15 15 # vendor/ 16 16 17 17 # Go workspace file 18 - go.work 18 + go.work* 19 19 20 20 testdata
+29 -61
README.md
··· 2 2 3 3 GoAT Site implements [Standard.site](https://standard.site/) in Go. 4 4 5 - Use official [`bluesky-social/indigo`](https://github.com/bluesky-social/indigo/). 5 + Use [`anhgelus.world/xrpc`](https://tangled.org/anhgelus.world/xrpc/), a lightweight XRPC client. 6 6 7 7 Main repository is hosted on [Tangled](https://tangled.org/anhgelus.world/goat-site/), an ATProto forge. 8 8 9 9 ## Usage 10 + 11 + > [!NOTE] Check [anhgelus.world/xrpc's documentation](https://tangled.org/anhgelus.world/xrpc) first! 10 12 11 13 Get the module with: 12 14 ```bash ··· 18 20 - `Document` is for `site.standard.document`; 19 21 - `Subscription` is for `site.standard.graph.subscription`. 20 22 21 - These types implement `Record`, an interface describing records. 23 + These types implement `xrpc.Record`, an interface describing records. 22 24 23 25 You can get, list, create, update or delete them with functions: 24 - - `GetRecord[*site.Publication]` to get a publication; 25 - - `ListRecords[*site.Document]` to list documents; 26 - - `CreateRecord[*site.Document]` to create a new document; 27 - - `UpdateRecord[*site.Subscription]` to update a subscription; 28 - - `DeleteRecord[*site.Publication]` to delete a publication. 26 + - `xrpc.GetRecord[*site.Publication]` to get a publication; 27 + - `xrpc.ListRecords[*site.Document]` to list documents; 28 + - `xrpc.CreateRecord[*site.Document]` to create a new document; 29 + - `xrpc.UpdateRecord[*site.Subscription]` to update a subscription; 30 + - `xrpc.DeleteRecord[*site.Publication]` to delete a publication. 29 31 30 32 You can [verify](https://standard.site/docs/verification/) a publication with `Publication.Verify` and a document with 31 33 `Document.Verify`: 32 34 ```go 33 35 var pub *site.Publication 34 - var client *atclient.APIClient 35 - valid, err := pub.Verify(context.Background(), client, "did:plc:123", "pub_rkey") 36 + var did *atproto.DID 37 + var client xrpc.Client 38 + valid, err := pub.Verify(context.Background(), client, did, "pub_rkey") 36 39 if err != nil { 37 40 panic(err) 38 41 } ··· 41 44 } 42 45 43 46 var doc *site.Document 44 - pubUrl, err := doc.PublicationURL(context.Background(), client.Client) 47 + pubUrl, err := doc.PublicationURL(context.Background(), client) 48 + if err != nil { 49 + panic(err) 50 + } 51 + valid, err = doc.Verify(context.Background(), client, pubUrl, did, "doc_rkey") 45 52 if err != nil { 46 53 panic(err) 47 54 } 48 - valid, err = doc.Verify(context.Background(), client, pubUrl, "did:plc:123", "doc_rkey") 49 55 if !valid { 50 56 panic("invalid document :(") 51 57 } ··· 65 71 66 72 To use it, you have to implement `site.Record`: 67 73 ```go 68 - const CollectionContent = `tld.example.content` 74 + var CollectionContent = atproto.NewNSIDBuilder(`tld.example`).Name("content").Build() 69 75 70 - func (c *Content) Type() string { 76 + func (c *Content) Collection() *atproto.NSID { 71 77 return CollectionContent 72 78 } 73 79 ``` 74 80 75 - But if you use `site.GetRecord[*site.Document]` to retrieve one, it will return a simple `site.Document` without your 81 + But if you use `xrpc.GetRecord[*site.Document]` to retrieve one, it will return a simple `site.Document` without your 76 82 custom content! 77 - The `Document.Content` field is a `site.RecordJSON`, a wrapper. 78 - You can get the type of the content with `RecordJSON.Type` and the raw bytes with `RecordJSON.Raw`. 79 - You can also directly parse your `Content` with `RecordJSON.As`: 83 + The `Document.Content` field is a `xrpc.Union`, a type representing an open union. 84 + You can get the collection of the content with `Union.Collection()` and the raw bytes with `Union.Raw`. 85 + You can also directly parse your `Content` with `Union.As`: 80 86 ```go 81 87 var doc *site.Document 82 - var c *Content 88 + c := new(Content) 83 89 // returns an error if it cannot parse or if the type is invalid 84 - err := doc.Content.As(c) 85 - if err != nil { 86 - panic(err) 90 + if !doc.Content.As(c) { 91 + panic("not a Content :(") 87 92 } 88 93 ``` 89 94 90 95 ### Marshal/Unmarshal 91 96 92 - When your record is sent, it is firstly marshaled to a map. 93 - We provide `site.MarshalToMap` which works like the standard `encoding/json`: 94 - ```go 95 - var c *Content 96 - // mp is the map[string]any created 97 - mp, err := site.MarshalToMap(c) 98 - if err != nil { 99 - panic(err) 100 - } 101 - /* 102 - mp = map[string]any{"content": []string{}} 103 - */ 104 - ``` 105 - 106 - It uses the `json` tag to determine how to marshal the content. 107 - It supports `omitempty` and embedded type from the standard library. 108 - 109 - If you want to marshal your field into a string, you can set `string` in the field tag (with the key `map` here). 110 - It automatically calls the `String() string` method. 111 - ```go 112 - type Content struct { 113 - Hey *url.URL `json:"hey" map:"string"` 114 - } 115 - /* 116 - mp = map[string]any{"hey": "https://example.org/"} 117 - */ 118 - ``` 119 - 120 - If you are using complexe types, you may have to implement `json.Unmarshaler` to unmarshal from JSON and 121 - `site.MarshalerMap` to marshal to a map. 122 - ```go 123 - func (c *Content) MarshalMap() (map[string]any, error) { 124 - mp := make(map[string]any, 1) 125 - mp["foo"] = "bar" 126 - return mp, nil 127 - } 128 - // the future call to site.MarshalToMap on *Content 129 - // will return map[string]any{"foo":"bar"}. 130 - ``` 97 + See [anhgelus.world/xrpc documentation](https://tangled.org/anhgelus.world/xrpc/#complexe-records). 131 98 132 99 ## Extending lexicons 133 100 ··· 141 108 } 142 109 ``` 143 110 144 - You can call any functions with this new lexicon: the embedded base lexicon already implements the `Record` interface! 111 + You can call any functions with this new lexicon: the embedded base lexicon already implements the `xrpc.Record` 112 + interface!
+33 -30
document.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - lexutil "github.com/bluesky-social/indigo/lex/util" 16 14 "golang.org/x/net/html" 15 + "tangled.org/anhgelus.world/xrpc" 16 + "tangled.org/anhgelus.world/xrpc/atproto" 17 17 ) 18 18 19 - const CollectionDocument = CollectionBase + ".document" 19 + var CollectionDocument = CollectionBase.Name("document").Build() 20 20 21 21 // Document may be standalone or associated with a [Publication]. 22 - // This [Record] can be used to store a document's content and its associated metadata. 22 + // This [xrpc.Record] can be used to store a document's content and its associated metadata. 23 23 type Document struct { 24 24 // Site points to a [Publication] record `at://` or a [Publication.URL] `https://` for loose documents. 25 25 // Avoid trailing slashes. 26 - Site *URL `json:"site" map:"string"` 26 + Site *URL `json:"site"` 27 27 // Title of the [Document]. 28 28 // Max length: 5000. 29 29 // Max graphemes: 500. 30 30 Title string `json:"title"` 31 31 // PublishedAt is the [time.Time] of the [Document]'s publish time. 32 - PublishedAt time.Time `json:"-"` 32 + PublishedAt time.Time `json:"publishedAt"` 33 33 // Path is combined with [Document.Site] or [Publication.URL] to construct a canonical URL to the document. 34 34 // A slash should be included at the beginning of this value. 35 35 Path *string `json:"path,omitempty"` ··· 39 39 Description *string `json:"description,omitempty"` 40 40 // CoverImage to used for thumbnail or cover. 41 41 // Less than 1MB in size. 42 - CoverImage *Blob `json:"coverImage,omitempty"` 43 - // Content is a custom [Record] used to define the [Document]'s content. 44 - Content *RecordJSON `json:"content,omitempty"` 42 + CoverImage *xrpc.Blob `json:"coverImage,omitempty"` 43 + // Content is an [xrpc.Union] used to define the [Document]'s content. 44 + Content *xrpc.Union `json:"content,omitempty"` 45 45 // TextContent is a plaintext representation of the [Document.Content]. 46 46 // Should not contain markdown or other formatting. 47 47 TextContent string `json:"textContent,omitempty"` ··· 59 59 // Max graphemes: 128. 60 60 Tags []string `json:"tags,omitempty"` 61 61 // UpdatedAt is the [time.Time] of the [Document]'s last edit. 62 - UpdatedAt *time.Time `json:"-"` 62 + UpdatedAt *time.Time `json:"updatedAt,omitempty"` 63 63 } 64 64 65 - func (d *Document) Type() string { 65 + func (d *Document) Collection() *atproto.NSID { 66 66 return CollectionDocument 67 67 } 68 68 69 - func (d *Document) MarshalMap() (map[string]any, error) { 69 + func (d *Document) MarshalMap() (any, error) { 70 70 type t Document 71 - mp, err := MarshalToMap(t(*d)) 71 + mpp, err := xrpc.MarshalToMap(t(*d)) 72 72 if err != nil { 73 73 return nil, err 74 74 } 75 + mp := mpp.(map[string]any) 75 76 if v, ok := mp["path"]; ok && !strings.HasPrefix(v.(string), "/") { 76 77 mp["path"] = "/" + v.(string) 77 - } 78 - mp["publishedAt"] = d.PublishedAt.UTC().Format(TimeFormat) 79 - if d.UpdatedAt != nil { 80 - tn := d.UpdatedAt.UTC().Format(TimeFormat) 81 - mp["updatedAt"] = &tn 82 78 } 83 79 return mp, nil 84 80 } ··· 94 90 if err != nil { 95 91 return err 96 92 } 97 - v.t.PublishedAt, err = ParseTime(v.PublishedAt) 93 + v.t.PublishedAt, err = atproto.ParseTime(v.PublishedAt) 98 94 if err != nil { 99 95 return err 100 96 } 101 97 if v.UpdatedAt != nil { 102 98 v.t.UpdatedAt = new(time.Time) 103 - *v.t.UpdatedAt, err = ParseTime(*v.UpdatedAt) 99 + *v.t.UpdatedAt, err = atproto.ParseTime(*v.UpdatedAt) 104 100 if err != nil { 105 101 return err 106 102 } ··· 113 109 // 114 110 // Prefer using [GetRecord] if you plan to retrieve the record. 115 111 // Under the hood, this method retrieves the [Publication] record if [Document.Site] is an [ATURL]. 116 - func (d *Document) PublicationURL(ctx context.Context, client lexutil.LexClient) (*url.URL, error) { 112 + func (d *Document) PublicationURL(ctx context.Context, client xrpc.Client) (*url.URL, error) { 117 113 if !d.Site.IsAT() { 118 114 return d.Site.URL(), nil 119 115 } 120 - at := d.Site.AT() 121 - pub, err := GetRecord[*Publication](ctx, client, at.Authority(), at.RecordKey()) 116 + uri, err := d.Site.AT().URI(ctx, client.Directory()) 122 117 if err != nil { 123 118 return nil, err 124 119 } 120 + pub := new(Publication) 121 + union, err := client.FetchURI(ctx, uri) 122 + if err != nil { 123 + return nil, err 124 + } 125 + if !union.Value.As(pub) { 126 + return nil, ErrInvalidCollection{union.Value.Collection(), pub.Collection()} 127 + } 125 128 return pub.URL, nil 126 129 } 127 130 128 131 var ErrCannotVerifyWithNoPath = errors.New("cannot verify a document with no path") 129 132 130 133 // Verify the [Document]. 131 - func (d *Document) Verify(ctx context.Context, client *http.Client, pubURL *url.URL, repo syntax.AtIdentifier, rkey syntax.RecordKey) (bool, error) { 134 + func (d *Document) Verify(ctx context.Context, client *http.Client, pubURL *url.URL, did *atproto.DID, rkey atproto.RecordKey) (bool, error) { 132 135 if d.Path == nil { 133 136 return false, ErrCannotVerifyWithNoPath 134 137 } ··· 156 159 case "href": 157 160 href = attr.Val 158 161 } 159 - if rel != "" && rel != CollectionDocument { 162 + if rel != "" && rel != CollectionDocument.String() { 160 163 break 161 164 } 162 - if href == getDocumentVerification(repo, rkey) { 165 + if href == getDocumentVerification(did, rkey) { 163 166 return true, nil 164 167 } 165 168 } ··· 169 172 } 170 173 171 174 // GetDocumentVerificationTag returns the HTML link tag checked during the verification of the [Document]. 172 - func GetDocumentVerificationTag(repo syntax.AtIdentifier, rkey syntax.RecordKey) template.HTML { 175 + func GetDocumentVerificationTag(repo *atproto.DID, rkey atproto.RecordKey) template.HTML { 173 176 // We don't use /> to end the tag, because it is only valid for HTML5 and only required for XHTML. 174 177 // See https://blog.novalistic.com/archives/2017/08/optional-end-tags-in-html/ 175 178 return template.HTML( 176 - fmt.Sprintf(`<link rel="%s" href="%s">`, CollectionDocument, createAtURL(repo, CollectionDocument, rkey)), 179 + fmt.Sprintf(`<link rel="%s" href="%s">`, CollectionDocument, getDocumentVerification(repo, rkey)), 177 180 ) 178 181 } 179 182 180 183 // getPublicationVerification returns the string used during the verification of the [Publication]. 181 - func getDocumentVerification(repo syntax.AtIdentifier, rkey syntax.RecordKey) string { 182 - return createAtURL(repo, CollectionDocument, rkey).String() 184 + func getDocumentVerification(repo *atproto.DID, rkey atproto.RecordKey) string { 185 + return atproto.NewURI(repo, CollectionDocument, rkey).String() 183 186 }
+57 -61
document_test.go
··· 8 8 9 9 "pgregory.net/rapid" 10 10 site "tangled.org/anhgelus.world/goat-site" 11 + "tangled.org/anhgelus.world/xrpc" 12 + "tangled.org/anhgelus.world/xrpc/atproto" 11 13 ) 12 14 13 15 type content struct { 14 16 Pages any `json:"pages"` 15 17 } 16 18 17 - func (c *content) Type() string { 18 - return `pub.leaflet.content` 19 + func (c *content) Collection() *atproto.NSID { 20 + return atproto.NewNSIDBuilder(`pub.leaflet`).Name(`content`).Build() 19 21 } 20 22 21 23 func TestDocument_JSON(t *testing.T) { ··· 23 25 var pubUrl string 24 26 if rapid.Bool().Draw(t, "url_at?") { 25 27 pubUrl = "at://" + genDid(t, "url_did") + 26 - "/" + site.CollectionPublication + 28 + "/" + site.CollectionPublication.String() + 27 29 "/" + genRecordKey(t, "url_record_key") 28 30 } else { 29 31 pubUrl = genURL(t, "url") ··· 40 42 "$type": site.CollectionDocument, 41 43 "site": pubUrl, 42 44 "title": title, 43 - "publishedAt": publishedAt, 45 + "publishedAt": publishedAt.Format(atproto.TimeFormat), 44 46 "path": path, 45 47 "description": description, 46 48 "coverImage": coverImageRaw, ··· 48 50 "textContent": textContent, 49 51 "bskyPostRef": json.RawMessage(`{"cid":"bafyreidepvhssy3zglq3bo4nauszqhqmbk6lzzfay3r2nskvijyiewlr2u","commit":{"cid":"bafyreickwfv4p2jr6zvbdk6mldmddag2m6grpkbbvkvz57mvaqso5dpf5e","rev":"3mhm4oeyyzi2g"},"uri":"at://did:plc:jdhpqeb4cb4mng533dx56cbc/app.bsky.feed.post/3mhm4oevhmk2d","validationStatus":"valid"}`), 50 52 "tags": tags, 51 - "updatedAt": updatedAt, 53 + "updatedAt": updatedAt.Format(atproto.TimeFormat), 52 54 } 53 55 b, err := json.Marshal(input) 54 56 if err != nil { 55 57 t.Fatal(err) 56 58 } 57 59 t.Log(string(b)) 58 - var v *site.RecordJSON 59 - err = json.Unmarshal(b, &v) 60 + var doc *site.Document 61 + err = json.Unmarshal(b, &doc) 60 62 if err != nil { 61 63 t.Fatal(err) 62 64 } 63 - doc := v.Record.(*site.Document) 64 - if doc.Site.String() != pubUrl { 65 - t.Errorf("invalid site: %s, wanted %s", doc.Site, pubUrl) 65 + var site string 66 + if doc.Site.IsAT() { 67 + site = doc.Site.AT().String() 68 + } else { 69 + site = doc.Site.URL().String() 70 + } 71 + if site != pubUrl { 72 + t.Errorf("invalid site: %s, wanted %s", doc.Site.URL().String(), pubUrl) 66 73 } 67 74 if doc.Title != title { 68 75 t.Errorf("invalid title: %s, wanted %s", doc.Title, title) ··· 76 83 if *doc.Path != path { 77 84 t.Errorf("invalid path: %s, wanted %s", *doc.Path, path) 78 85 } 79 - if doc.Content.Record != nil { 80 - t.Errorf("invalid content lexicon: %v", doc.Content.Record) 86 + if doc.Content == nil { 87 + t.Errorf("invalid content: is nil") 81 88 } else { 82 - if doc.Content.Type != `pub.leaflet.content` { 83 - t.Errorf("invalid content type: %s", doc.Content.Type) 89 + if doc.Content.Collection().String() != `pub.leaflet.content` { 90 + t.Errorf("invalid content type: %s", doc.Content.Collection()) 84 91 } 85 92 if !slices.Equal(doc.Content.Raw, []byte(`{"$type":"pub.leaflet.content","pages":[{"$type":"pub.leaflet.pages.linearDocument","blocks":[{"$type":"pub.leaflet.pages.linearDocument#block","block":{"$type":"pub.leaflet.blocks.text","plaintext":"hiiiiiiiii"}}],"id":"019d1297-2fdd-733b-9837-911e1758f300"}]}`)) { 86 - t.Errorf("invalid content raw: %s", doc.Content.Raw) 93 + t.Errorf("invalid content raw: %s", string(doc.Content.Raw)) 87 94 } 88 95 } 89 96 if !slices.Equal(doc.Tags, tags) { 90 97 t.Errorf("invalid tags: %v, wanted %v", doc.Tags, tags) 91 98 } 92 99 93 - b, err = json.Marshal(v) 100 + b, err = xrpc.Marshal(doc) 94 101 if err != nil { 95 102 t.Fatal(err) 96 103 } 97 104 t.Log(string(b)) 98 105 99 106 c := new(content) 100 - err = doc.Content.As(c) 101 - if err != nil { 102 - t.Fatal(err) 107 + ok := doc.Content.As(c) 108 + if !ok { 109 + t.Fatal("expected content type to be", c.Collection().String()) 103 110 } 104 111 if c.Pages == nil { 105 112 t.Errorf("invalid content pages: nil") ··· 118 125 t.Skip("not doing http requests in short") 119 126 } 120 127 for _, uri := range genDocAt { 121 - uri, client := getClient(t, uri) 122 - doc, err := site.GetRecord[*site.Document](context.Background(), client, uri.Authority(), uri.RecordKey()) 128 + client := getClient() 129 + u, err := atproto.ParseURI(context.Background(), client.Directory(), uri) 123 130 if err != nil { 124 131 t.Fatal(err) 125 132 } 126 - if doc == nil { 127 - t.Errorf("doc is nil") 133 + union, err := client.FetchURI(context.Background(), u) 134 + if err != nil { 135 + t.Fatal(err) 128 136 } 129 - } 130 - 131 - } 132 - 133 - func TestListDocuments(t *testing.T) { 134 - if testing.Short() { 135 - t.Skip("not doing http requests in short") 136 - } 137 - for _, uri := range genDocAt { 138 - uri, client := getClient(t, uri) 139 - docs, _, err := site.ListRecords[*site.Document](context.Background(), client, uri.Authority(), "", false) 137 + doc := new(site.Document) 138 + if !union.Value.As(doc) { 139 + t.Fatalf("cannot convert union to document: %s", union.Value.Raw) 140 + } 141 + pubURL, err := doc.PublicationURL(context.Background(), client) 140 142 if err != nil { 141 143 t.Fatal(err) 142 144 } 143 - if docs == nil { 144 - t.Errorf("docs is nil") 145 + valid, err := doc.Verify( 146 + context.Background(), 147 + client.HTTP(), 148 + pubURL, 149 + u.Authority(), 150 + *u.RecordKey(), 151 + ) 152 + if err != nil { 153 + t.Fatal(err) 145 154 } 146 - for i, doc := range docs { 147 - if doc == nil { 148 - t.Errorf("doc %d is nil", i) 149 - } 155 + if !valid { 156 + t.Errorf("cannot verify %s", uri) 150 157 } 151 158 } 152 - } 153 159 154 - func TestDocumentVerification(t *testing.T) { 155 - tag := site.GetDocumentVerificationTag("did:plc:xyz789", "rkey") 156 - if tag != `<link rel="site.standard.document" href="at://did:plc:xyz789/site.standard.document/rkey">` { 157 - t.Errorf("invalid tag: %s", tag) 158 - } 159 160 } 160 161 161 - func TestDocument_Verify(t *testing.T) { 162 + func TestListDocuments(t *testing.T) { 162 163 if testing.Short() { 163 164 t.Skip("not doing http requests in short") 164 165 } 165 166 for _, uri := range genDocAt { 166 - uri, client := getClient(t, uri) 167 - doc, err := site.GetRecord[*site.Document](context.Background(), client, uri.Authority(), uri.RecordKey()) 167 + client := getClient() 168 + u, err := atproto.ParseURI(context.Background(), client.Directory(), uri) 168 169 if err != nil { 169 170 t.Fatal(err) 170 171 } 171 - pubURL, err := doc.PublicationURL(context.Background(), client) 172 + docs, _, err := xrpc.ListRecords[*site.Document](context.Background(), client, u.Authority(), 0, "", false) 172 173 if err != nil { 173 174 t.Fatal(err) 174 175 } 175 - valid, err := doc.Verify( 176 - context.Background(), 177 - client.Client, 178 - pubURL, 179 - uri.Authority(), 180 - uri.RecordKey(), 181 - ) 182 - if err != nil { 183 - t.Fatal(err) 176 + if docs == nil { 177 + t.Errorf("docs is nil") 184 178 } 185 - if !valid { 186 - t.Errorf("cannot verify %s", uri) 179 + for i, doc := range docs { 180 + if doc.Value == nil { 181 + t.Errorf("doc %d is nil", i) 182 + } 187 183 } 188 184 } 189 185 }
+1 -32
go.mod
··· 3 3 go 1.25.0 4 4 5 5 require ( 6 - github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd 7 6 golang.org/x/net v0.52.0 8 7 pgregory.net/rapid v1.2.0 9 - ) 10 - 11 - require ( 12 - github.com/beorn7/perks v1.0.1 // indirect 13 - github.com/cespare/xxhash/v2 v2.2.0 // indirect 14 - github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 15 - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 16 - github.com/ipfs/go-cid v0.6.0 // indirect 17 - github.com/klauspost/cpuid/v2 v2.3.0 // indirect 18 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 19 - github.com/minio/sha256-simd v1.0.1 // indirect 20 - github.com/mr-tron/base58 v1.2.0 // indirect 21 - github.com/multiformats/go-base32 v0.1.0 // indirect 22 - github.com/multiformats/go-base36 v0.2.0 // indirect 23 - github.com/multiformats/go-multibase v0.2.0 // indirect 24 - github.com/multiformats/go-multihash v0.2.3 // indirect 25 - github.com/multiformats/go-varint v0.1.0 // indirect 26 - github.com/prometheus/client_golang v1.17.0 // indirect 27 - github.com/prometheus/client_model v0.5.0 // indirect 28 - github.com/prometheus/common v0.45.0 // indirect 29 - github.com/prometheus/procfs v0.12.0 // indirect 30 - github.com/spaolacci/murmur3 v1.1.0 // indirect 31 - github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 32 - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 33 - gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 34 - golang.org/x/crypto v0.49.0 // indirect 35 - golang.org/x/sys v0.42.0 // indirect 36 - golang.org/x/time v0.3.0 // indirect 37 - golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 38 - google.golang.org/protobuf v1.33.0 // indirect 39 - lukechampine.com/blake3 v1.4.1 // indirect 8 + tangled.org/anhgelus.world/xrpc v0.0.0-20260328160318-8aa828876bb2 40 9 )
+2 -68
go.sum
··· 1 - github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 - github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 - github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd h1:FZSMlxClfm7jCA6A/vwTNw5EPxSngPPpK09MxuEx9l0= 4 - github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 5 - github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 - github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 - github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 - github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 - github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 10 - github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 11 - github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 12 - github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 - github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 14 - github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 15 - github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 16 - github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 17 - github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 18 - github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 19 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 20 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 21 - github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 22 - github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 23 - github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 24 - github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 25 - github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 26 - github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 27 - github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 28 - github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 29 - github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 30 - github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 31 - github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 32 - github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 33 - github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 34 - github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 35 - github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 - github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 - github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 38 - github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 39 - github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 40 - github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 41 - github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 42 - github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 43 - github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 44 - github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 45 - github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 46 - github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 47 - github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 48 - github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 49 - github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 50 - github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 51 - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 52 - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 53 - gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 54 - gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 55 - golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= 56 - golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 57 1 golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= 58 2 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 59 - golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 60 - golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 61 - golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 62 - golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 63 - golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 64 - golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 65 - google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 66 - google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 67 - gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 - gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 - lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 70 - lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 71 3 pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= 72 4 pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= 5 + tangled.org/anhgelus.world/xrpc v0.0.0-20260328160318-8aa828876bb2 h1:FoR0bHys1CrnqLxQA5XM53fO+Ui4GC5ocT83fgvUu9Y= 6 + tangled.org/anhgelus.world/xrpc v0.0.0-20260328160318-8aa828876bb2/go.mod h1:DW43uo9DKZHVN9fiH6lAYVQ+0cfSLoceo7aE5lE1jjw=
+8 -190
lexicons.go
··· 1 1 package site 2 2 3 3 import ( 4 - "encoding/json" 5 - "errors" 6 4 "fmt" 7 - "time" 5 + 6 + "tangled.org/anhgelus.world/xrpc/atproto" 8 7 ) 9 8 10 - // Record represents an ATProto record. 11 - type Record interface { 12 - Type() string 13 - } 14 - 15 - const ( 9 + var ( 16 10 // CollectionBase is the base NSID for Standard.site. 17 - CollectionBase = "site.standard" 11 + CollectionBase = atproto.NewNSIDBuilder("site.standard") 18 12 CollectionBlob = "blob" 19 - 20 - // TimeFormat is the standard time format specified by the ATProto. 21 - // 22 - // See [ParseTime] 23 - TimeFormat = "2006-01-02T15:04:05.000Z07:00" 24 13 ) 25 14 26 - // ParseTime returns a [time.Time] if it follows the standard time format specified by the ATProto. 27 - // 28 - // See [TimeFormat]. 29 - // Fallback to [time.RFC3339] if it doesn't work. 30 - func ParseTime(raw string) (t time.Time, err error) { 31 - t, err = time.Parse(TimeFormat, raw) 32 - if err != nil { 33 - t, err = time.Parse(time.RFC3339, raw) 34 - } 35 - return 36 - } 37 - 38 - type ErrInvalidType struct { 39 - expected, got string 40 - } 41 - 42 - func (err ErrInvalidType) Error() string { 43 - return fmt.Sprintf("invalid collection type: expected %s, got %s", err.expected, err.got) 44 - } 45 - 46 - func (err ErrInvalidType) As(target any) bool { 47 - it, ok := target.(*ErrInvalidType) 48 - if !ok { 49 - return false 50 - } 51 - *it = ErrInvalidType{err.expected, err.got} 52 - return true 53 - } 54 - 55 - func (err ErrInvalidType) Is(e error) bool { 56 - var it ErrInvalidType 57 - ok := errors.As(e, &it) 58 - if !ok { 59 - return false 60 - } 61 - return it.expected == err.expected && it.got == err.got 62 - } 63 - 64 - var ( 65 - ErrRecordAlreadyParsed = errors.New("record already parsed") 66 - ErrNoContent = errors.New("no content") 67 - ) 68 - 69 - // RecordJSON is used to encode and to decode [Record] from JSON. 70 - // 71 - // If the [Record] is known by the library, it is decoded in [RecordJSON.Record]. 72 - // Else its type is filled in [RecordJSON.Type] and its raw bytes are placed in [RecordJSON.Raw]. 73 - // 74 - // See [AsJSON] to create a [RecordJSON] from a [Record]. 75 - type RecordJSON struct { 76 - // Record parsed. 77 - // Nil if [Record] is unknown. 78 - Record Record 79 - // Type stored if [Record] is unknown. 80 - // Set after [json.Unmarshal]. 81 - Type string 82 - // Raw returns bytes stored if [Record] is unknown. 83 - // Set after [json.Unmarshal]. 84 - Raw []byte 85 - } 86 - 87 - // AsJSON wraps a [Record] as a [RecordJSON]. 88 - func AsJSON(r Record) *RecordJSON { 89 - return &RecordJSON{Record: r} 90 - } 91 - 92 - // GetType returns the type associated with the [RecordJSON]. 93 - func (r *RecordJSON) GetType() string { 94 - if r.Record != nil { 95 - return r.Record.Type() 96 - } 97 - return r.Type 98 - } 99 - 100 - // As unmarshals the [RecordJSON] as the provided [Record]. 101 - // 102 - // [ErrRecordAlreadyParsed] if the [Record] was already parsed (stored in [RecordJSON.Record]). 103 - // [ErrNoContent] if [RecordJSON.Raw] is nil. 104 - func (r *RecordJSON) As(rec Record) error { 105 - if r.Record != nil { 106 - return ErrRecordAlreadyParsed 107 - } 108 - if r.Raw == nil { 109 - return ErrNoContent 110 - } 111 - if r.Type != rec.Type() { 112 - return ErrInvalidType{r.Type, rec.Type()} 113 - } 114 - return json.Unmarshal(r.Raw, rec) 115 - } 116 - 117 - func (r *RecordJSON) MarshalJSON() ([]byte, error) { 118 - if r.Record == nil { 119 - return r.Raw, nil 120 - } 121 - mp, err := r.MarshalMap() 122 - if err != nil { 123 - return nil, err 124 - } 125 - mp["$type"] = r.Record.Type() 126 - return json.Marshal(mp) 127 - } 128 - 129 - func (r *RecordJSON) MarshalMap() (mp map[string]any, err error) { 130 - if r.Record == nil { 131 - err = json.Unmarshal(r.Raw, &mp) 132 - return 133 - } 134 - mp, err = MarshalToMap(r.Record) 135 - return 15 + type ErrInvalidCollection struct { 16 + expected, got *atproto.NSID 136 17 } 137 18 138 - func (r *RecordJSON) UnmarshalJSON(b []byte) error { 139 - var v struct { 140 - Type string `json:"$type"` 141 - } 142 - err := json.Unmarshal(b, &v) 143 - if err != nil { 144 - return err 145 - } 146 - switch v.Type { 147 - case CollectionPublication: 148 - r.Record = &Publication{} 149 - case CollectionDocument: 150 - r.Record = &Document{} 151 - case CollectionSubscription: 152 - r.Record = &Subscription{} 153 - case CollectionThemeBasic: 154 - r.Record = &Theme{} 155 - case CollectionThemeColorRGB: 156 - r.Record = &RGB{} 157 - case CollectionThemeColorRGBA: 158 - r.Record = &RGBA{} 159 - case CollectionBlob: 160 - r.Record = &Blob{} 161 - default: 162 - r.Raw = b 163 - r.Type = v.Type 164 - return nil 165 - } 166 - return json.Unmarshal(b, r.Record) 167 - } 168 - 169 - // Blob represents an ATProto `blob` type. 170 - type Blob struct { 171 - CID string `json:"-"` 172 - MimeType string `json:"mimeType"` 173 - Size uint `json:"size"` 174 - } 175 - 176 - func (b *Blob) Type() string { 177 - return CollectionBlob 178 - } 179 - 180 - func (b *Blob) MarshalMap() (map[string]any, error) { 181 - mp := make(map[string]any, 3) 182 - mp["mimeType"] = b.MimeType 183 - mp["size"] = b.Size 184 - mp["ref"] = map[string]any{"$link": b.CID} 185 - return mp, nil 186 - } 187 - 188 - func (b *Blob) UnmarshalJSON(data []byte) error { 189 - type t Blob 190 - var v struct { 191 - t 192 - Ref struct { 193 - Link string `json:"$link"` 194 - } `json:"ref"` 195 - } 196 - err := json.Unmarshal(data, &v) 197 - if err != nil { 198 - return err 199 - } 200 - *b = Blob(v.t) 201 - b.CID = v.Ref.Link 202 - return nil 19 + func (err ErrInvalidCollection) Error() string { 20 + return fmt.Sprintf("invalid collection: expected %s, got %s", err.expected, err.got) 203 21 }
+13 -55
lexicons_test.go
··· 1 1 package site_test 2 2 3 3 import ( 4 - "context" 5 - "encoding/json" 6 - "testing" 4 + "net" 5 + "net/http" 7 6 "time" 8 7 9 - "github.com/bluesky-social/indigo/atproto/atclient" 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 8 "pgregory.net/rapid" 13 - site "tangled.org/anhgelus.world/goat-site" 9 + "tangled.org/anhgelus.world/xrpc" 10 + "tangled.org/anhgelus.world/xrpc/atproto" 14 11 ) 15 12 16 13 var ( 17 14 rapidLowerRunes = rapid.RuneFrom([]rune("abcdefghijklmnopqrstuvwxyz")) 18 15 ) 19 16 20 - func genBlob(t *rapid.T, baseMime, label string) (*site.Blob, map[string]any) { 21 - blob := &site.Blob{ 17 + func genBlob(t *rapid.T, baseMime, label string) (*xrpc.Blob, map[string]any) { 18 + blob := &xrpc.Blob{ 22 19 CID: rapid.StringN(2, -1, 128).Draw(t, label+"_cid"), 23 20 MimeType: baseMime + "/" + 24 21 rapid.StringOfN(rapidLowerRunes, 2, 20, -1).Draw(t, label+"_mimeType"), 25 22 Size: rapid.UintMin(1).Draw(t, label+"_size"), 26 23 } 27 24 return blob, map[string]any{ 28 - "$type": blob.Type(), 25 + "$type": blob.Collection(), 29 26 "ref": map[string]any{"$link": blob.CID}, 30 27 "mimeType": blob.MimeType, 31 28 "size": blob.Size, 32 29 } 33 30 } 34 31 35 - func getClient(t rapid.TB, test string) (syntax.ATURI, *atclient.APIClient) { 36 - dir := identity.DefaultDirectory() 37 - uri, err := syntax.ParseATURI(test) 38 - if err != nil { 39 - t.Fatal(err) 32 + var dir *atproto.Directory 33 + 34 + func getClient() xrpc.Client { 35 + if dir == nil { 36 + dir = atproto.NewDirectory(http.DefaultClient, net.DefaultResolver, 5*time.Minute) 40 37 } 41 - var id *identity.Identity 42 - id, err = dir.Lookup(context.Background(), uri.Authority()) 43 - if err != nil { 44 - t.Fatal(err) 45 - } 46 - t.Log("using", id.PDSEndpoint(), "for", test) 47 - client := atclient.NewAPIClient(id.PDSEndpoint()) 48 - t.Log(uri.Authority().String(), uri.RecordKey()) 49 - return uri, client 38 + return xrpc.NewClient(http.DefaultClient, dir) 50 39 } 51 40 52 41 func genTime(t *rapid.T, label string) time.Time { 53 42 return time.Unix(int64(rapid.Uint32().Draw(t, label)), 0) 54 43 } 55 - 56 - func TestBlob_JSON(t *testing.T) { 57 - rapid.Check(t, func(t *rapid.T) { 58 - base := rapid.StringN(1, -1, 10).Draw(t, "mimeType") 59 - blob, blobRaw := genBlob(t, base, "blob") 60 - b, err := json.Marshal(blobRaw) 61 - if err != nil { 62 - t.Fatal(err) 63 - } 64 - var v *site.RecordJSON 65 - err = json.Unmarshal(b, &v) 66 - if err != nil { 67 - t.Fatal(err) 68 - } 69 - bl := v.Record.(*site.Blob) 70 - if bl.CID != blob.CID { 71 - t.Errorf("invalid CID: %s, wanted %s", bl.CID, blob.CID) 72 - } 73 - if bl.MimeType != blob.MimeType { 74 - t.Errorf("invalid mimeType: %s, wanted %s", bl.MimeType, blob.MimeType) 75 - } 76 - if bl.Size != blob.Size { 77 - t.Errorf("invalid size: %d, wanted %d", bl.Size, blob.Size) 78 - } 79 - b, err = json.Marshal(bl) 80 - if err != nil { 81 - t.Fatal(err) 82 - } 83 - t.Log(string(b)) 84 - }) 85 - }
-126
map.go
··· 1 - package site 2 - 3 - import ( 4 - "fmt" 5 - "reflect" 6 - "slices" 7 - "strings" 8 - ) 9 - 10 - // MarshalerMap implements a custom [MarshalToMap]. 11 - type MarshalerMap interface { 12 - MarshalMap() (map[string]any, error) 13 - } 14 - 15 - type options struct { 16 - key string 17 - fn func(any) (any, bool) 18 - } 19 - 20 - func newOpt(key string, fn func(any) (any, bool)) options { 21 - return options{key, fn} 22 - } 23 - 24 - var opts = []options{ 25 - newOpt("omitempty", func(v any) (any, bool) { 26 - if v == nil { 27 - return nil, false 28 - } 29 - refVal := reflect.ValueOf(v) 30 - if reflect.DeepEqual(v, reflect.Zero(refVal.Type()).Interface()) { 31 - return v, false 32 - } 33 - return v, true 34 - }), 35 - newOpt("string", func(v any) (any, bool) { 36 - if cv, ok := v.(fmt.Stringer); ok { 37 - return cv.String(), true 38 - } 39 - return fmt.Sprintf("%v", v), true 40 - }), 41 - } 42 - 43 - func getElem(v reflect.Value) (any, error) { 44 - if !v.CanInterface() { 45 - return nil, nil 46 - } 47 - val := v.Interface() 48 - if r, ok := val.(Record); ok { 49 - val = AsJSON(r) 50 - } 51 - if conv, ok := val.(MarshalerMap); ok { 52 - return conv.MarshalMap() 53 - } 54 - switch v.Kind() { 55 - case reflect.Struct: 56 - return MarshalToMap(val) 57 - case reflect.Pointer: 58 - if v.IsNil() { 59 - return nil, nil 60 - } 61 - return getElem(v.Elem()) 62 - default: 63 - return val, nil 64 - } 65 - } 66 - 67 - // MarshalToMap transforms a struct into a map. 68 - // 69 - // If v is not a map, it returns nil. 70 - // 71 - // Implements [MarshalerMap] to have a custom behavior. 72 - func MarshalToMap(v any) (map[string]any, error) { 73 - ref := reflect.ValueOf(v) 74 - switch ref.Kind() { 75 - case reflect.Struct: 76 - case reflect.Pointer: 77 - if ref.IsNil() { 78 - return nil, nil 79 - } 80 - return MarshalToMap(ref.Elem().Interface()) 81 - default: 82 - return nil, nil 83 - } 84 - if conv, ok := v.(MarshalerMap); ok { 85 - return conv.MarshalMap() 86 - } 87 - refType := ref.Type() 88 - fields := ref.NumField() 89 - mp := make(map[string]any, fields) 90 - for i := range fields { 91 - field := ref.Field(i) 92 - fieldType := refType.Field(i) 93 - val, err := getElem(field) 94 - if err != nil { 95 - return nil, err 96 - } 97 - name := fieldType.Name 98 - data := strings.Split(fieldType.Tag.Get("json"), ",") 99 - data = append(data, strings.Split(fieldType.Tag.Get("map"), ",")...) 100 - if len(data) > 0 { 101 - if len(data[0]) > 0 { 102 - name = data[0] 103 - } 104 - if name == "-" { 105 - continue 106 - } 107 - if len(data) > 1 { 108 - tagOpts := data[1:] 109 - ok := true 110 - i := 0 111 - for i < len(opts) && ok { 112 - opt := opts[i] 113 - if slices.Contains(tagOpts, opt.key) { 114 - val, ok = opt.fn(val) 115 - } 116 - i++ 117 - } 118 - if !ok { 119 - continue 120 - } 121 - } 122 - } 123 - mp[name] = val 124 - } 125 - return mp, nil 126 - }
-97
map_test.go
··· 1 - package site_test 2 - 3 - import ( 4 - "fmt" 5 - "reflect" 6 - "testing" 7 - 8 - site "tangled.org/anhgelus.world/goat-site" 9 - ) 10 - 11 - func validMap(t *testing.T, check, res map[string]any) { 12 - if len(check) != len(res) { 13 - t.Errorf("invalid value: got %v, wanted %v", check, res) 14 - } 15 - for k, val := range check { 16 - if !reflect.DeepEqual(res[k], val) { 17 - t.Errorf("invalid value at %s, got %v, wanted %v", k, val, res[k]) 18 - } 19 - } 20 - } 21 - 22 - type test1 struct { 23 - A string `json:"a"` 24 - B string `json:",omitempty"` 25 - } 26 - 27 - func TestMarshalToMap_Simple(t *testing.T) { 28 - h := test1{"aaa", ""} 29 - v, err := site.MarshalToMap(h) 30 - if err != nil { 31 - t.Fatal(err) 32 - } 33 - validMap(t, v, map[string]any{"a": "aaa"}) 34 - 35 - h = test1{"", "bbb"} 36 - v, err = site.MarshalToMap(h) 37 - if err != nil { 38 - t.Fatal(err) 39 - } 40 - validMap(t, v, map[string]any{"a": "", "B": "bbb"}) 41 - } 42 - 43 - type test2 struct { 44 - Hey *test1 `json:"hey,omitempty"` 45 - Key string `json:"key"` 46 - } 47 - 48 - func TestMarshalToMap_Nested(t *testing.T) { 49 - h := test2{&test1{"aaa", ""}, "key"} 50 - v, err := site.MarshalToMap(h) 51 - if err != nil { 52 - t.Fatal(err) 53 - } 54 - validMap(t, v, map[string]any{"hey": map[string]any{"a": "aaa"}, "key": "key"}) 55 - 56 - h = test2{nil, "k"} 57 - v, err = site.MarshalToMap(h) 58 - if err != nil { 59 - t.Fatal(err) 60 - } 61 - validMap(t, v, map[string]any{"key": "k"}) 62 - } 63 - 64 - func (t test1) String() string { 65 - return fmt.Sprintf("%s:%s", t.A, t.B) 66 - } 67 - 68 - type test3 struct { 69 - Default int `json:"default"` 70 - ConvDef int `json:"conv,string"` 71 - } 72 - 73 - func TestMarshalToMap_String(t *testing.T) { 74 - h := test3{1, 1} 75 - v, err := site.MarshalToMap(h) 76 - if err != nil { 77 - t.Fatal(err) 78 - } 79 - validMap(t, v, map[string]any{"default": 1, "conv": "1"}) 80 - } 81 - 82 - type test4 struct { 83 - A int 84 - } 85 - 86 - func (t test4) MarshalMap() (map[string]any, error) { 87 - return map[string]any{"a": t.A + 5}, nil 88 - } 89 - 90 - func TestMarshalToMap_CustomMarshal(t *testing.T) { 91 - h := test4{0} 92 - v, err := site.MarshalToMap(h) 93 - if err != nil { 94 - t.Fatal(err) 95 - } 96 - validMap(t, v, map[string]any{"a": 5}) 97 - }
+12 -11
publication.go
··· 9 9 "net/url" 10 10 "strings" 11 11 12 - "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/anhgelus.world/xrpc" 13 + "tangled.org/anhgelus.world/xrpc/atproto" 13 14 ) 14 15 15 - const CollectionPublication = CollectionBase + ".publication" 16 + var CollectionPublication = CollectionBase.Name("publication").Build() 16 17 17 18 // Publication represents a collection of [Document]s published to the web. 18 19 // It includes important information about a publication including its location on the web, theming information, user ··· 31 32 Name string `json:"name"` 32 33 // Icon to identify the [Publication]. 33 34 // Must be a square image and should be at least 256x256. 34 - Icon *Blob `json:"icon,omitempty"` 35 + Icon *xrpc.Blob `json:"icon,omitempty"` 35 36 // Description of the [Publication]. 36 37 // Max length: 30000. 37 38 // Max graphemes: 3000. ··· 42 43 Preferences *Preferences `json:"preferences,omitempty"` 43 44 } 44 45 45 - func (p *Publication) Type() string { 46 + func (p *Publication) Collection() *atproto.NSID { 46 47 return CollectionPublication 47 48 } 48 49 49 - func (p *Publication) MarshalMap() (map[string]any, error) { 50 + func (p *Publication) MarshalMap() (any, error) { 50 51 type t Publication 51 52 pp := struct { 52 53 t 53 54 URL string `json:"url"` 54 55 }{t(*p), strings.TrimSuffix(p.URL.String(), "/")} 55 - return MarshalToMap(pp) 56 + return xrpc.MarshalToMap(pp) 56 57 } 57 58 58 59 func (p *Publication) UnmarshalJSON(b []byte) error { ··· 75 76 } 76 77 77 78 // Verify the [Publication]. 78 - func (p *Publication) Verify(ctx context.Context, client *http.Client, repo syntax.AtIdentifier, rkey syntax.RecordKey) (bool, error) { 79 + func (p *Publication) Verify(ctx context.Context, client *http.Client, repo *atproto.DID, rkey atproto.RecordKey) (bool, error) { 79 80 req, err := http.NewRequest(http.MethodGet, p.URL.String()+GetPublicationVerificationURI(p.URL.Path), nil) 80 81 if err != nil { 81 82 return false, err ··· 99 100 } 100 101 101 102 // getPublicationVerification returns the string used during the verification of the [Publication]. 102 - func getPublicationVerification(repo syntax.AtIdentifier, rkey syntax.RecordKey) string { 103 - return createAtURL(repo, CollectionPublication, rkey).String() 103 + func getPublicationVerification(repo *atproto.DID, rkey atproto.RecordKey) string { 104 + return atproto.NewURI(repo, CollectionPublication, rkey).String() 104 105 } 105 106 106 107 // HandlePublicationVerification returns an [http.Handler] used during the verification of the [Publication]. 107 108 // 108 109 // See [GetPublicationVerificationURI]. 109 - func HandlePublicationVerification(repo syntax.AtIdentifier, rkey syntax.RecordKey) http.Handler { 110 + func HandlePublicationVerification(repo *atproto.DID, rkey atproto.RecordKey) http.Handler { 110 111 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 111 112 fmt.Fprint(w, getPublicationVerification(repo, rkey)) 112 113 }) ··· 122 123 if len(path) > 0 && !strings.HasPrefix(path, "/") { 123 124 path = "/" + path 124 125 } 125 - return "/.well-known/" + CollectionPublication + path 126 + return "/.well-known/" + CollectionPublication.String() + path 126 127 }
+29 -32
publication_test.go
··· 7 7 8 8 "pgregory.net/rapid" 9 9 site "tangled.org/anhgelus.world/goat-site" 10 + "tangled.org/anhgelus.world/xrpc" 11 + "tangled.org/anhgelus.world/xrpc/atproto" 10 12 ) 11 13 12 14 func genBasicTheme(t *rapid.T) (*site.Theme, map[string]any) { ··· 25 27 } 26 28 } 27 29 return theme, map[string]any{ 28 - "$type": theme.Type(), 30 + "$type": theme.Collection(), 29 31 "accent": colors("accent", &theme.Accent), 30 32 "accentForeground": colors("accentForeground", &theme.AccentForeground), 31 33 "foreground": colors("foreground", &theme.Foreground), ··· 55 57 t.Fatal(err) 56 58 } 57 59 t.Log(string(b)) 58 - var v *site.RecordJSON 59 - err = json.Unmarshal(b, &v) 60 + var pub *site.Publication 61 + err = json.Unmarshal(b, &pub) 60 62 if err != nil { 61 63 t.Fatal(err) 62 64 } 63 - pub := v.Record.(*site.Publication) 64 65 if pub.Name != name { 65 66 t.Errorf("invalid name: %s, wanted %s", pub.Name, name) 66 67 } ··· 89 90 if *th.Foreground != *theme.Foreground { 90 91 t.Errorf("invalid theme foreground color: %s, wanted %s", th.Foreground, theme.Foreground) 91 92 } 92 - b, err = json.Marshal(v) 93 + b, err = xrpc.Marshal(pub) 93 94 if err != nil { 94 95 t.Fatal(err) 95 96 } ··· 106 107 t.Skip() 107 108 } 108 109 for _, uri := range genPubAt { 109 - uri, client := getClient(t, uri) 110 - pub, err := site.GetRecord[*site.Publication](context.Background(), client, uri.Authority(), uri.RecordKey()) 110 + client := getClient() 111 + u, err := atproto.ParseURI(context.Background(), client.Directory(), uri) 112 + if err != nil { 113 + t.Fatal(err) 114 + } 115 + union, err := client.FetchURI(context.Background(), u) 116 + if err != nil { 117 + t.Fatal(err) 118 + } 119 + pub := new(site.Publication) 120 + if !union.Value.As(pub) { 121 + t.Fatalf("cannot convert union to publication: %s", union.Value.Raw) 122 + } 123 + v, err := pub.Verify(context.Background(), client.HTTP(), u.Authority(), *u.RecordKey()) 111 124 if err != nil { 112 125 t.Fatal(err) 113 126 } 114 - if pub == nil { 115 - t.Errorf("pub is nil") 127 + if !v { 128 + t.Errorf("cannot verify %s", uri) 116 129 } 117 130 } 118 131 } ··· 122 135 t.Skip("not doing http requests in short") 123 136 } 124 137 for _, uri := range genPubAt { 125 - uri, client := getClient(t, uri) 126 - pubs, _, err := site.ListRecords[*site.Publication](context.Background(), client, uri.Authority(), "", false) 138 + client := getClient() 139 + u, err := atproto.ParseURI(context.Background(), client.Directory(), uri) 140 + if err != nil { 141 + t.Fatal(err) 142 + } 143 + pubs, _, err := xrpc.ListRecords[*site.Document](context.Background(), client, u.Authority(), 0, "", false) 127 144 if err != nil { 128 145 t.Fatal(err) 129 146 } ··· 131 148 t.Errorf("pubs is nil") 132 149 } 133 150 for i, pub := range pubs { 134 - if pub == nil { 151 + if pub.Value == nil { 135 152 t.Errorf("pub %d is nil", i) 136 153 } 137 154 } ··· 152 169 t.Errorf("invalid uri: %s", uri) 153 170 } 154 171 } 155 - 156 - func TestPublication_Verify(t *testing.T) { 157 - if testing.Short() { 158 - t.Skip("not doing http requests in short") 159 - } 160 - for _, uri := range genPubAt { 161 - id, client := getClient(t, uri) 162 - pub, err := site.GetRecord[*site.Publication](context.Background(), client, id.Authority(), id.RecordKey()) 163 - if err != nil { 164 - t.Fatal(err) 165 - } 166 - v, err := pub.Verify(context.Background(), client.Client, id.Authority(), id.RecordKey()) 167 - if err != nil { 168 - t.Fatal(err) 169 - } 170 - if !v { 171 - t.Errorf("cannot verify %s", id) 172 - } 173 - } 174 - }
+5 -5
subscription.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 6 - "github.com/bluesky-social/indigo/atproto/syntax" 6 + "tangled.org/anhgelus.world/xrpc/atproto" 7 7 ) 8 8 9 - const CollectionSubscription = CollectionBase + ".graph.subscription" 9 + var CollectionSubscription = CollectionBase.SubAuthority("graph").Name("subscription").Build() 10 10 11 11 // Subscription enable users to follow publications and receive updates about new content. 12 12 // They represent the social connection between readers and the publications they're interested in. 13 13 type Subscription struct { 14 14 // Publication is an AT-URI reference to the publication record being subscribed to. 15 15 // E.g., `at://did:plc:abc123/site.standard.publication/xyz789`. 16 - Publication syntax.ATURI `json:"publication,string"` 16 + Publication atproto.RawURI `json:"publication"` 17 17 } 18 18 19 19 func (s *Subscription) UnmarshalJSON(b []byte) error { ··· 24 24 if err != nil { 25 25 return err 26 26 } 27 - s.Publication, err = syntax.ParseATURI(v.Publication) 27 + s.Publication, err = atproto.ParseRawURI(v.Publication) 28 28 return err 29 29 } 30 30 31 - func (s *Subscription) Type() string { 31 + func (s *Subscription) Collection() *atproto.NSID { 32 32 return CollectionSubscription 33 33 }
+11 -9
theme.go
··· 4 4 "fmt" 5 5 "image/color" 6 6 "math" 7 + 8 + "tangled.org/anhgelus.world/xrpc/atproto" 7 9 ) 8 10 9 - const ( 10 - CollectionTheme = CollectionBase + ".theme" 11 - CollectionThemeBasic = CollectionTheme + ".basic" 12 - CollectionThemeColor = CollectionTheme + ".color" 13 - CollectionThemeColorRGB = CollectionThemeColor + "#rgb" 14 - CollectionThemeColorRGBA = CollectionThemeColor + "#rgba" 11 + var ( 12 + CollectionTheme = CollectionBase.SubAuthority("theme") 13 + CollectionThemeBasic = CollectionTheme.Name("basic").Build() 14 + CollectionThemeColor = CollectionTheme.Name("color") 15 + CollectionThemeColorRGB = CollectionThemeColor.Fragment("rgb").Build() 16 + CollectionThemeColorRGBA = CollectionThemeColor.Fragment("rgba").Build() 15 17 ) 16 18 17 19 // Theme ensures [Publication]s maintain their visual identity across different reading applications and platforms by ··· 27 29 AccentForeground *RGB `json:"accentForeground"` 28 30 } 29 31 30 - func (t *Theme) Type() string { 32 + func (t *Theme) Collection() *atproto.NSID { 31 33 return CollectionThemeBasic 32 34 } 33 35 ··· 44 46 return &RGB{r, g, b} 45 47 } 46 48 47 - func (r *RGB) Type() string { 49 + func (r *RGB) Collection() *atproto.NSID { 48 50 return CollectionThemeColorRGB 49 51 } 50 52 ··· 80 82 } 81 83 } 82 84 83 - func (r *RGBA) Type() string { 85 + func (r *RGBA) Collection() *atproto.NSID { 84 86 return CollectionThemeColorRGBA 85 87 } 86 88
+9 -35
url.go
··· 6 6 "net/url" 7 7 "strings" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/anhgelus.world/xrpc" 10 + "tangled.org/anhgelus.world/xrpc/atproto" 10 11 ) 11 12 12 13 var ( 13 14 ErrIncompleteURL = errors.New("incomplete url") 14 15 ) 15 16 16 - // ATURL represents a full AT [url.URL]. 17 - type ATURL struct { 18 - syntax.ATURI 19 - } 20 - 21 - func (at *ATURL) String() string { 22 - // not using [fmt.Sprintf] because it may be slower 23 - return "at://" + at.Authority().String() + 24 - "/" + at.Collection().String() + 25 - "/" + at.RecordKey().String() 26 - } 27 - 28 - // ParseATURL returns an [ATURL] from a raw string. 29 - func ParseATURL(raw string) (*ATURL, error) { 30 - u, err := syntax.ParseATURI(raw) 31 - if err != nil { 32 - return nil, err 33 - } 34 - if u.Collection() == "" { 35 - return nil, ErrIncompleteURL 36 - } 37 - if u.RecordKey() == "" { 38 - return nil, ErrIncompleteURL 39 - } 40 - return &ATURL{u}, nil 41 - } 42 - 43 17 // URL represents an [url.URL] that may be an [ATURL]. 44 18 type URL struct { 45 19 url *url.URL 46 - at *ATURL 20 + at *atproto.RawURI 47 21 } 48 22 49 23 // IsAT returns true if the [URL] is an [ATURL]. ··· 68 42 // Panics if it isn't an [ATURL]. 69 43 // 70 44 // See [URL.IsAT]. 71 - func (u *URL) AT() *ATURL { 45 + func (u *URL) AT() *atproto.RawURI { 72 46 if !u.IsAT() { 73 47 panic("not an AT URL") 74 48 } 75 49 return u.at 76 50 } 77 51 78 - func (u *URL) String() string { 52 + func (u *URL) MarshalMap() (any, error) { 79 53 if u.IsAT() { 80 - return u.at.String() 54 + return xrpc.MarshalToMap(u.AT()) 81 55 } 82 - return u.url.String() 56 + return xrpc.MarshalToMap(u.URL().String()) 83 57 } 84 58 85 59 func (u *URL) UnmarshalJSON(b []byte) error { ··· 99 73 // ParseURL returns an [URL] from a raw string. 100 74 func ParseURL(raw string) (*URL, error) { 101 75 if strings.HasPrefix(raw, "at://") { 102 - u, err := ParseATURL(raw) 76 + u, err := atproto.ParseRawURI(raw) 103 77 if err != nil { 104 78 return nil, err 105 79 } 106 - return &URL{at: u}, nil 80 + return &URL{at: &u}, nil 107 81 } 108 82 u, err := url.Parse(raw) 109 83 if err != nil {
+11 -6
url_test.go
··· 1 1 package site_test 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "strings" 6 7 "testing" ··· 85 86 t.Fatal(err) 86 87 } 87 88 if url.IsAT() { 88 - t.Errorf("invalid url kind: %s is AT", url) 89 + t.Fatalf("invalid url kind: %s is AT", raw) 89 90 } 90 - if url.String() != raw { 91 - t.Errorf("invalid string: %s, wanted %s", url.String(), raw) 91 + if url.URL().String() != raw { 92 + t.Errorf("invalid string: %s, wanted %s", url.URL().String(), raw) 92 93 } 93 94 }) 94 95 rapid.Check(t, func(t *rapid.T) { ··· 101 102 t.Fatal(err) 102 103 } 103 104 if !url.IsAT() { 104 - t.Errorf("invalid url kind: %s is not AT", url) 105 + t.Fatalf("invalid url kind: %s is not AT", raw) 105 106 } 106 - if url.String() != raw { 107 - t.Errorf("invalid string: %s, wanted %s", url.String(), raw) 107 + u, err := url.AT().URI(context.Background(), getClient().Directory()) 108 + if err != nil { 109 + t.Fatal(err) 110 + } 111 + if u.String() != raw { 112 + t.Errorf("invalid string: %s, wanted %s", u.String(), raw) 108 113 } 109 114 }) 110 115 }
-138
xrpc.go
··· 1 - package site 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - 7 - "github.com/bluesky-social/indigo/api/agnostic" 8 - "github.com/bluesky-social/indigo/api/atproto" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - lexutil "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - // MaxItemsPerList is the number of items per list call. 14 - const MaxItemsPerList = 25 15 - 16 - // Result is returned when after creating a record. 17 - type Result struct { 18 - URI string 19 - CID string 20 - ValidationStatus *string 21 - Commit *agnostic.RepoDefs_CommitMeta 22 - } 23 - 24 - // GetRecord returns the [Record] in the repo associated with the rkey. 25 - // Automatically uses the latest CID. 26 - // 27 - // Returns [ErrInvalidType] if the [Record] got doesn't have a valid type. 28 - func GetRecord[T Record](ctx context.Context, client lexutil.LexClient, repo syntax.AtIdentifier, rkey syntax.RecordKey) (t T, err error) { 29 - var rec *agnostic.RepoGetRecord_Output 30 - rec, err = agnostic.RepoGetRecord(ctx, client, "", t.Type(), repo.String(), rkey.String()) 31 - if err != nil { 32 - return 33 - } 34 - var v *RecordJSON 35 - err = json.Unmarshal(*rec.Value, &v) 36 - if err != nil { 37 - return 38 - } 39 - if v.GetType() != t.Type() { 40 - err = ErrInvalidType{t.Type(), v.Type} 41 - return 42 - } 43 - return v.Record.(T), nil 44 - } 45 - 46 - // ListRecords returns all the [Record]s stored in the repo and the cursor. 47 - // 48 - // Returns [ErrInvalidType] if a [Record] got doesn't have a valid type. 49 - // 50 - // See [MaxItemsPerList]. 51 - func ListRecords[T Record](ctx context.Context, client lexutil.LexClient, repo syntax.AtIdentifier, cursor string, reverse bool) ([]T, *string, error) { 52 - var t T 53 - rec, err := agnostic.RepoListRecords(ctx, client, t.Type(), cursor, MaxItemsPerList, repo.String(), reverse) 54 - if err != nil { 55 - return nil, nil, err 56 - } 57 - docs := make([]T, MaxItemsPerList) 58 - i := 0 59 - for i = range len(rec.Records) { 60 - r := rec.Records[i] 61 - var v *RecordJSON 62 - err = json.Unmarshal(*r.Value, &v) 63 - if err != nil { 64 - return nil, nil, err 65 - } 66 - if v.GetType() != t.Type() { 67 - return nil, nil, ErrInvalidType{t.Type(), v.Type} 68 - } 69 - docs[i] = v.Record.(T) 70 - } 71 - return docs[:i], rec.Cursor, nil 72 - } 73 - 74 - // CreateRecord in a repo with the given rkey. 75 - // Always tries to validate the [Record] against the lexicon saved. 76 - // 77 - // Rkey can be nil. 78 - func CreateRecord[T Record](ctx context.Context, client lexutil.LexClient, repo syntax.AtIdentifier, rkey *syntax.RecordKey, v T) (*Result, error) { 79 - mp, err := MarshalToMap(AsJSON(v)) 80 - if err != nil { 81 - return nil, err 82 - } 83 - var cv *string 84 - if rkey != nil { 85 - t := rkey.String() 86 - cv = &t 87 - } 88 - t := true 89 - out, err := agnostic.RepoCreateRecord(ctx, client, &agnostic.RepoCreateRecord_Input{ 90 - Collection: v.Type(), 91 - Record: mp, 92 - Repo: repo.String(), 93 - Rkey: cv, 94 - Validate: &t, 95 - }) 96 - if err != nil { 97 - return nil, err 98 - } 99 - return &Result{out.Uri, out.Cid, out.ValidationStatus, out.Commit}, nil 100 - } 101 - 102 - // UpdateRecord in a repo with the given rkey. 103 - // Always tries to validate the [Record] against the lexicon saved. 104 - func UpdateRecord[T Record](ctx context.Context, client lexutil.LexClient, repo syntax.AtIdentifier, rkey syntax.RecordKey, v T) (*Result, error) { 105 - mp, err := MarshalToMap(AsJSON(v)) 106 - if err != nil { 107 - return nil, err 108 - } 109 - t := true 110 - out, err := agnostic.RepoPutRecord(ctx, client, &agnostic.RepoPutRecord_Input{ 111 - Collection: v.Type(), 112 - Record: mp, 113 - Repo: repo.String(), 114 - Rkey: rkey.String(), 115 - Validate: &t, 116 - //SwapRecord: &cid, 117 - }) 118 - if err != nil { 119 - return nil, err 120 - } 121 - return &Result{out.Uri, out.Cid, out.ValidationStatus, out.Commit}, nil 122 - } 123 - 124 - // DeleteRecord in a repo with the given rkey. 125 - func DeleteRecord[T Record](ctx context.Context, client lexutil.LexClient, repo syntax.AtIdentifier, rkey syntax.RecordKey) error { 126 - var t T 127 - _, err := atproto.RepoDeleteRecord(ctx, client, &atproto.RepoDeleteRecord_Input{ 128 - Collection: t.Type(), 129 - Repo: repo.String(), 130 - Rkey: rkey.String(), 131 - }) 132 - return err 133 - } 134 - 135 - // createAtURL returns a valid [syntax.ATURI]. 136 - func createAtURL(repo syntax.AtIdentifier, collection string, rkey syntax.RecordKey) *ATURL { 137 - return &ATURL{syntax.ATURI("at://" + repo.String() + "/" + collection + "/" + rkey.String())} 138 - }