A community based topic aggregation platform built on atproto
1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "flag"
7 "fmt"
8 "io"
9 "log"
10 "os"
11 "path/filepath"
12 "strings"
13
14 lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
15)
16
17func main() {
18 var (
19 schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory")
20 testDataPath = flag.String("test-data", "tests/lexicon-test-data", "Path to test data directory for ValidateRecord testing")
21 verbose = flag.Bool("v", false, "Verbose output")
22 strict = flag.Bool("strict", false, "Use strict validation mode")
23 schemasOnly = flag.Bool("schemas-only", false, "Only validate schemas, skip test data validation")
24 )
25 flag.Parse()
26
27 if *verbose {
28 log.SetFlags(log.LstdFlags | log.Lshortfile)
29 }
30
31 // Check if path exists
32 if _, err := os.Stat(*schemaPath); os.IsNotExist(err) {
33 log.Fatalf("Schema path does not exist: %s", *schemaPath)
34 }
35
36 // Create a new catalog
37 catalog := lexicon.NewBaseCatalog()
38
39 // Load all schemas from the directory
40 fmt.Printf("Loading schemas from: %s\n", *schemaPath)
41 if err := loadSchemasWithDebug(&catalog, *schemaPath, *verbose); err != nil {
42 log.Fatalf("Failed to load schemas: %v", err)
43 }
44
45 fmt.Printf("✅ Successfully loaded schemas from %s\n", *schemaPath)
46
47 // Validate schema structure by trying to resolve some known schemas
48 if err := validateSchemaStructure(&catalog, *schemaPath, *verbose); err != nil {
49 log.Fatalf("Schema validation failed: %v", err)
50 }
51
52 // Validate cross-references between schemas
53 if err := validateCrossReferences(&catalog, *verbose); err != nil {
54 log.Fatalf("Cross-reference validation failed: %v", err)
55 }
56
57 // Validate test data unless schemas-only flag is set
58 if !*schemasOnly {
59 fmt.Printf("\n📋 Validating test data from: %s\n", *testDataPath)
60 allSchemas := extractAllSchemaIDs(*schemaPath)
61 if err := validateTestData(&catalog, *testDataPath, *verbose, *strict, allSchemas); err != nil {
62 log.Fatalf("Test data validation failed: %v", err)
63 }
64 } else {
65 fmt.Println("\n⏩ Skipping test data validation (--schemas-only flag set)")
66 }
67
68 fmt.Println("\n✅ All validations passed successfully!")
69}
70
71// validateSchemaStructure performs additional validation checks
72func validateSchemaStructure(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error {
73 var validationErrors []string
74 var schemaFiles []string
75 var schemaIDs []string
76
77 // Collect all JSON schema files and derive their IDs
78 err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
79 if err != nil {
80 return err
81 }
82
83 // Skip test-data directory
84 if info.IsDir() && info.Name() == "test-data" {
85 return filepath.SkipDir
86 }
87
88 // Only process .json files
89 if !info.IsDir() && filepath.Ext(path) == ".json" {
90 schemaFiles = append(schemaFiles, path)
91
92 // Convert file path to schema ID
93 // e.g., internal/atproto/lexicon/social/coves/actor/profile.json -> social.coves.actor.profile
94 relPath, _ := filepath.Rel(schemaPath, path)
95 schemaID := filepath.ToSlash(relPath)
96 schemaID = schemaID[:len(schemaID)-5] // Remove .json extension
97 schemaID = strings.ReplaceAll(schemaID, "/", ".")
98 schemaIDs = append(schemaIDs, schemaID)
99 }
100 return nil
101 })
102
103 if err != nil {
104 return fmt.Errorf("error walking schema directory: %w", err)
105 }
106
107 if verbose {
108 fmt.Printf("\nFound %d schema files to validate:\n", len(schemaFiles))
109 for _, file := range schemaFiles {
110 fmt.Printf(" - %s\n", file)
111 }
112 }
113
114 // Validate all discovered schemas
115 if verbose {
116 fmt.Println("\nValidating all schemas:")
117 }
118
119 for i, schemaID := range schemaIDs {
120 if _, err := catalog.Resolve(schemaID); err != nil {
121 validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err))
122 } else if verbose {
123 fmt.Printf(" ✅ %s\n", schemaID)
124 }
125 }
126
127 if len(validationErrors) > 0 {
128 fmt.Println("❌ Schema validation errors found:")
129 for _, errMsg := range validationErrors {
130 fmt.Printf(" %s\n", errMsg)
131 }
132 return fmt.Errorf("found %d validation errors", len(validationErrors))
133 }
134
135 fmt.Printf("\n✅ Successfully validated all %d schemas\n", len(schemaIDs))
136 return nil
137}
138
139// loadSchemasWithDebug loads schemas one by one to identify problematic files
140func loadSchemasWithDebug(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error {
141 var schemaFiles []string
142
143 // Collect all JSON schema files
144 err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
145 if err != nil {
146 return err
147 }
148
149 // Skip test-data directory
150 if info.IsDir() && info.Name() == "test-data" {
151 return filepath.SkipDir
152 }
153
154 // Only process .json files
155 if !info.IsDir() && filepath.Ext(path) == ".json" {
156 schemaFiles = append(schemaFiles, path)
157 }
158 return nil
159 })
160
161 if err != nil {
162 return fmt.Errorf("error walking schema directory: %w", err)
163 }
164
165 // Try to load schemas one by one
166 for _, schemaFile := range schemaFiles {
167 if verbose {
168 fmt.Printf(" Loading: %s\n", schemaFile)
169 }
170
171 // Create a temporary catalog for this file
172 tempCatalog := lexicon.NewBaseCatalog()
173 if err := tempCatalog.LoadDirectory(filepath.Dir(schemaFile)); err != nil {
174 return fmt.Errorf("failed to load schema file %s: %w", schemaFile, err)
175 }
176 }
177
178 // If all individual files loaded OK, try loading the whole directory
179 return catalog.LoadDirectory(schemaPath)
180}
181
182// extractAllSchemaIDs walks the schema directory and returns all schema IDs
183func extractAllSchemaIDs(schemaPath string) []string {
184 var schemaIDs []string
185
186 filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
187 if err != nil {
188 return err
189 }
190
191 // Skip test-data directory
192 if info.IsDir() && info.Name() == "test-data" {
193 return filepath.SkipDir
194 }
195
196 // Only process .json files
197 if !info.IsDir() && filepath.Ext(path) == ".json" {
198 // Convert file path to schema ID
199 relPath, _ := filepath.Rel(schemaPath, path)
200 schemaID := filepath.ToSlash(relPath)
201 schemaID = schemaID[:len(schemaID)-5] // Remove .json extension
202 schemaID = strings.ReplaceAll(schemaID, "/", ".")
203
204 // Only include record schemas (not procedures)
205 if strings.Contains(schemaID, ".record") ||
206 strings.Contains(schemaID, ".profile") ||
207 strings.Contains(schemaID, ".rules") ||
208 strings.Contains(schemaID, ".wiki") ||
209 strings.Contains(schemaID, ".subscription") ||
210 strings.Contains(schemaID, ".membership") ||
211 strings.Contains(schemaID, ".vote") ||
212 strings.Contains(schemaID, ".tag") ||
213 strings.Contains(schemaID, ".comment") ||
214 strings.Contains(schemaID, ".share") ||
215 strings.Contains(schemaID, ".tribunalVote") ||
216 strings.Contains(schemaID, ".ruleProposal") ||
217 strings.Contains(schemaID, ".ban") {
218 schemaIDs = append(schemaIDs, schemaID)
219 }
220 }
221 return nil
222 })
223
224 return schemaIDs
225}
226
227// validateTestData validates test JSON data files against their corresponding schemas
228func validateTestData(catalog *lexicon.BaseCatalog, testDataPath string, verbose bool, strict bool, allSchemas []string) error {
229 // Check if test data directory exists
230 if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
231 return fmt.Errorf("test data path does not exist: %s", testDataPath)
232 }
233
234 var validationErrors []string
235 validFiles := 0
236 invalidFiles := 0
237 validSuccessCount := 0
238 invalidFailCount := 0
239 testedTypes := make(map[string]bool)
240
241 // Walk through test data directory
242 err := filepath.Walk(testDataPath, func(path string, info os.FileInfo, err error) error {
243 if err != nil {
244 return err
245 }
246
247 // Only process .json files
248 if !info.IsDir() && filepath.Ext(path) == ".json" {
249 filename := filepath.Base(path)
250 isInvalidTest := strings.Contains(filename, "-invalid-")
251
252 if verbose {
253 if isInvalidTest {
254 fmt.Printf("\n Testing (expect failure): %s\n", filename)
255 } else {
256 fmt.Printf("\n Testing: %s\n", filename)
257 }
258 }
259
260 // Read the test file
261 file, err := os.Open(path)
262 if err != nil {
263 validationErrors = append(validationErrors, fmt.Sprintf("Failed to open %s: %v", path, err))
264 return nil
265 }
266 defer file.Close()
267
268 data, err := io.ReadAll(file)
269 if err != nil {
270 validationErrors = append(validationErrors, fmt.Sprintf("Failed to read %s: %v", path, err))
271 return nil
272 }
273
274 // Parse JSON data using Decoder to handle numbers properly
275 var recordData map[string]interface{}
276 decoder := json.NewDecoder(bytes.NewReader(data))
277 decoder.UseNumber() // This preserves numbers as json.Number instead of float64
278 if err := decoder.Decode(&recordData); err != nil {
279 validationErrors = append(validationErrors, fmt.Sprintf("Failed to parse JSON in %s: %v", path, err))
280 return nil
281 }
282
283 // Convert json.Number values to appropriate types
284 recordData = convertNumbers(recordData).(map[string]interface{})
285
286 // Extract $type field
287 recordType, ok := recordData["$type"].(string)
288 if !ok {
289 validationErrors = append(validationErrors, fmt.Sprintf("Missing or invalid $type field in %s", path))
290 return nil
291 }
292
293 // Set validation flags
294 flags := lexicon.ValidateFlags(0)
295 if strict {
296 flags |= lexicon.StrictRecursiveValidation
297 } else {
298 flags |= lexicon.AllowLenientDatetime
299 }
300
301 // Validate the record
302 err = lexicon.ValidateRecord(catalog, recordData, recordType, flags)
303
304 if isInvalidTest {
305 // This file should fail validation
306 invalidFiles++
307 if err != nil {
308 invalidFailCount++
309 if verbose {
310 fmt.Printf(" ✅ Correctly rejected invalid %s record: %v\n", recordType, err)
311 }
312 } else {
313 validationErrors = append(validationErrors, fmt.Sprintf("Invalid test file %s passed validation when it should have failed", path))
314 if verbose {
315 fmt.Printf(" ❌ ERROR: Invalid record passed validation!\n")
316 }
317 }
318 } else {
319 // This file should pass validation
320 validFiles++
321 if err != nil {
322 validationErrors = append(validationErrors, fmt.Sprintf("Validation failed for %s (type: %s): %v", path, recordType, err))
323 if verbose {
324 fmt.Printf(" ❌ Failed: %v\n", err)
325 }
326 } else {
327 validSuccessCount++
328 testedTypes[recordType] = true
329 if verbose {
330 fmt.Printf(" ✅ Valid %s record\n", recordType)
331 }
332 }
333 }
334 }
335 return nil
336 })
337
338 if err != nil {
339 return fmt.Errorf("error walking test data directory: %w", err)
340 }
341
342 if len(validationErrors) > 0 {
343 fmt.Println("\n❌ Test data validation errors found:")
344 for _, errMsg := range validationErrors {
345 fmt.Printf(" %s\n", errMsg)
346 }
347 return fmt.Errorf("found %d validation errors", len(validationErrors))
348 }
349
350 totalFiles := validFiles + invalidFiles
351 if totalFiles == 0 {
352 fmt.Println(" ⚠️ No test data files found")
353 } else {
354 // Show validation summary
355 fmt.Printf("\n📋 Validation Summary:\n")
356 fmt.Printf(" Valid test files: %d/%d passed\n", validSuccessCount, validFiles)
357 fmt.Printf(" Invalid test files: %d/%d correctly rejected\n", invalidFailCount, invalidFiles)
358
359 if validSuccessCount == validFiles && invalidFailCount == invalidFiles {
360 fmt.Printf("\n ✅ All test files behaved as expected!\n")
361 }
362
363 // Show test coverage summary (only for valid files)
364 fmt.Printf("\n📊 Test Data Coverage Summary:\n")
365 fmt.Printf(" - Records with test data: %d types\n", len(testedTypes))
366 fmt.Printf(" - Valid test files: %d\n", validFiles)
367 fmt.Printf(" - Invalid test files: %d (for error validation)\n", invalidFiles)
368
369 fmt.Printf("\n Tested record types:\n")
370 for recordType := range testedTypes {
371 fmt.Printf(" ✓ %s\n", recordType)
372 }
373
374 // Show untested schemas
375 untestedCount := 0
376 fmt.Printf("\n ⚠️ Record types without test data:\n")
377 for _, schema := range allSchemas {
378 if !testedTypes[schema] {
379 fmt.Printf(" - %s\n", schema)
380 untestedCount++
381 }
382 }
383
384 if untestedCount == 0 {
385 fmt.Println(" (None - full test coverage!)")
386 } else {
387 fmt.Printf("\n Coverage: %d/%d record types have test data (%.1f%%)\n",
388 len(testedTypes), len(allSchemas),
389 float64(len(testedTypes))/float64(len(allSchemas))*100)
390 }
391 }
392 return nil
393}
394
395// validateCrossReferences validates that all schema references resolve correctly
396func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error {
397 knownRefs := []string{
398 // Rich text facets
399 "social.coves.richtext.facet",
400 "social.coves.richtext.facet#byteSlice",
401 "social.coves.richtext.facet#mention",
402 "social.coves.richtext.facet#link",
403 "social.coves.richtext.facet#bold",
404 "social.coves.richtext.facet#italic",
405 "social.coves.richtext.facet#strikethrough",
406 "social.coves.richtext.facet#spoiler",
407
408 // Post types and views
409 "social.coves.post.get#postView",
410 "social.coves.post.get#authorView",
411 "social.coves.post.get#communityRef",
412 "social.coves.post.get#imageView",
413 "social.coves.post.get#videoView",
414 "social.coves.post.get#externalView",
415 "social.coves.post.get#postStats",
416 "social.coves.post.get#viewerState",
417
418 // Post record types
419 "social.coves.post.record#originalAuthor",
420
421 // Actor definitions
422 "social.coves.actor.profile#geoLocation",
423
424 // Community definitions
425 "social.coves.community.rules#rule",
426 }
427
428 var errors []string
429 if verbose {
430 fmt.Println("\n🔍 Validating cross-references between schemas:")
431 }
432
433 for _, ref := range knownRefs {
434 if _, err := catalog.Resolve(ref); err != nil {
435 errors = append(errors, fmt.Sprintf("Failed to resolve reference %s: %v", ref, err))
436 } else if verbose {
437 fmt.Printf(" ✅ %s\n", ref)
438 }
439 }
440
441 if len(errors) > 0 {
442 return fmt.Errorf("cross-reference validation failed:\n%s", strings.Join(errors, "\n"))
443 }
444
445 return nil
446}
447
448// convertNumbers recursively converts json.Number values to int64 or float64
449func convertNumbers(v interface{}) interface{} {
450 switch vv := v.(type) {
451 case map[string]interface{}:
452 result := make(map[string]interface{})
453 for k, val := range vv {
454 result[k] = convertNumbers(val)
455 }
456 return result
457 case []interface{}:
458 result := make([]interface{}, len(vv))
459 for i, val := range vv {
460 result[i] = convertNumbers(val)
461 }
462 return result
463 case json.Number:
464 // Try to convert to int64 first
465 if i, err := vv.Int64(); err == nil {
466 return i
467 }
468 // If that fails, convert to float64
469 if f, err := vv.Float64(); err == nil {
470 return f
471 }
472 // If both fail, return as string
473 return vv.String()
474 default:
475 return v
476 }
477}