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}