this repo has no description
0
fork

Configure Feed

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

atproto/lexicon: package for working with lexicon schemas, and runtime data validation (#420)

This is currently a branch on top of
https://github.com/bluesky-social/indigo/pull/407

- [x] parse lexicon schema JSON
- [x] load entire directories of schema JSON files from disk as a
catalog
- [x] check lexicon schema semantics (eg, can't have min greater than
max)
- [x] validate runtime data (`map[string]any`) against lexicons
- [x] whole bunch of corner-case tests
- [x] CLI tool for some live-network testing
- [x] add support for `tid` and `record-key` lex formats (not in specs
yet)
- [x] configurable flexible to legacy blobs and lenient datetime parsing
(?)
- [x] comments and example code

probably in a later iteration:

- [ ] ensure empty body works
(https://github.com/bluesky-social/atproto/pull/2746)
- [ ] validate rkey type against lexicon
- [ ] CLI tool to validate prod firehose
- [ ] CLI tool to validate CAR files
- [x] clarify specs around unions: only `object` and `token` types?
- [x] clarify specs around `unknown`: only `object` type?
- [ ] validate other "primary" lexicon types: subscription, HTTP body,
HTTP URL params, etc

authored by

bnewbold and committed by
GitHub
5668e31f 7e7ac233

+2567 -3
+36
atproto/data/parse.go
··· 111 111 return nil, fmt.Errorf("$type field must contain a non-empty string") 112 112 } 113 113 } 114 + // legacy blob type 115 + if len(obj) == 2 { 116 + if _, ok := obj["mimeType"]; ok { 117 + if _, ok := obj["cid"]; ok { 118 + b, err := parseLegacyBlob(obj) 119 + if err != nil { 120 + return nil, err 121 + } 122 + return *b, nil 123 + } 124 + } 125 + } 114 126 out := make(map[string]any, len(obj)) 115 127 for k, val := range obj { 116 128 if len(k) > MAX_OBJECT_KEY_LEN { ··· 210 222 Size: size, 211 223 MimeType: mimeType, 212 224 Ref: ref, 225 + }, nil 226 + } 227 + 228 + func parseLegacyBlob(obj map[string]any) (*Blob, error) { 229 + if len(obj) != 2 { 230 + return nil, fmt.Errorf("legacy blobs expected to have 2 fields") 231 + } 232 + var err error 233 + mimeType, ok := obj["mimeType"].(string) 234 + if !ok { 235 + return nil, fmt.Errorf("blob 'mimeType' missing or not a string") 236 + } 237 + cidStr, ok := obj["cid"] 238 + if !ok { 239 + return nil, fmt.Errorf("blob 'cid' missing") 240 + } 241 + c, err := cid.Parse(cidStr) 242 + if err != nil { 243 + return nil, fmt.Errorf("invalid CID: %w", err) 244 + } 245 + return &Blob{ 246 + Size: -1, 247 + MimeType: mimeType, 248 + Ref: CIDLink(c), 213 249 }, nil 214 250 } 215 251
+1 -3
atproto/identity/doc.go
··· 1 1 /* 2 2 Package identity provides types and routines for resolving handles and DIDs from the network 3 3 4 - The two main abstractions are a Catalog interface for identity service implementations, and an Identity structure which represents core identity information relevant to atproto. The Catalog interface can be nested, somewhat like HTTP middleware, to provide caching, observability, or other bespoke needs in more complex systems. 5 - 6 - Much of the implementation of this SDK is based on existing code in indigo:api/extra.go 4 + The two main abstractions are a Directory interface for identity service implementations, and an Identity struct which represents core identity information relevant to atproto. The Directory interface can be nested, somewhat like HTTP middleware, to provide caching, observability, or other bespoke needs in more complex systems. 7 5 */ 8 6 package identity
+117
atproto/lexicon/catalog.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "io/fs" 8 + "log/slog" 9 + "os" 10 + "path/filepath" 11 + "strings" 12 + ) 13 + 14 + // Interface type for a resolver or container of lexicon schemas, and methods for validating generic data against those schemas. 15 + type Catalog interface { 16 + // Looks up a schema refrence (NSID string with optional fragment) to a Schema object. 17 + Resolve(ref string) (*Schema, error) 18 + } 19 + 20 + // Trivial in-memory Lexicon Catalog implementation. 21 + type BaseCatalog struct { 22 + schemas map[string]Schema 23 + } 24 + 25 + // Creates a new empty BaseCatalog 26 + func NewBaseCatalog() BaseCatalog { 27 + return BaseCatalog{ 28 + schemas: make(map[string]Schema), 29 + } 30 + } 31 + 32 + func (c *BaseCatalog) Resolve(ref string) (*Schema, error) { 33 + if ref == "" { 34 + return nil, fmt.Errorf("tried to resolve empty string name") 35 + } 36 + // default to #main if name doesn't have a fragment 37 + if !strings.Contains(ref, "#") { 38 + ref = ref + "#main" 39 + } 40 + s, ok := c.schemas[ref] 41 + if !ok { 42 + return nil, fmt.Errorf("schema not found in catalog: %s", ref) 43 + } 44 + return &s, nil 45 + } 46 + 47 + // Inserts a schema loaded from a JSON file in to the catalog. 48 + func (c *BaseCatalog) AddSchemaFile(sf SchemaFile) error { 49 + base := sf.ID 50 + for frag, def := range sf.Defs { 51 + if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") { 52 + // TODO: more validation here? 53 + return fmt.Errorf("schema name invalid: %s", frag) 54 + } 55 + name := base + "#" + frag 56 + if _, ok := c.schemas[name]; ok { 57 + return fmt.Errorf("catalog already contained a schema with name: %s", name) 58 + } 59 + // "A file can have at most one definition with one of the "primary" types. Primary types should always have the name main. It is possible for main to describe a non-primary type." 60 + switch s := def.Inner.(type) { 61 + case SchemaRecord, SchemaQuery, SchemaProcedure, SchemaSubscription: 62 + if frag != "main" { 63 + return fmt.Errorf("record, query, procedure, and subscription types must be 'main', not: %s", frag) 64 + } 65 + case SchemaToken: 66 + // add fully-qualified name to token 67 + s.fullName = name 68 + def.Inner = s 69 + } 70 + def.SetBase(base) 71 + if err := def.CheckSchema(); err != nil { 72 + return err 73 + } 74 + s := Schema{ 75 + ID: name, 76 + Revision: sf.Revision, 77 + Def: def.Inner, 78 + } 79 + c.schemas[name] = s 80 + } 81 + return nil 82 + } 83 + 84 + // Recursively loads all '.json' files from a directory in to the catalog. 85 + func (c *BaseCatalog) LoadDirectory(dirPath string) error { 86 + return filepath.WalkDir(dirPath, func(p string, d fs.DirEntry, err error) error { 87 + if err != nil { 88 + return err 89 + } 90 + if d.IsDir() { 91 + return nil 92 + } 93 + if !strings.HasSuffix(p, ".json") { 94 + return nil 95 + } 96 + slog.Debug("loading Lexicon schema file", "path", p) 97 + f, err := os.Open(p) 98 + if err != nil { 99 + return err 100 + } 101 + defer func() { _ = f.Close() }() 102 + 103 + b, err := io.ReadAll(f) 104 + if err != nil { 105 + return err 106 + } 107 + 108 + var sf SchemaFile 109 + if err = json.Unmarshal(b, &sf); err != nil { 110 + return err 111 + } 112 + if err = c.AddSchemaFile(sf); err != nil { 113 + return err 114 + } 115 + return nil 116 + }) 117 + }
+90
atproto/lexicon/cmd/lextool/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "os" 9 + 10 + "github.com/bluesky-social/indigo/atproto/lexicon" 11 + 12 + "github.com/urfave/cli/v2" 13 + ) 14 + 15 + func main() { 16 + app := cli.App{ 17 + Name: "lex-tool", 18 + Usage: "informal debugging CLI tool for atproto lexicons", 19 + } 20 + app.Commands = []*cli.Command{ 21 + &cli.Command{ 22 + Name: "parse-schema", 23 + Usage: "parse an individual lexicon schema file (JSON)", 24 + Action: runParseSchema, 25 + }, 26 + &cli.Command{ 27 + Name: "load-directory", 28 + Usage: "try recursively loading all the schemas from a directory", 29 + Action: runLoadDirectory, 30 + }, 31 + &cli.Command{ 32 + Name: "validate-record", 33 + Usage: "fetch from network, validate against catalog", 34 + Action: runValidateRecord, 35 + }, 36 + &cli.Command{ 37 + Name: "validate-firehose", 38 + Usage: "subscribe to a firehose, validate every known record against catalog", 39 + Action: runValidateFirehose, 40 + }, 41 + } 42 + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) 43 + slog.SetDefault(slog.New(h)) 44 + app.RunAndExitOnError() 45 + } 46 + 47 + func runParseSchema(cctx *cli.Context) error { 48 + p := cctx.Args().First() 49 + if p == "" { 50 + return fmt.Errorf("need to provide path to a schema file as an argument") 51 + } 52 + 53 + f, err := os.Open(p) 54 + if err != nil { 55 + return err 56 + } 57 + defer func() { _ = f.Close() }() 58 + 59 + b, err := io.ReadAll(f) 60 + if err != nil { 61 + return err 62 + } 63 + 64 + var sf lexicon.SchemaFile 65 + if err := json.Unmarshal(b, &sf); err != nil { 66 + return err 67 + } 68 + out, err := json.MarshalIndent(sf, "", " ") 69 + if err != nil { 70 + return err 71 + } 72 + fmt.Println(string(out)) 73 + return nil 74 + } 75 + 76 + func runLoadDirectory(cctx *cli.Context) error { 77 + p := cctx.Args().First() 78 + if p == "" { 79 + return fmt.Errorf("need to provide directory path as an argument") 80 + } 81 + 82 + c := lexicon.NewBaseCatalog() 83 + err := c.LoadDirectory(p) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + fmt.Println("success!") 89 + return nil 90 + }
+95
atproto/lexicon/cmd/lextool/net.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + 10 + "github.com/bluesky-social/indigo/atproto/data" 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/lexicon" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 15 + "github.com/urfave/cli/v2" 16 + ) 17 + 18 + func runValidateRecord(cctx *cli.Context) error { 19 + ctx := context.Background() 20 + args := cctx.Args().Slice() 21 + if len(args) != 2 { 22 + return fmt.Errorf("expected two args (catalog path and AT-URI)") 23 + } 24 + p := args[0] 25 + if p == "" { 26 + return fmt.Errorf("need to provide directory path as an argument") 27 + } 28 + 29 + cat := lexicon.NewBaseCatalog() 30 + err := cat.LoadDirectory(p) 31 + if err != nil { 32 + return err 33 + } 34 + 35 + aturi, err := syntax.ParseATURI(args[1]) 36 + if err != nil { 37 + return err 38 + } 39 + if aturi.RecordKey() == "" { 40 + return fmt.Errorf("need a full, not partial, AT-URI: %s", aturi) 41 + } 42 + dir := identity.DefaultDirectory() 43 + ident, err := dir.Lookup(ctx, aturi.Authority()) 44 + if err != nil { 45 + return fmt.Errorf("resolving AT-URI authority: %v", err) 46 + } 47 + pdsURL := ident.PDSEndpoint() 48 + if pdsURL == "" { 49 + return fmt.Errorf("could not resolve PDS endpoint for AT-URI account: %s", ident.DID.String()) 50 + } 51 + 52 + slog.Info("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) 53 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 54 + pdsURL, ident.DID, aturi.Collection(), aturi.RecordKey()) 55 + resp, err := http.Get(url) 56 + if err != nil { 57 + return err 58 + } 59 + if resp.StatusCode != http.StatusOK { 60 + return fmt.Errorf("fetch failed") 61 + } 62 + respBytes, err := io.ReadAll(resp.Body) 63 + if err != nil { 64 + return err 65 + } 66 + 67 + body, err := data.UnmarshalJSON(respBytes) 68 + record, ok := body["value"].(map[string]any) 69 + if !ok { 70 + return fmt.Errorf("fetched record was not an object") 71 + } 72 + 73 + slog.Info("validating", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) 74 + err = lexicon.ValidateRecord(&cat, record, aturi.Collection().String(), lexicon.LenientMode) 75 + if err != nil { 76 + return err 77 + } 78 + fmt.Println("success!") 79 + return nil 80 + } 81 + 82 + func runValidateFirehose(cctx *cli.Context) error { 83 + p := cctx.Args().First() 84 + if p == "" { 85 + return fmt.Errorf("need to provide directory path as an argument") 86 + } 87 + 88 + cat := lexicon.NewBaseCatalog() 89 + err := cat.LoadDirectory(p) 90 + if err != nil { 91 + return err 92 + } 93 + 94 + return fmt.Errorf("UNIMPLEMENTED") 95 + }
+4
atproto/lexicon/docs.go
··· 1 + /* 2 + Package atproto/lexicon provides generic Lexicon schema parsing and run-time validation. 3 + */ 4 + package lexicon
+41
atproto/lexicon/examples_test.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "fmt" 5 + 6 + atdata "github.com/bluesky-social/indigo/atproto/data" 7 + ) 8 + 9 + func ExampleValidateRecord() { 10 + 11 + // First load Lexicon schema JSON files from local disk. 12 + cat := NewBaseCatalog() 13 + if err := cat.LoadDirectory("testdata/catalog"); err != nil { 14 + panic("failed to load lexicons") 15 + } 16 + 17 + // Parse record JSON data using atproto/data helper 18 + recordJSON := `{ 19 + "$type": "example.lexicon.record", 20 + "integer": 123, 21 + "formats": { 22 + "did": "did:web:example.com", 23 + "aturi": "at://handle.example.com/com.example.nsid/asdf123", 24 + "datetime": "2023-10-30T22:25:23Z", 25 + "language": "en", 26 + "tid": "3kznmn7xqxl22" 27 + } 28 + }` 29 + 30 + recordData, err := atdata.UnmarshalJSON([]byte(recordJSON)) 31 + if err != nil { 32 + panic("failed to parse record JSON") 33 + } 34 + 35 + if err := ValidateRecord(&cat, recordData, "example.lexicon.record", 0); err != nil { 36 + fmt.Printf("Schema validation failed: %v\n", err) 37 + } else { 38 + fmt.Println("Success!") 39 + } 40 + // Output: Success! 41 + }
+20
atproto/lexicon/extract.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "encoding/json" 5 + ) 6 + 7 + // Helper type for extracting record type from JSON 8 + type genericSchemaDef struct { 9 + Type string `json:"type"` 10 + } 11 + 12 + // Parses the top-level $type field from generic atproto JSON data 13 + func ExtractTypeJSON(b []byte) (string, error) { 14 + var gsd genericSchemaDef 15 + if err := json.Unmarshal(b, &gsd); err != nil { 16 + return "", err 17 + } 18 + 19 + return gsd.Type, nil 20 + }
+95
atproto/lexicon/interop_language_test.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "os" 7 + "testing" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + type LexiconFixture struct { 13 + Name string `json:"name"` 14 + Lexicon json.RawMessage `json:"lexicon"` 15 + } 16 + 17 + func TestInteropLexiconValid(t *testing.T) { 18 + 19 + f, err := os.Open("testdata/lexicon-valid.json") 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + defer func() { _ = f.Close() }() 24 + 25 + jsonBytes, err := io.ReadAll(f) 26 + if err != nil { 27 + t.Fatal(err) 28 + } 29 + 30 + var fixtures []LexiconFixture 31 + if err := json.Unmarshal(jsonBytes, &fixtures); err != nil { 32 + t.Fatal(err) 33 + } 34 + 35 + for _, f := range fixtures { 36 + testLexiconFixtureValid(t, f) 37 + } 38 + } 39 + 40 + func testLexiconFixtureValid(t *testing.T, fixture LexiconFixture) { 41 + assert := assert.New(t) 42 + 43 + var schema SchemaFile 44 + if err := json.Unmarshal(fixture.Lexicon, &schema); err != nil { 45 + t.Fatal(err) 46 + } 47 + 48 + outBytes, err := json.Marshal(schema) 49 + if err != nil { 50 + t.Fatal(err) 51 + } 52 + 53 + var beforeMap map[string]any 54 + if err := json.Unmarshal(fixture.Lexicon, &beforeMap); err != nil { 55 + t.Fatal(err) 56 + } 57 + 58 + var afterMap map[string]any 59 + if err := json.Unmarshal(outBytes, &afterMap); err != nil { 60 + t.Fatal(err) 61 + } 62 + 63 + assert.Equal(beforeMap, afterMap) 64 + } 65 + 66 + func TestInteropLexiconInvalid(t *testing.T) { 67 + 68 + f, err := os.Open("testdata/lexicon-invalid.json") 69 + if err != nil { 70 + t.Fatal(err) 71 + } 72 + defer func() { _ = f.Close() }() 73 + 74 + jsonBytes, err := io.ReadAll(f) 75 + if err != nil { 76 + t.Fatal(err) 77 + } 78 + 79 + var fixtures []LexiconFixture 80 + if err := json.Unmarshal(jsonBytes, &fixtures); err != nil { 81 + t.Fatal(err) 82 + } 83 + 84 + for _, f := range fixtures { 85 + testLexiconFixtureInvalid(t, f) 86 + } 87 + } 88 + 89 + func testLexiconFixtureInvalid(t *testing.T, fixture LexiconFixture) { 90 + assert := assert.New(t) 91 + 92 + var schema SchemaFile 93 + err := json.Unmarshal(fixture.Lexicon, &schema) 94 + assert.Error(err) 95 + }
+92
atproto/lexicon/interop_record_test.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "testing" 9 + 10 + "github.com/bluesky-social/indigo/atproto/data" 11 + 12 + "github.com/stretchr/testify/assert" 13 + ) 14 + 15 + type RecordFixture struct { 16 + Name string `json:"name"` 17 + RecordKey string `json:"rkey"` 18 + Data json.RawMessage `json:"data"` 19 + } 20 + 21 + func TestInteropRecordValid(t *testing.T) { 22 + assert := assert.New(t) 23 + 24 + cat := NewBaseCatalog() 25 + if err := cat.LoadDirectory("testdata/catalog"); err != nil { 26 + t.Fatal(err) 27 + } 28 + 29 + f, err := os.Open("testdata/record-data-valid.json") 30 + if err != nil { 31 + t.Fatal(err) 32 + } 33 + defer func() { _ = f.Close() }() 34 + 35 + jsonBytes, err := io.ReadAll(f) 36 + if err != nil { 37 + t.Fatal(err) 38 + } 39 + 40 + var fixtures []RecordFixture 41 + if err := json.Unmarshal(jsonBytes, &fixtures); err != nil { 42 + t.Fatal(err) 43 + } 44 + 45 + for _, fixture := range fixtures { 46 + fmt.Println(fixture.Name) 47 + d, err := data.UnmarshalJSON(fixture.Data) 48 + if err != nil { 49 + t.Fatal(err) 50 + } 51 + 52 + assert.NoError(ValidateRecord(&cat, d, "example.lexicon.record", 0)) 53 + } 54 + } 55 + 56 + func TestInteropRecordInvalid(t *testing.T) { 57 + assert := assert.New(t) 58 + 59 + cat := NewBaseCatalog() 60 + if err := cat.LoadDirectory("testdata/catalog"); err != nil { 61 + t.Fatal(err) 62 + } 63 + 64 + f, err := os.Open("testdata/record-data-invalid.json") 65 + if err != nil { 66 + t.Fatal(err) 67 + } 68 + defer func() { _ = f.Close() }() 69 + 70 + jsonBytes, err := io.ReadAll(f) 71 + if err != nil { 72 + t.Fatal(err) 73 + } 74 + 75 + var fixtures []RecordFixture 76 + if err := json.Unmarshal(jsonBytes, &fixtures); err != nil { 77 + t.Fatal(err) 78 + } 79 + 80 + for _, fixture := range fixtures { 81 + fmt.Println(fixture.Name) 82 + d, err := data.UnmarshalJSON(fixture.Data) 83 + if err != nil { 84 + t.Fatal(err) 85 + } 86 + err = ValidateRecord(&cat, d, "example.lexicon.record", 0) 87 + if err == nil { 88 + fmt.Println(" FAIL") 89 + } 90 + assert.Error(err) 91 + } 92 + }
+923
atproto/lexicon/language.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "reflect" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/data" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/rivo/uniseg" 13 + ) 14 + 15 + // Serialization helper type for top-level Lexicon schema JSON objects (files) 16 + type SchemaFile struct { 17 + Lexicon int `json:"lexicon,const=1"` 18 + ID string `json:"id"` 19 + Revision *int `json:"revision,omitempty"` 20 + Description *string `json:"description,omitempty"` 21 + Defs map[string]SchemaDef `json:"defs"` 22 + } 23 + 24 + // enum type to represent any of the schema fields 25 + type SchemaDef struct { 26 + Inner any 27 + } 28 + 29 + // Checks that the schema definition itself is valid (recursively). 30 + func (s *SchemaDef) CheckSchema() error { 31 + switch v := s.Inner.(type) { 32 + case SchemaRecord: 33 + return v.CheckSchema() 34 + case SchemaQuery: 35 + return v.CheckSchema() 36 + case SchemaProcedure: 37 + return v.CheckSchema() 38 + case SchemaSubscription: 39 + return v.CheckSchema() 40 + case SchemaNull: 41 + return v.CheckSchema() 42 + case SchemaBoolean: 43 + return v.CheckSchema() 44 + case SchemaInteger: 45 + return v.CheckSchema() 46 + case SchemaString: 47 + return v.CheckSchema() 48 + case SchemaBytes: 49 + return v.CheckSchema() 50 + case SchemaCIDLink: 51 + return v.CheckSchema() 52 + case SchemaArray: 53 + return v.CheckSchema() 54 + case SchemaObject: 55 + return v.CheckSchema() 56 + case SchemaBlob: 57 + return v.CheckSchema() 58 + case SchemaParams: 59 + return v.CheckSchema() 60 + case SchemaToken: 61 + return v.CheckSchema() 62 + case SchemaRef: 63 + return v.CheckSchema() 64 + case SchemaUnion: 65 + return v.CheckSchema() 66 + case SchemaUnknown: 67 + return v.CheckSchema() 68 + default: 69 + return fmt.Errorf("unhandled schema type: %v", reflect.TypeOf(v)) 70 + } 71 + } 72 + 73 + // Helper to recurse down the definition tree and set full references on any sub-schemas which need to embed that metadata 74 + func (s *SchemaDef) SetBase(base string) { 75 + switch v := s.Inner.(type) { 76 + case SchemaRecord: 77 + for i, val := range v.Record.Properties { 78 + val.SetBase(base) 79 + v.Record.Properties[i] = val 80 + } 81 + s.Inner = v 82 + case SchemaQuery: 83 + for i, val := range v.Parameters.Properties { 84 + val.SetBase(base) 85 + v.Parameters.Properties[i] = val 86 + } 87 + if v.Output != nil && v.Output.Schema != nil { 88 + v.Output.Schema.SetBase(base) 89 + } 90 + s.Inner = v 91 + case SchemaProcedure: 92 + for i, val := range v.Parameters.Properties { 93 + val.SetBase(base) 94 + v.Parameters.Properties[i] = val 95 + } 96 + if v.Input != nil && v.Input.Schema != nil { 97 + v.Input.Schema.SetBase(base) 98 + } 99 + if v.Output != nil && v.Output.Schema != nil { 100 + v.Output.Schema.SetBase(base) 101 + } 102 + s.Inner = v 103 + case SchemaSubscription: 104 + for i, val := range v.Parameters.Properties { 105 + val.SetBase(base) 106 + v.Parameters.Properties[i] = val 107 + } 108 + if v.Message != nil { 109 + v.Message.Schema.SetBase(base) 110 + } 111 + s.Inner = v 112 + case SchemaArray: 113 + v.Items.SetBase(base) 114 + s.Inner = v 115 + case SchemaObject: 116 + for i, val := range v.Properties { 117 + val.SetBase(base) 118 + v.Properties[i] = val 119 + } 120 + s.Inner = v 121 + case SchemaParams: 122 + for i, val := range v.Properties { 123 + val.SetBase(base) 124 + v.Properties[i] = val 125 + } 126 + s.Inner = v 127 + case SchemaRef: 128 + // add fully-qualified name 129 + if strings.HasPrefix(v.Ref, "#") { 130 + v.fullRef = base + v.Ref 131 + } else { 132 + v.fullRef = v.Ref 133 + } 134 + s.Inner = v 135 + case SchemaUnion: 136 + // add fully-qualified name 137 + for _, ref := range v.Refs { 138 + if strings.HasPrefix(ref, "#") { 139 + ref = base + ref 140 + } 141 + v.fullRefs = append(v.fullRefs, ref) 142 + } 143 + s.Inner = v 144 + } 145 + return 146 + } 147 + 148 + func (s SchemaDef) MarshalJSON() ([]byte, error) { 149 + return json.Marshal(s.Inner) 150 + } 151 + 152 + func (s *SchemaDef) UnmarshalJSON(b []byte) error { 153 + t, err := ExtractTypeJSON(b) 154 + if err != nil { 155 + return err 156 + } 157 + // TODO: should we call CheckSchema here, instead of in lexicon loading? 158 + switch t { 159 + case "record": 160 + v := new(SchemaRecord) 161 + if err = json.Unmarshal(b, v); err != nil { 162 + return err 163 + } 164 + s.Inner = *v 165 + return nil 166 + case "query": 167 + v := new(SchemaQuery) 168 + if err = json.Unmarshal(b, v); err != nil { 169 + return err 170 + } 171 + s.Inner = *v 172 + return nil 173 + case "procedure": 174 + v := new(SchemaProcedure) 175 + if err = json.Unmarshal(b, v); err != nil { 176 + return err 177 + } 178 + s.Inner = *v 179 + return nil 180 + case "subscription": 181 + v := new(SchemaSubscription) 182 + if err = json.Unmarshal(b, v); err != nil { 183 + return err 184 + } 185 + s.Inner = *v 186 + return nil 187 + case "null": 188 + v := new(SchemaNull) 189 + if err = json.Unmarshal(b, v); err != nil { 190 + return err 191 + } 192 + s.Inner = *v 193 + return nil 194 + case "boolean": 195 + v := new(SchemaBoolean) 196 + if err = json.Unmarshal(b, v); err != nil { 197 + return err 198 + } 199 + s.Inner = *v 200 + return nil 201 + case "integer": 202 + v := new(SchemaInteger) 203 + if err = json.Unmarshal(b, v); err != nil { 204 + return err 205 + } 206 + s.Inner = *v 207 + return nil 208 + case "string": 209 + v := new(SchemaString) 210 + if err = json.Unmarshal(b, v); err != nil { 211 + return err 212 + } 213 + s.Inner = *v 214 + return nil 215 + case "bytes": 216 + v := new(SchemaBytes) 217 + if err = json.Unmarshal(b, v); err != nil { 218 + return err 219 + } 220 + s.Inner = *v 221 + return nil 222 + case "cid-link": 223 + v := new(SchemaCIDLink) 224 + if err = json.Unmarshal(b, v); err != nil { 225 + return err 226 + } 227 + s.Inner = *v 228 + return nil 229 + case "array": 230 + v := new(SchemaArray) 231 + if err = json.Unmarshal(b, v); err != nil { 232 + return err 233 + } 234 + s.Inner = *v 235 + return nil 236 + case "object": 237 + v := new(SchemaObject) 238 + if err = json.Unmarshal(b, v); err != nil { 239 + return err 240 + } 241 + s.Inner = *v 242 + return nil 243 + case "blob": 244 + v := new(SchemaBlob) 245 + if err = json.Unmarshal(b, v); err != nil { 246 + return err 247 + } 248 + s.Inner = *v 249 + return nil 250 + case "params": 251 + v := new(SchemaParams) 252 + if err = json.Unmarshal(b, v); err != nil { 253 + return err 254 + } 255 + s.Inner = *v 256 + return nil 257 + case "token": 258 + v := new(SchemaToken) 259 + if err = json.Unmarshal(b, v); err != nil { 260 + return err 261 + } 262 + s.Inner = *v 263 + return nil 264 + case "ref": 265 + v := new(SchemaRef) 266 + if err = json.Unmarshal(b, v); err != nil { 267 + return err 268 + } 269 + s.Inner = *v 270 + return nil 271 + case "union": 272 + v := new(SchemaUnion) 273 + if err = json.Unmarshal(b, v); err != nil { 274 + return err 275 + } 276 + s.Inner = *v 277 + return nil 278 + case "unknown": 279 + v := new(SchemaUnknown) 280 + if err = json.Unmarshal(b, v); err != nil { 281 + return err 282 + } 283 + s.Inner = *v 284 + return nil 285 + default: 286 + return fmt.Errorf("unexpected schema type: %s", t) 287 + } 288 + } 289 + 290 + type SchemaRecord struct { 291 + Type string `json:"type,const=record"` 292 + Description *string `json:"description,omitempty"` 293 + Key string `json:"key"` 294 + Record SchemaObject `json:"record"` 295 + } 296 + 297 + func (s *SchemaRecord) CheckSchema() error { 298 + switch s.Key { 299 + case "tid", "any": 300 + // pass 301 + default: 302 + if !strings.HasPrefix(s.Key, "literal:") { 303 + return fmt.Errorf("invalid record key specifier: %s", s.Key) 304 + } 305 + } 306 + return s.Record.CheckSchema() 307 + } 308 + 309 + type SchemaQuery struct { 310 + Type string `json:"type,const=query"` 311 + Description *string `json:"description,omitempty"` 312 + Parameters SchemaParams `json:"parameters"` 313 + Output *SchemaBody `json:"output"` 314 + Errors []SchemaError `json:"errors,omitempty"` // optional 315 + } 316 + 317 + func (s *SchemaQuery) CheckSchema() error { 318 + if s.Output != nil { 319 + if err := s.Output.CheckSchema(); err != nil { 320 + return err 321 + } 322 + } 323 + for _, e := range s.Errors { 324 + if err := e.CheckSchema(); err != nil { 325 + return err 326 + } 327 + } 328 + return s.Parameters.CheckSchema() 329 + } 330 + 331 + type SchemaProcedure struct { 332 + Type string `json:"type,const=procedure"` 333 + Description *string `json:"description,omitempty"` 334 + Parameters SchemaParams `json:"parameters"` 335 + Output *SchemaBody `json:"output"` // optional 336 + Errors []SchemaError `json:"errors,omitempty"` // optional 337 + Input *SchemaBody `json:"input"` // optional 338 + } 339 + 340 + func (s *SchemaProcedure) CheckSchema() error { 341 + if s.Input != nil { 342 + if err := s.Input.CheckSchema(); err != nil { 343 + return err 344 + } 345 + } 346 + if s.Output != nil { 347 + if err := s.Output.CheckSchema(); err != nil { 348 + return err 349 + } 350 + } 351 + for _, e := range s.Errors { 352 + if err := e.CheckSchema(); err != nil { 353 + return err 354 + } 355 + } 356 + return s.Parameters.CheckSchema() 357 + } 358 + 359 + type SchemaSubscription struct { 360 + Type string `json:"type,const=subscription"` 361 + Description *string `json:"description,omitempty"` 362 + Parameters SchemaParams `json:"parameters"` 363 + Message *SchemaMessage `json:"message,omitempty"` // TODO(specs): is this really optional? 364 + } 365 + 366 + func (s *SchemaSubscription) CheckSchema() error { 367 + if s.Message != nil { 368 + if err := s.Message.CheckSchema(); err != nil { 369 + return err 370 + } 371 + } 372 + return s.Parameters.CheckSchema() 373 + } 374 + 375 + type SchemaBody struct { 376 + Description *string `json:"description,omitempty"` 377 + Encoding string `json:"encoding"` // required, mimetype 378 + Schema *SchemaDef `json:"schema"` // optional; type:object, type:ref, or type:union 379 + } 380 + 381 + func (s *SchemaBody) CheckSchema() error { 382 + // TODO: any validation of encoding? 383 + if s.Schema != nil { 384 + switch s.Schema.Inner.(type) { 385 + case SchemaObject, SchemaRef, SchemaUnion: 386 + // pass 387 + default: 388 + return fmt.Errorf("body type can only have object, ref, or union schema") 389 + } 390 + if err := s.Schema.CheckSchema(); err != nil { 391 + return err 392 + } 393 + } 394 + return nil 395 + } 396 + 397 + type SchemaMessage struct { 398 + Description *string `json:"description,omitempty"` 399 + Schema SchemaDef `json:"schema"` // required; type:union only 400 + } 401 + 402 + func (s *SchemaMessage) CheckSchema() error { 403 + if _, ok := s.Schema.Inner.(SchemaUnion); !ok { 404 + return fmt.Errorf("message must have schema type union") 405 + } 406 + return s.Schema.CheckSchema() 407 + } 408 + 409 + type SchemaError struct { 410 + Name string `json:"name"` 411 + Description *string `json:"description"` 412 + } 413 + 414 + func (s *SchemaError) CheckSchema() error { 415 + return nil 416 + } 417 + func (s *SchemaError) Validate(d any) error { 418 + e, ok := d.(map[string]any) 419 + if !ok { 420 + return fmt.Errorf("expected an object in error position") 421 + } 422 + n, ok := e["error"] 423 + if !ok { 424 + return fmt.Errorf("expected error type") 425 + } 426 + if n != s.Name { 427 + return fmt.Errorf("error type mis-match: %s", n) 428 + } 429 + return nil 430 + } 431 + 432 + type SchemaNull struct { 433 + Type string `json:"type,const=null"` 434 + Description *string `json:"description,omitempty"` 435 + } 436 + 437 + func (s *SchemaNull) CheckSchema() error { 438 + return nil 439 + } 440 + 441 + func (s *SchemaNull) Validate(d any) error { 442 + if d != nil { 443 + return fmt.Errorf("expected null data, got: %s", reflect.TypeOf(d)) 444 + } 445 + return nil 446 + } 447 + 448 + type SchemaBoolean struct { 449 + Type string `json:"type,const=bool"` 450 + Description *string `json:"description,omitempty"` 451 + Default *bool `json:"default,omitempty"` 452 + Const *bool `json:"const,omitempty"` 453 + } 454 + 455 + func (s *SchemaBoolean) CheckSchema() error { 456 + if s.Default != nil && s.Const != nil { 457 + return fmt.Errorf("schema can't have both 'default' and 'const'") 458 + } 459 + return nil 460 + } 461 + 462 + func (s *SchemaBoolean) Validate(d any) error { 463 + v, ok := d.(bool) 464 + if !ok { 465 + return fmt.Errorf("expected a boolean") 466 + } 467 + if s.Const != nil && v != *s.Const { 468 + return fmt.Errorf("boolean val didn't match constant (%v): %v", *s.Const, v) 469 + } 470 + return nil 471 + } 472 + 473 + type SchemaInteger struct { 474 + Type string `json:"type,const=integer"` 475 + Description *string `json:"description,omitempty"` 476 + Minimum *int `json:"minimum,omitempty"` 477 + Maximum *int `json:"maximum,omitempty"` 478 + Enum []int `json:"enum,omitempty"` 479 + Default *int `json:"default,omitempty"` 480 + Const *int `json:"const,omitempty"` 481 + } 482 + 483 + func (s *SchemaInteger) CheckSchema() error { 484 + // TODO: enforce min/max against enum, default, const 485 + if s.Default != nil && s.Const != nil { 486 + return fmt.Errorf("schema can't have both 'default' and 'const'") 487 + } 488 + if s.Minimum != nil && s.Maximum != nil && *s.Maximum < *s.Minimum { 489 + return fmt.Errorf("schema max < min") 490 + } 491 + return nil 492 + } 493 + 494 + func (s *SchemaInteger) Validate(d any) error { 495 + v64, ok := d.(int64) 496 + if !ok { 497 + return fmt.Errorf("expected an integer") 498 + } 499 + v := int(v64) 500 + if s.Const != nil && v != *s.Const { 501 + return fmt.Errorf("integer val didn't match constant (%d): %d", *s.Const, v) 502 + } 503 + if (s.Minimum != nil && v < *s.Minimum) || (s.Maximum != nil && v > *s.Maximum) { 504 + return fmt.Errorf("integer val outside specified range: %d", v) 505 + } 506 + if len(s.Enum) != 0 { 507 + inEnum := false 508 + for _, e := range s.Enum { 509 + if e == v { 510 + inEnum = true 511 + break 512 + } 513 + } 514 + if !inEnum { 515 + return fmt.Errorf("integer val not in required enum: %d", v) 516 + } 517 + } 518 + return nil 519 + } 520 + 521 + type SchemaString struct { 522 + Type string `json:"type,const=string"` 523 + Description *string `json:"description,omitempty"` 524 + Format *string `json:"format,omitempty"` 525 + MinLength *int `json:"minLength,omitempty"` 526 + MaxLength *int `json:"maxLength,omitempty"` 527 + MinGraphemes *int `json:"minGraphemes,omitempty"` 528 + MaxGraphemes *int `json:"maxGraphemes,omitempty"` 529 + KnownValues []string `json:"knownValues,omitempty"` 530 + Enum []string `json:"enum,omitempty"` 531 + Default *string `json:"default,omitempty"` 532 + Const *string `json:"const,omitempty"` 533 + } 534 + 535 + func (s *SchemaString) CheckSchema() error { 536 + // TODO: enforce min/max against enum, default, const 537 + if s.Default != nil && s.Const != nil { 538 + return fmt.Errorf("schema can't have both 'default' and 'const'") 539 + } 540 + if s.MinLength != nil && s.MaxLength != nil && *s.MaxLength < *s.MinLength { 541 + return fmt.Errorf("schema max < min") 542 + } 543 + if s.MinGraphemes != nil && s.MaxGraphemes != nil && *s.MaxGraphemes < *s.MinGraphemes { 544 + return fmt.Errorf("schema max < min") 545 + } 546 + if (s.MinLength != nil && *s.MinLength < 0) || 547 + (s.MaxLength != nil && *s.MaxLength < 0) || 548 + (s.MinGraphemes != nil && *s.MinGraphemes < 0) || 549 + (s.MaxGraphemes != nil && *s.MaxGraphemes < 0) { 550 + return fmt.Errorf("string schema min or max below zero") 551 + } 552 + if s.Format != nil { 553 + switch *s.Format { 554 + case "at-identifier", "at-uri", "cid", "datetime", "did", "handle", "nsid", "uri", "language", "tid", "record-key": 555 + // pass 556 + default: 557 + return fmt.Errorf("unknown string format: %s", *s.Format) 558 + } 559 + } 560 + return nil 561 + } 562 + 563 + func (s *SchemaString) Validate(d any, flags ValidateFlags) error { 564 + v, ok := d.(string) 565 + if !ok { 566 + return fmt.Errorf("expected a string: %v", reflect.TypeOf(d)) 567 + } 568 + if s.Const != nil && v != *s.Const { 569 + return fmt.Errorf("string val didn't match constant (%s): %s", *s.Const, v) 570 + } 571 + // TODO: is this actually counting UTF-8 length? 572 + if (s.MinLength != nil && len(v) < *s.MinLength) || (s.MaxLength != nil && len(v) > *s.MaxLength) { 573 + return fmt.Errorf("string length outside specified range: %d", len(v)) 574 + } 575 + if len(s.Enum) != 0 { 576 + inEnum := false 577 + for _, e := range s.Enum { 578 + if e == v { 579 + inEnum = true 580 + break 581 + } 582 + } 583 + if !inEnum { 584 + return fmt.Errorf("string val not in required enum: %s", v) 585 + } 586 + } 587 + if s.MinGraphemes != nil || s.MaxGraphemes != nil { 588 + lenG := uniseg.GraphemeClusterCount(v) 589 + if (s.MinGraphemes != nil && lenG < *s.MinGraphemes) || (s.MaxGraphemes != nil && lenG > *s.MaxGraphemes) { 590 + return fmt.Errorf("string length (graphemes) outside specified range: %d", lenG) 591 + } 592 + } 593 + if s.Format != nil { 594 + switch *s.Format { 595 + case "at-identifier": 596 + if _, err := syntax.ParseAtIdentifier(v); err != nil { 597 + return err 598 + } 599 + case "at-uri": 600 + if _, err := syntax.ParseATURI(v); err != nil { 601 + return err 602 + } 603 + case "cid": 604 + if _, err := syntax.ParseCID(v); err != nil { 605 + return err 606 + } 607 + case "datetime": 608 + if flags&AllowLenientDatetime != 0 { 609 + if _, err := syntax.ParseDatetimeLenient(v); err != nil { 610 + return err 611 + } 612 + } else { 613 + if _, err := syntax.ParseDatetime(v); err != nil { 614 + return err 615 + } 616 + } 617 + case "did": 618 + if _, err := syntax.ParseDID(v); err != nil { 619 + return err 620 + } 621 + case "handle": 622 + if _, err := syntax.ParseHandle(v); err != nil { 623 + return err 624 + } 625 + case "nsid": 626 + if _, err := syntax.ParseNSID(v); err != nil { 627 + return err 628 + } 629 + case "uri": 630 + if _, err := syntax.ParseURI(v); err != nil { 631 + return err 632 + } 633 + case "language": 634 + if _, err := syntax.ParseLanguage(v); err != nil { 635 + return err 636 + } 637 + case "tid": 638 + if _, err := syntax.ParseTID(v); err != nil { 639 + return err 640 + } 641 + case "record-key": 642 + if _, err := syntax.ParseRecordKey(v); err != nil { 643 + return err 644 + } 645 + } 646 + } 647 + return nil 648 + } 649 + 650 + type SchemaBytes struct { 651 + Type string `json:"type,const=bytes"` 652 + Description *string `json:"description,omitempty"` 653 + MinLength *int `json:"minLength,omitempty"` 654 + MaxLength *int `json:"maxLength,omitempty"` 655 + } 656 + 657 + func (s *SchemaBytes) CheckSchema() error { 658 + if s.MinLength != nil && s.MaxLength != nil && *s.MaxLength < *s.MinLength { 659 + return fmt.Errorf("schema max < min") 660 + } 661 + if (s.MinLength != nil && *s.MinLength < 0) || 662 + (s.MaxLength != nil && *s.MaxLength < 0) { 663 + return fmt.Errorf("bytes schema min or max below zero") 664 + } 665 + return nil 666 + } 667 + 668 + func (s *SchemaBytes) Validate(d any) error { 669 + v, ok := d.(data.Bytes) 670 + if !ok { 671 + return fmt.Errorf("expecting bytes") 672 + } 673 + if (s.MinLength != nil && len(v) < *s.MinLength) || (s.MaxLength != nil && len(v) > *s.MaxLength) { 674 + return fmt.Errorf("bytes size out of bounds: %d", len(v)) 675 + } 676 + return nil 677 + } 678 + 679 + type SchemaCIDLink struct { 680 + Type string `json:"type,const=cid-link"` 681 + Description *string `json:"description,omitempty"` 682 + } 683 + 684 + func (s *SchemaCIDLink) CheckSchema() error { 685 + return nil 686 + } 687 + 688 + func (s *SchemaCIDLink) Validate(d any) error { 689 + _, ok := d.(data.CIDLink) 690 + if !ok { 691 + return fmt.Errorf("expecting a cid-link") 692 + } 693 + return nil 694 + } 695 + 696 + type SchemaArray struct { 697 + Type string `json:"type,const=array"` 698 + Description *string `json:"description,omitempty"` 699 + Items SchemaDef `json:"items"` 700 + MinLength *int `json:"minLength,omitempty"` 701 + MaxLength *int `json:"maxLength,omitempty"` 702 + } 703 + 704 + func (s *SchemaArray) CheckSchema() error { 705 + if s.MinLength != nil && s.MaxLength != nil && *s.MaxLength < *s.MinLength { 706 + return fmt.Errorf("schema max < min") 707 + } 708 + if (s.MinLength != nil && *s.MinLength < 0) || 709 + (s.MaxLength != nil && *s.MaxLength < 0) { 710 + return fmt.Errorf("array schema min or max below zero") 711 + } 712 + return s.Items.CheckSchema() 713 + } 714 + 715 + type SchemaObject struct { 716 + Type string `json:"type,const=object"` 717 + Description *string `json:"description,omitempty"` 718 + Properties map[string]SchemaDef `json:"properties"` 719 + Required []string `json:"required,omitempty"` 720 + Nullable []string `json:"nullable,omitempty"` 721 + } 722 + 723 + func (s *SchemaObject) CheckSchema() error { 724 + // TODO: check for set intersection between required and nullable 725 + // TODO: check for set uniqueness of required and nullable 726 + for _, k := range s.Required { 727 + if _, ok := s.Properties[k]; !ok { 728 + return fmt.Errorf("object 'required' field not in properties: %s", k) 729 + } 730 + } 731 + for _, k := range s.Nullable { 732 + if _, ok := s.Properties[k]; !ok { 733 + return fmt.Errorf("object 'nullable' field not in properties: %s", k) 734 + } 735 + } 736 + for k, def := range s.Properties { 737 + // TODO: more checks on field name? 738 + if len(k) == 0 { 739 + return fmt.Errorf("empty object schema field name not allowed") 740 + } 741 + if err := def.CheckSchema(); err != nil { 742 + return err 743 + } 744 + } 745 + return nil 746 + } 747 + 748 + // Checks if a field name 'k' is one of the Nullable fields for this object 749 + func (s *SchemaObject) IsNullable(k string) bool { 750 + for _, el := range s.Nullable { 751 + if el == k { 752 + return true 753 + } 754 + } 755 + return false 756 + } 757 + 758 + type SchemaBlob struct { 759 + Type string `json:"type,const=blob"` 760 + Description *string `json:"description,omitempty"` 761 + Accept []string `json:"accept,omitempty"` 762 + MaxSize *int `json:"maxSize,omitempty"` 763 + } 764 + 765 + func (s *SchemaBlob) CheckSchema() error { 766 + // TODO: validate Accept (mimetypes)? 767 + if s.MaxSize != nil && *s.MaxSize <= 0 { 768 + return fmt.Errorf("blob max size less or equal to zero") 769 + } 770 + return nil 771 + } 772 + 773 + func (s *SchemaBlob) Validate(d any, flags ValidateFlags) error { 774 + v, ok := d.(data.Blob) 775 + if !ok { 776 + return fmt.Errorf("expected a blob") 777 + } 778 + if !(flags&AllowLegacyBlob != 0) && v.Size < 0 { 779 + return fmt.Errorf("legacy blobs not allowed") 780 + } 781 + if len(s.Accept) > 0 { 782 + typeOk := false 783 + for _, pat := range s.Accept { 784 + if acceptableMimeType(pat, v.MimeType) { 785 + typeOk = true 786 + break 787 + } 788 + } 789 + if !typeOk { 790 + return fmt.Errorf("blob mimetype doesn't match accepted: %s", v.MimeType) 791 + } 792 + } 793 + if s.MaxSize != nil && int(v.Size) > *s.MaxSize { 794 + return fmt.Errorf("blob size too large: %d", v.Size) 795 + } 796 + return nil 797 + } 798 + 799 + type SchemaParams struct { 800 + Type string `json:"type,const=params"` 801 + Description *string `json:"description,omitempty"` 802 + Properties map[string]SchemaDef `json:"properties"` // boolean, integer, string, or unknown; or an array of these types 803 + Required []string `json:"required,omitempty"` 804 + } 805 + 806 + func (s *SchemaParams) CheckSchema() error { 807 + // TODO: check for set uniqueness of required 808 + for _, k := range s.Required { 809 + if _, ok := s.Properties[k]; !ok { 810 + return fmt.Errorf("object 'required' field not in properties: %s", k) 811 + } 812 + } 813 + for k, def := range s.Properties { 814 + // TODO: more checks on field name? 815 + if len(k) == 0 { 816 + return fmt.Errorf("empty object schema field name not allowed") 817 + } 818 + switch v := def.Inner.(type) { 819 + case SchemaBoolean, SchemaInteger, SchemaString, SchemaUnknown: 820 + // pass 821 + case SchemaArray: 822 + switch v.Items.Inner.(type) { 823 + case SchemaBoolean, SchemaInteger, SchemaString, SchemaUnknown: 824 + // pass 825 + default: 826 + return fmt.Errorf("params array item type must be boolean, integer, string, or unknown") 827 + } 828 + default: 829 + return fmt.Errorf("params field type must be boolean, integer, string, or unknown") 830 + } 831 + if err := def.CheckSchema(); err != nil { 832 + return err 833 + } 834 + } 835 + return nil 836 + } 837 + 838 + type SchemaToken struct { 839 + Type string `json:"type,const=token"` 840 + Description *string `json:"description,omitempty"` 841 + // the fully-qualified identifier of this token 842 + fullName string 843 + } 844 + 845 + func (s *SchemaToken) CheckSchema() error { 846 + if s.fullName == "" { 847 + return fmt.Errorf("expected fully-qualified token name") 848 + } 849 + return nil 850 + } 851 + 852 + func (s *SchemaToken) Validate(d any) error { 853 + str, ok := d.(string) 854 + if !ok { 855 + return fmt.Errorf("expected a string for token, got: %s", reflect.TypeOf(d)) 856 + } 857 + if s.fullName == "" { 858 + return fmt.Errorf("token name was not populated at parse time") 859 + } 860 + if str != s.fullName { 861 + return fmt.Errorf("token name did not match expected: %s", str) 862 + } 863 + return nil 864 + } 865 + 866 + type SchemaRef struct { 867 + Type string `json:"type,const=ref"` 868 + Description *string `json:"description,omitempty"` 869 + Ref string `json:"ref"` 870 + // full path of reference 871 + fullRef string 872 + } 873 + 874 + func (s *SchemaRef) CheckSchema() error { 875 + // TODO: more validation of ref string? 876 + if len(s.Ref) == 0 { 877 + return fmt.Errorf("empty schema ref") 878 + } 879 + if len(s.fullRef) == 0 { 880 + return fmt.Errorf("empty full schema ref") 881 + } 882 + return nil 883 + } 884 + 885 + type SchemaUnion struct { 886 + Type string `json:"type,const=union"` 887 + Description *string `json:"description,omitempty"` 888 + Refs []string `json:"refs"` 889 + Closed *bool `json:"closed,omitempty"` 890 + // fully qualified 891 + fullRefs []string 892 + } 893 + 894 + func (s *SchemaUnion) CheckSchema() error { 895 + // TODO: uniqueness check on refs 896 + for _, ref := range s.Refs { 897 + // TODO: more validation of ref string? 898 + if len(ref) == 0 { 899 + return fmt.Errorf("empty schema ref") 900 + } 901 + } 902 + if len(s.fullRefs) != len(s.Refs) { 903 + return fmt.Errorf("union refs were not expanded") 904 + } 905 + return nil 906 + } 907 + 908 + type SchemaUnknown struct { 909 + Type string `json:"type,const=unknown"` 910 + Description *string `json:"description,omitempty"` 911 + } 912 + 913 + func (s *SchemaUnknown) CheckSchema() error { 914 + return nil 915 + } 916 + 917 + func (s *SchemaUnknown) Validate(d any) error { 918 + _, ok := d.(map[string]any) 919 + if !ok { 920 + return fmt.Errorf("'unknown' data must an object") 921 + } 922 + return nil 923 + }
+47
atproto/lexicon/language_test.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "os" 7 + "testing" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + func TestBasicLabelLexicon(t *testing.T) { 13 + assert := assert.New(t) 14 + 15 + f, err := os.Open("testdata/catalog/com_atproto_label_defs.json") 16 + if err != nil { 17 + t.Fatal(err) 18 + } 19 + defer func() { _ = f.Close() }() 20 + 21 + jsonBytes, err := io.ReadAll(f) 22 + if err != nil { 23 + t.Fatal(err) 24 + } 25 + 26 + var schema SchemaFile 27 + if err := json.Unmarshal(jsonBytes, &schema); err != nil { 28 + t.Fatal(err) 29 + } 30 + 31 + outBytes, err := json.Marshal(schema) 32 + if err != nil { 33 + t.Fatal(err) 34 + } 35 + 36 + var beforeMap map[string]any 37 + if err := json.Unmarshal(jsonBytes, &beforeMap); err != nil { 38 + t.Fatal(err) 39 + } 40 + 41 + var afterMap map[string]any 42 + if err := json.Unmarshal(outBytes, &afterMap); err != nil { 43 + t.Fatal(err) 44 + } 45 + 46 + assert.Equal(beforeMap, afterMap) 47 + }
+179
atproto/lexicon/lexicon.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "fmt" 5 + "reflect" 6 + ) 7 + 8 + // Boolean flags tweaking how Lexicon validation rules are interpreted. 9 + type ValidateFlags int 10 + 11 + const ( 12 + // Flag which allows legacy "blob" data to pass validation. 13 + AllowLegacyBlob = 1 << iota 14 + // Flag which loosens "datetime" string syntax validation. String must still be an ISO datetime, but might be missing timezone (for example) 15 + AllowLenientDatetime 16 + // Flag which requires validation of nested data in open unions. By default nested union types are only validated optimistically (if the type is known in catatalog) for unlisted types. This flag will result in a validation error if the Lexicon can't be resolved from the catalog. 17 + StrictRecursiveValidation 18 + ) 19 + 20 + // Combination of agument flags for less formal validation. Recommended for, eg, working with old/legacy data from 2023. 21 + var LenientMode ValidateFlags = AllowLegacyBlob | AllowLenientDatetime 22 + 23 + // Represents a Lexicon schema definition 24 + type Schema struct { 25 + ID string 26 + Revision *int 27 + Def any 28 + } 29 + 30 + // Checks Lexicon schema (fetched from the catalog) for the given record, with optional flags tweaking default validation rules. 31 + // 32 + // 'recordData' is typed as 'any', but is expected to be 'map[string]any' 33 + // 'ref' is a reference to the schema type, as an NSID with optional fragment. For records, the '$type' must match 'ref' 34 + // 'flags' are parameters tweaking Lexicon validation rules. Zero value is default. 35 + func ValidateRecord(cat Catalog, recordData any, ref string, flags ValidateFlags) error { 36 + return validateRecordConfig(cat, recordData, ref, flags) 37 + } 38 + 39 + func validateRecordConfig(cat Catalog, recordData any, ref string, flags ValidateFlags) error { 40 + def, err := cat.Resolve(ref) 41 + if err != nil { 42 + return err 43 + } 44 + s, ok := def.Def.(SchemaRecord) 45 + if !ok { 46 + return fmt.Errorf("schema is not of record type: %s", ref) 47 + } 48 + d, ok := recordData.(map[string]any) 49 + if !ok { 50 + return fmt.Errorf("record data is not object type") 51 + } 52 + t, ok := d["$type"] 53 + if !ok || t != ref { 54 + return fmt.Errorf("record data missing $type, or didn't match expected NSID") 55 + } 56 + return validateObject(cat, s.Record, d, flags) 57 + } 58 + 59 + func validateData(cat Catalog, def any, d any, flags ValidateFlags) error { 60 + switch v := def.(type) { 61 + case SchemaNull: 62 + return v.Validate(d) 63 + case SchemaBoolean: 64 + return v.Validate(d) 65 + case SchemaInteger: 66 + return v.Validate(d) 67 + case SchemaString: 68 + return v.Validate(d, flags) 69 + case SchemaBytes: 70 + return v.Validate(d) 71 + case SchemaCIDLink: 72 + return v.Validate(d) 73 + case SchemaArray: 74 + arr, ok := d.([]any) 75 + if !ok { 76 + return fmt.Errorf("expected an array, got: %s", reflect.TypeOf(d)) 77 + } 78 + return validateArray(cat, v, arr, flags) 79 + case SchemaObject: 80 + obj, ok := d.(map[string]any) 81 + if !ok { 82 + return fmt.Errorf("expected an object, got: %s", reflect.TypeOf(d)) 83 + } 84 + return validateObject(cat, v, obj, flags) 85 + case SchemaBlob: 86 + return v.Validate(d, flags) 87 + case SchemaRef: 88 + // recurse 89 + next, err := cat.Resolve(v.fullRef) 90 + if err != nil { 91 + return err 92 + } 93 + return validateData(cat, next.Def, d, flags) 94 + case SchemaUnion: 95 + return validateUnion(cat, v, d, flags) 96 + case SchemaUnknown: 97 + return v.Validate(d) 98 + case SchemaToken: 99 + return v.Validate(d) 100 + default: 101 + return fmt.Errorf("unhandled schema type: %s", reflect.TypeOf(v)) 102 + } 103 + } 104 + 105 + func validateObject(cat Catalog, s SchemaObject, d map[string]any, flags ValidateFlags) error { 106 + for _, k := range s.Required { 107 + if _, ok := d[k]; !ok { 108 + return fmt.Errorf("required field missing: %s", k) 109 + } 110 + } 111 + for k, def := range s.Properties { 112 + if v, ok := d[k]; ok { 113 + if v == nil && s.IsNullable(k) { 114 + continue 115 + } 116 + err := validateData(cat, def.Inner, v, flags) 117 + if err != nil { 118 + return err 119 + } 120 + } 121 + } 122 + return nil 123 + } 124 + 125 + func validateArray(cat Catalog, s SchemaArray, arr []any, flags ValidateFlags) error { 126 + if (s.MinLength != nil && len(arr) < *s.MinLength) || (s.MaxLength != nil && len(arr) > *s.MaxLength) { 127 + return fmt.Errorf("array length out of bounds: %d", len(arr)) 128 + } 129 + for _, v := range arr { 130 + err := validateData(cat, s.Items.Inner, v, flags) 131 + if err != nil { 132 + return err 133 + } 134 + } 135 + return nil 136 + } 137 + 138 + func validateUnion(cat Catalog, s SchemaUnion, d any, flags ValidateFlags) error { 139 + closed := s.Closed != nil && *s.Closed == true 140 + 141 + obj, ok := d.(map[string]any) 142 + if !ok { 143 + return fmt.Errorf("union data is not object type") 144 + } 145 + typeVal, ok := obj["$type"] 146 + if !ok { 147 + return fmt.Errorf("union data must have $type") 148 + } 149 + t, ok := typeVal.(string) 150 + if !ok { 151 + return fmt.Errorf("union data must have string $type") 152 + } 153 + 154 + for _, ref := range s.fullRefs { 155 + if ref != t { 156 + continue 157 + } 158 + def, err := cat.Resolve(ref) 159 + if err != nil { 160 + return fmt.Errorf("could not resolve known union variant $type: %s", ref) 161 + } 162 + return validateData(cat, def.Def, d, flags) 163 + } 164 + if closed { 165 + return fmt.Errorf("data did not match any variant of closed union: %s", t) 166 + } 167 + 168 + // eagerly attempt validation of the open union type 169 + // TODO: validate reference as NSID with optional fragment 170 + def, err := cat.Resolve(t) 171 + if err != nil { 172 + if flags&StrictRecursiveValidation != 0 { 173 + return fmt.Errorf("could not strictly validate open union variant $type: %s", t) 174 + } 175 + // by default, ignore validation of unknown open union data 176 + return nil 177 + } 178 + return validateData(cat, def.Def, d, flags) 179 + }
+47
atproto/lexicon/lexicon_test.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestBasicCatalog(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + cat := NewBaseCatalog() 13 + if err := cat.LoadDirectory("testdata/catalog"); err != nil { 14 + t.Fatal(err) 15 + } 16 + 17 + def, err := cat.Resolve("com.atproto.label.defs#label") 18 + if err != nil { 19 + t.Fatal(err) 20 + } 21 + assert.NoError(validateData( 22 + &cat, 23 + def.Def, 24 + map[string]any{ 25 + "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", 26 + "cts": "2000-01-01T00:00:00.000Z", 27 + "neg": false, 28 + "src": "did:example:labeler", 29 + "uri": "at://did:plc:asdf123/com.atproto.feed.post/asdf123", 30 + "val": "test-label", 31 + }, 32 + 0, 33 + )) 34 + 35 + assert.Error(validateData( 36 + &cat, 37 + def.Def, 38 + map[string]any{ 39 + "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", 40 + "cts": "2000-01-01T00:00:00.000Z", 41 + "neg": false, 42 + "uri": "at://did:plc:asdf123/com.atproto.feed.post/asdf123", 43 + "val": "test-label", 44 + }, 45 + 0, 46 + )) 47 + }
+18
atproto/lexicon/mimetype.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + // checks if val matches pattern, with optional trailing glob on pattern. case-sensitive. 8 + func acceptableMimeType(pattern, val string) bool { 9 + if val == "" || pattern == "" { 10 + return false 11 + } 12 + if strings.HasSuffix(pattern, "*") { 13 + prefix := pattern[:len(pattern)-1] 14 + return strings.HasPrefix(val, prefix) 15 + } else { 16 + return pattern == val 17 + } 18 + }
+21
atproto/lexicon/mimetype_test.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestAcceptableMimeType(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + assert.True(acceptableMimeType("image/*", "image/png")) 13 + assert.True(acceptableMimeType("text/plain", "text/plain")) 14 + 15 + assert.False(acceptableMimeType("image/*", "text/plain")) 16 + assert.False(acceptableMimeType("text/plain", "image/png")) 17 + assert.False(acceptableMimeType("text/plain", "")) 18 + assert.False(acceptableMimeType("", "text/plain")) 19 + 20 + // TODO: application/json, application/json+thing 21 + }
+78
atproto/lexicon/testdata/catalog/com_atproto_label_defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "description": "Metadata tag on an atproto resource (eg, repo or record)", 7 + "properties": { 8 + "cid": { 9 + "description": "optionally, CID specifying the specific version of 'uri' resource this label applies to", 10 + "format": "cid", 11 + "type": "string" 12 + }, 13 + "cts": { 14 + "description": "timestamp when this label was created", 15 + "format": "datetime", 16 + "type": "string" 17 + }, 18 + "neg": { 19 + "description": "if true, this is a negation label, overwriting a previous label", 20 + "type": "boolean" 21 + }, 22 + "src": { 23 + "description": "DID of the actor who created this label", 24 + "format": "did", 25 + "type": "string" 26 + }, 27 + "uri": { 28 + "description": "AT URI of the record, repository (account), or other resource which this label applies to", 29 + "format": "uri", 30 + "type": "string" 31 + }, 32 + "val": { 33 + "description": "the short string name of the value or type of this label", 34 + "maxLength": 128, 35 + "type": "string" 36 + } 37 + }, 38 + "required": [ 39 + "src", 40 + "uri", 41 + "val", 42 + "cts" 43 + ], 44 + "type": "object" 45 + }, 46 + "selfLabel": { 47 + "description": "Metadata tag on an atproto record, published by the author within the record. Note -- schemas should use #selfLabels, not #selfLabel.", 48 + "properties": { 49 + "val": { 50 + "description": "the short string name of the value or type of this label", 51 + "maxLength": 128, 52 + "type": "string" 53 + } 54 + }, 55 + "required": [ 56 + "val" 57 + ], 58 + "type": "object" 59 + }, 60 + "selfLabels": { 61 + "description": "Metadata tags on an atproto record, published by the author within the record.", 62 + "properties": { 63 + "values": { 64 + "items": { 65 + "ref": "#selfLabel", 66 + "type": "ref" 67 + }, 68 + "maxLength": 10, 69 + "type": "array" 70 + } 71 + }, 72 + "required": [ 73 + "values" 74 + ], 75 + "type": "object" 76 + } 77 + } 78 + }
+70
atproto/lexicon/testdata/catalog/query.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "example.lexicon.query", 4 + "revision": 1, 5 + "description": "exersizes many lexicon features for the query type", 6 + "defs": { 7 + "main": { 8 + "type": "query", 9 + "description": "a query type", 10 + "parameters": { 11 + "type": "params", 12 + "description": "a params type", 13 + "required": ["string"], 14 + "properties": { 15 + "boolean": { 16 + "type": "boolean", 17 + "description": "field of type boolean" 18 + }, 19 + "integer": { 20 + "type": "integer", 21 + "description": "field of type integer" 22 + }, 23 + "string": { 24 + "type": "string", 25 + "description": "field of type string" 26 + }, 27 + "handle": { 28 + "type": "string", 29 + "format": "handle", 30 + "description": "field of type string, format handle" 31 + }, 32 + "unknown": { 33 + "type": "unknown", 34 + "description": "field of type unknown" 35 + }, 36 + "array": { 37 + "type": "array", 38 + "description": "field of type array", 39 + "items": { "type": "integer" } 40 + } 41 + } 42 + }, 43 + "output": { 44 + "description": "output body type", 45 + "encoding": "application/json", 46 + "schema": { 47 + "type": "object", 48 + "properties": { 49 + "a": { 50 + "type": "integer" 51 + }, 52 + "b": { 53 + "type": "integer" 54 + } 55 + } 56 + } 57 + }, 58 + "errors": [ 59 + { 60 + "name": "DemoError", 61 + "description": "demo error value" 62 + }, 63 + { 64 + "name": "AnotherDemoError", 65 + "description": "another demo error value" 66 + } 67 + ] 68 + } 69 + } 70 + }
+234
atproto/lexicon/testdata/catalog/record.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "example.lexicon.record", 4 + "revision": 1, 5 + "description": "exersizes many lexicon features for the record type", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "literal:demo", 10 + "description": "a record type with many field", 11 + "record": { 12 + "required": [ "integer" ], 13 + "nullable": [ "nullableString" ], 14 + "properties": { 15 + "null": { 16 + "type": "null", 17 + "description": "field of type null" 18 + }, 19 + "boolean": { 20 + "type": "boolean", 21 + "description": "field of type boolean" 22 + }, 23 + "integer": { 24 + "type": "integer", 25 + "description": "field of type integer" 26 + }, 27 + "string": { 28 + "type": "string", 29 + "description": "field of type string" 30 + }, 31 + "nullableString": { 32 + "type": "string", 33 + "description": "field of type string; value is nullable" 34 + }, 35 + "bytes": { 36 + "type": "bytes", 37 + "description": "field of type bytes" 38 + }, 39 + "cid-link": { 40 + "type": "cid-link", 41 + "description": "field of type cid-link" 42 + }, 43 + "blob": { 44 + "type": "blob", 45 + "description": "field of type blob" 46 + }, 47 + "unknown": { 48 + "type": "unknown", 49 + "description": "field of type unknown" 50 + }, 51 + "array": { 52 + "type": "array", 53 + "description": "field of type array", 54 + "items": { "type": "integer" } 55 + }, 56 + "object": { 57 + "type": "object", 58 + "description": "field of type null", 59 + "properties": { 60 + "a": { "type": "integer" }, 61 + "b": { "type": "integer" } 62 + } 63 + }, 64 + "ref": { 65 + "type": "ref", 66 + "description": "field of type ref", 67 + "ref": "example.lexicon.record#demoToken" 68 + }, 69 + "union": { 70 + "type": "union", 71 + "refs": [ 72 + "example.lexicon.record#demoObject", 73 + "example.lexicon.record#demoObjectTwo" 74 + ] 75 + }, 76 + "formats": { 77 + "type": "ref", 78 + "ref": "example.lexicon.record#stringFormats" 79 + }, 80 + "constInteger": { 81 + "type": "integer", 82 + "const": 42 83 + }, 84 + "defaultInteger": { 85 + "type": "integer", 86 + "default": 42 87 + }, 88 + "enumInteger": { 89 + "type": "integer", 90 + "enum": [4, 9, 16, 25] 91 + }, 92 + "rangeInteger": { 93 + "type": "integer", 94 + "minimum": 10, 95 + "maximum": 20 96 + }, 97 + "lenString": { 98 + "type": "string", 99 + "minLength": 10, 100 + "maxLength": 20 101 + }, 102 + "graphemeString": { 103 + "type": "string", 104 + "minGraphemes": 10, 105 + "maxGraphemes": 20 106 + }, 107 + "enumString": { 108 + "type": "string", 109 + "enum": ["fish", "tree", "rock"] 110 + }, 111 + "knownString": { 112 + "type": "string", 113 + "knownValues": ["blue", "green", "red"] 114 + }, 115 + "sizeBytes": { 116 + "type": "bytes", 117 + "minLength": 10, 118 + "maxLength": 20 119 + }, 120 + "lenArray": { 121 + "type": "array", 122 + "items": { "type": "integer" }, 123 + "minLength": 2, 124 + "maxLength": 5 125 + }, 126 + "sizeBlob": { 127 + "type": "blob", 128 + "maxSize": 20 129 + }, 130 + "acceptBlob": { 131 + "type": "blob", 132 + "accept": [ "image/*" ] 133 + }, 134 + "closedUnion": { 135 + "type": "union", 136 + "refs": [ 137 + "example.lexicon.record#demoObject" 138 + ], 139 + "closed": true 140 + } 141 + } 142 + } 143 + }, 144 + "stringFormats": { 145 + "type": "object", 146 + "description": "all the various string format types", 147 + "properties": { 148 + "did": { 149 + "type": "string", 150 + "format": "did", 151 + "description": "a did string" 152 + }, 153 + "handle": { 154 + "type": "string", 155 + "format": "handle", 156 + "description": "a did string" 157 + }, 158 + "atidentifier": { 159 + "type": "string", 160 + "format": "at-identifier", 161 + "description": "an at-identifier string" 162 + }, 163 + "nsid": { 164 + "type": "string", 165 + "format": "nsid", 166 + "description": "an nsid string" 167 + }, 168 + "aturi": { 169 + "type": "string", 170 + "format": "at-uri", 171 + "description": "an at-uri string" 172 + }, 173 + "cid": { 174 + "type": "string", 175 + "format": "cid", 176 + "description": "a cid string (not a cid-link)" 177 + }, 178 + "datetime": { 179 + "type": "string", 180 + "format": "datetime", 181 + "description": "a datetime string" 182 + }, 183 + "language": { 184 + "type": "string", 185 + "format": "language", 186 + "description": "a language string" 187 + }, 188 + "uri": { 189 + "type": "string", 190 + "format": "uri", 191 + "description": "a generic URI field" 192 + }, 193 + "tid": { 194 + "type": "string", 195 + "format": "tid", 196 + "description": "a generic TID field" 197 + }, 198 + "recordkey": { 199 + "type": "string", 200 + "format": "record-key", 201 + "description": "a generic record-key field" 202 + } 203 + } 204 + }, 205 + "demoToken": { 206 + "type": "token", 207 + "description": "an example of what a token looks like" 208 + }, 209 + "demoObject": { 210 + "type": "object", 211 + "description": "smaller object schema for unions", 212 + "properties": { 213 + "a": { 214 + "type": "integer" 215 + }, 216 + "b": { 217 + "type": "integer" 218 + } 219 + } 220 + }, 221 + "demoObjectTwo": { 222 + "type": "object", 223 + "description": "smaller object schema for unions", 224 + "properties": { 225 + "c": { 226 + "type": "integer" 227 + }, 228 + "d": { 229 + "type": "integer" 230 + } 231 + } 232 + } 233 + } 234 + }
+18
atproto/lexicon/testdata/lexicon-invalid.json
··· 1 + [ 2 + { 3 + "name": "invalid lexicon field", 4 + "lexicon": { 5 + "lexicon": "one", 6 + "id": "example.lexicon", 7 + "defs": { "demo": { "type": "integer" } } 8 + } 9 + }, 10 + { 11 + "name": "invalid id field", 12 + "lexicon": { 13 + "lexicon": 1, 14 + "id": 2, 15 + "defs": { "demo": { "type": "integer" } } 16 + } 17 + } 18 + ]
+10
atproto/lexicon/testdata/lexicon-valid.json
··· 1 + [ 2 + { 3 + "name": "minimal", 4 + "lexicon": { 5 + "lexicon": 1, 6 + "id": "example.lexicon", 7 + "defs": { "demo": { "type": "integer" } } 8 + } 9 + } 10 + ]
+223
atproto/lexicon/testdata/record-data-invalid.json
··· 1 + [ 2 + { "name": "missing required field", 3 + "rkey": "demo", 4 + "data": { "$type": "example.lexicon.record" } 5 + }, 6 + { "name": "invalid null field", 7 + "rkey": "demo", 8 + "data": { "$type": "example.lexicon.record", "integer": 1, "null": true } }, 9 + { "name": "invalid boolean field", 10 + "rkey": "demo", 11 + "data": { "$type": "example.lexicon.record", "integer": 1, "boolean": "green"} }, 12 + { "name": "invalid integer field", 13 + "rkey": "demo", 14 + "data": { "$type": "example.lexicon.record", "integer": 1, "integer": "green"} }, 15 + { "name": "invalid non-nullable string field", 16 + "rkey": "demo", 17 + "data": { "$type": "example.lexicon.record", "integer": 1, "string": null } }, 18 + { "name": "invalid string field", 19 + "rkey": "demo", 20 + "data": { "$type": "example.lexicon.record", "integer": 1, "string": 2 } }, 21 + { "name": "invalid bytes field", 22 + "rkey": "demo", 23 + "data": { "$type": "example.lexicon.record", "integer": 1, "bytes": "green" } }, 24 + { "name": "invalid bytes: empty object", 25 + "rkey": "demo", 26 + "data": { "$type": "example.lexicon.record", "integer": 1, "bytes": {}}}, 27 + { "name": "invalid bytes: wrong type", 28 + "rkey": "demo", 29 + "data": { "$type": "example.lexicon.record", "integer": 1, "bytes": { 30 + "bytes": "asdfasdfasdfasdf" 31 + }}}, 32 + { "name": "invalid cid-link field", 33 + "rkey": "demo", 34 + "data": { "$type": "example.lexicon.record", "integer": 1, "cid-link": "green" } }, 35 + { "name": "invalid blob field", 36 + "rkey": "demo", 37 + "data": { "$type": "example.lexicon.record", "integer": 1, "blob": "green" } }, 38 + { "name": "invalid blob: wrong type", 39 + "rkey": "demo", 40 + "data": { "$type": "example.lexicon.record", "integer": 1, "blob": { 41 + "type": "blob", 42 + "size": 123, 43 + "mimeType": false, 44 + "ref": { 45 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 46 + } 47 + }}}, 48 + { "name": "invalid array", 49 + "rkey": "demo", 50 + "data": { "$type": "example.lexicon.record", "integer": 1, "array": 123 } 51 + }, 52 + { "name": "invalid array element", 53 + "rkey": "demo", 54 + "data": { "$type": "example.lexicon.record", "integer": 1, "array": [true, false] } 55 + }, 56 + { "name": "object wrong data type", 57 + "rkey": "demo", 58 + "data": { "$type": "example.lexicon.record", "integer": 1, "object": 123 } 59 + }, 60 + { "name": "object nested wrong data type", 61 + "rkey": "demo", 62 + "data": { "$type": "example.lexicon.record", "integer": 1, "object": {"a": "not-a-number" } } 63 + }, 64 + { "name": "invalid token ref type", 65 + "rkey": "demo", 66 + "data": { "$type": "example.lexicon.record", "integer": 1, "ref": 123 } 67 + }, 68 + { "name": "invalid ref value", 69 + "rkey": "demo", 70 + "data": { "$type": "example.lexicon.record", "integer": 1, "ref": "example.lexicon.record#wrongToken" } 71 + }, 72 + { "name": "invalid string format handle", 73 + "rkey": "demo", 74 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "handle": "123" } } 75 + }, 76 + { "name": "invalid string format did", 77 + "rkey": "demo", 78 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "did": "123" } } 79 + }, 80 + { "name": "invalid string format atidentifier", 81 + "rkey": "demo", 82 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "atidentifier": "123" } } 83 + }, 84 + { "name": "invalid string format nsid", 85 + "rkey": "demo", 86 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "nsid": "123" } } 87 + }, 88 + { "name": "invalid string format aturi", 89 + "rkey": "demo", 90 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "aturi": "123" } } 91 + }, 92 + { "name": "invalid string format cid", 93 + "rkey": "demo", 94 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "cid": "123" } } 95 + }, 96 + { "name": "invalid string format datetime", 97 + "rkey": "demo", 98 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "datetime": "123" } } 99 + }, 100 + { "name": "invalid string format language", 101 + "rkey": "demo", 102 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "language": "123" } } 103 + }, 104 + { "name": "invalid string format uri", 105 + "rkey": "demo", 106 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "uri": "123" } } 107 + }, 108 + { "name": "invalid string format tid", 109 + "rkey": "demo", 110 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "tid": "000" } } 111 + }, 112 + { "name": "invalid string format recordkey", 113 + "rkey": "demo", 114 + "data": { "$type": "example.lexicon.record", "integer": 1, "formats": { "recordkey": "." } } 115 + }, 116 + { "name": "wrong const value", 117 + "rkey": "demo", 118 + "data": { "$type": "example.lexicon.record", "integer": 1, "constInteger": 41 } 119 + }, 120 + { "name": "integer not in enum", 121 + "rkey": "demo", 122 + "data": { "$type": "example.lexicon.record", "integer": 1, "enumInteger": 7 } 123 + }, 124 + { "name": "out of integer range", 125 + "rkey": "demo", 126 + "data": { "$type": "example.lexicon.record", "integer": 1, "rangeInteger": 9000 } 127 + }, 128 + { "name": "string too short", 129 + "rkey": "demo", 130 + "data": { "$type": "example.lexicon.record", "integer": 1, "lenString": "." } 131 + }, 132 + { "name": "string too long", 133 + "rkey": "demo", 134 + "data": { "$type": "example.lexicon.record", "integer": 1, "lenString": "abcdefg-abcdefg-abcdefg" } 135 + }, 136 + { "name": "string too short (graphemes)", 137 + "rkey": "demo", 138 + "data": { "$type": "example.lexicon.record", "integer": 1, "graphemeString": "👩‍👩‍👦‍👦👩‍👩‍👦‍👦" } 139 + }, 140 + { "name": "string too long (graphemes)", 141 + "rkey": "demo", 142 + "data": { "$type": "example.lexicon.record", "integer": 1, "graphemeString": "abcdefg-abcdefg-abcdefg" } 143 + }, 144 + { "name": "out of enum string", 145 + "rkey": "demo", 146 + "data": { "$type": "example.lexicon.record", "integer": 1, "enumString": "unexpected" } 147 + }, 148 + { "name": "bytes too short", 149 + "rkey": "demo", 150 + "data": { "$type": "example.lexicon.record", "integer": 1, "sizeBytes": { "$bytes": "b25l" }} 151 + }, 152 + { "name": "bytes too long", 153 + "rkey": "demo", 154 + "data": { "$type": "example.lexicon.record", "integer": 1, "sizeBytes": { "$bytes": "b25lb25lb25lb25lb25lb25lb25lb25lb25lb25lb25l" }} 155 + }, 156 + { "name": "array too short", 157 + "rkey": "demo", 158 + "data": { "$type": "example.lexicon.record", "integer": 1, "lenArray": [0]} 159 + }, 160 + { "name": "array too long", 161 + "rkey": "demo", 162 + "data": { "$type": "example.lexicon.record", "integer": 1, "lenArray": [0,0,0,0,0,0,0,0,0,0]} 163 + }, 164 + { "name": "blob too large", 165 + "rkey": "demo", 166 + "data": { "$type": "example.lexicon.record", "integer": 1, "sizeBlob": { 167 + "$type": "blob", 168 + "size": 12345, 169 + "mimeType": "text/plain", 170 + "ref": { 171 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 172 + } 173 + }}}, 174 + { "name": "blob wrong type", 175 + "rkey": "demo", 176 + "data": { "$type": "example.lexicon.record", "integer": 1, "acceptBlob": { 177 + "$type": "blob", 178 + "size": 12345, 179 + "mimeType": "text/plain", 180 + "ref": { 181 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 182 + } 183 + }}}, 184 + { "name": "open union wrong data type", 185 + "rkey": "demo", 186 + "data": { "$type": "example.lexicon.record", "integer": 1, "union": 123 } 187 + }, 188 + { "name": "open union missing $type", 189 + "rkey": "demo", 190 + "data": { "$type": "example.lexicon.record", "integer": 1, "union": {"a": 1, "b": 2 } } 191 + }, 192 + { "name": "out of closed union", 193 + "rkey": "demo", 194 + "data": { "$type": "example.lexicon.record", "integer": 1, "closedUnion": { "$type": "example.unknown-lexicon.blah", "a": 1 } } 195 + }, 196 + { "name": "union inner invalid", 197 + "rkey": "demo", 198 + "data": { "$type": "example.lexicon.record", "integer": 1, "closedUnion": { "$type": "example.lexicon.record#demoObjectTwo", "a": 1 } } 199 + }, 200 + { "name": "union inner invalid", 201 + "rkey": "demo", 202 + "data": { "$type": "example.lexicon.record", "integer": 1, "union": { "$type": "example.lexicon.record#demoObject", "a": "not-a-number" } } 203 + }, 204 + { "name": "unknown wrong type (bool)", 205 + "rkey": "demo", 206 + "data": { "$type": "example.lexicon.record", "unknown": false } 207 + }, 208 + { "name": "unknown wrong type (bytes)", 209 + "rkey": "demo", 210 + "data": { "$type": "example.lexicon.record", "unknown": { "$bytes": "123" } } 211 + }, 212 + { "name": "unknown wrong type (blob)", 213 + "rkey": "demo", 214 + "data": { "$type": "example.lexicon.record", "unknown": { 215 + "$type": "blob", 216 + "mimeType": "text/plain", 217 + "size": 12345, 218 + "ref": { 219 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 220 + } 221 + }} 222 + } 223 + ]
+108
atproto/lexicon/testdata/record-data-valid.json
··· 1 + [ 2 + { 3 + "name": "minimal", 4 + "rkey": "demo", 5 + "data": { 6 + "$type": "example.lexicon.record", 7 + "integer": 1 8 + } 9 + }, 10 + { 11 + "name": "full", 12 + "rkey": "demo", 13 + "data": { 14 + "$type": "example.lexicon.record", 15 + "null": null, 16 + "boolean": true, 17 + "integer": 3, 18 + "string": "blah", 19 + "nullableString": null, 20 + "bytes": { 21 + "$bytes": "123" 22 + }, 23 + "cidlink": { 24 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 25 + }, 26 + "blob": { 27 + "$type": "blob", 28 + "mimeType": "text/plain", 29 + "size": 12345, 30 + "ref": { 31 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 32 + } 33 + }, 34 + "unknown": { 35 + "a": "alphabet", 36 + "b": 3 37 + }, 38 + "array": [1,2,3], 39 + "object": { 40 + "a": 1, 41 + "b": 2 42 + }, 43 + "ref": "example.lexicon.record#demoToken", 44 + "union": { 45 + "$type": "example.lexicon.record#demoObject", 46 + "a": 1, 47 + "b": 2 48 + }, 49 + "formats": { 50 + "did": "did:web:example.com", 51 + "handle": "handle.example.com", 52 + "atidentifier": "handle.example.com", 53 + "aturi": "at://handle.example.com/com.example.nsid/asdf123", 54 + "nsid": "com.example.nsid", 55 + "cid": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq", 56 + "datetime": "2023-10-30T22:25:23Z", 57 + "language": "en", 58 + "tid": "3kznmn7xqxl22", 59 + "recordkey": "simple" 60 + }, 61 + "constInteger": 42, 62 + "defaultInteger": 123, 63 + "enumInteger": 16, 64 + "rangeInteger": 16, 65 + "lenString": "1234567890ABC", 66 + "graphemeString": "🇩🇪🏳️‍🌈🇩🇪🏳️‍🌈🇩🇪🏳️‍🌈🇩🇪🏳️‍🌈🇩🇪🏳️‍🌈🇩🇪🏳️‍🌈", 67 + "enumString": "fish", 68 + "knownString": "blue", 69 + "sizeBytes": { 70 + "$bytes": "asdfasdfasdfasdf" 71 + }, 72 + "lenArray": [1,2,3], 73 + "sizeBlob": { 74 + "$type": "blob", 75 + "mimeType": "text/plain", 76 + "size": 8, 77 + "ref": { 78 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 79 + } 80 + }, 81 + "acceptBlob": { 82 + "$type": "blob", 83 + "mimeType": "image/png", 84 + "size": 12345, 85 + "ref": { 86 + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" 87 + } 88 + }, 89 + "closedUnion": { 90 + "$type": "example.lexicon.record#demoObject", 91 + "a": 1 92 + } 93 + } 94 + }, 95 + { 96 + "name": "unknown as a type", 97 + "rkey": "demo", 98 + "data": { 99 + "$type": "example.lexicon.record", 100 + "integer": 1, 101 + "unknown": { 102 + "$type": "example.lexicon.record#demoObject", 103 + "a": 1, 104 + "b": 2 105 + } 106 + } 107 + } 108 + ]