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, err := filepath.Rel(schemaPath, path) 95 if err != nil { 96 return fmt.Errorf("failed to compute relative path: %w", err) 97 } 98 schemaID := filepath.ToSlash(relPath) 99 schemaID = schemaID[:len(schemaID)-5] // Remove .json extension 100 schemaID = strings.ReplaceAll(schemaID, "/", ".") 101 schemaIDs = append(schemaIDs, schemaID) 102 } 103 return nil 104 }) 105 if err != nil { 106 return fmt.Errorf("error walking schema directory: %w", err) 107 } 108 109 if verbose { 110 fmt.Printf("\nFound %d schema files to validate:\n", len(schemaFiles)) 111 for _, file := range schemaFiles { 112 fmt.Printf(" - %s\n", file) 113 } 114 } 115 116 // Validate all discovered schemas 117 if verbose { 118 fmt.Println("\nValidating all schemas:") 119 } 120 121 for i, schemaID := range schemaIDs { 122 // Skip validation for definition-only files (*.defs) - they don't need a "main" section 123 // These files only contain shared type definitions referenced by other schemas 124 if strings.HasSuffix(schemaID, ".defs") { 125 if verbose { 126 fmt.Printf(" ⏭️ %s (defs-only file, skipping main validation)\n", schemaID) 127 } 128 continue 129 } 130 131 if _, err := catalog.Resolve(schemaID); err != nil { 132 validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err)) 133 } else if verbose { 134 fmt.Printf(" ✅ %s\n", schemaID) 135 } 136 } 137 138 if len(validationErrors) > 0 { 139 fmt.Println("❌ Schema validation errors found:") 140 for _, errMsg := range validationErrors { 141 fmt.Printf(" %s\n", errMsg) 142 } 143 return fmt.Errorf("found %d validation errors", len(validationErrors)) 144 } 145 146 fmt.Printf("\n✅ Successfully validated all %d schemas\n", len(schemaIDs)) 147 return nil 148} 149 150// loadSchemasWithDebug loads schemas one by one to identify problematic files 151func loadSchemasWithDebug(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error { 152 var schemaFiles []string 153 154 // Collect all JSON schema files 155 err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error { 156 if err != nil { 157 return err 158 } 159 160 // Skip test-data directory 161 if info.IsDir() && info.Name() == "test-data" { 162 return filepath.SkipDir 163 } 164 165 // Only process .json files 166 if !info.IsDir() && filepath.Ext(path) == ".json" { 167 schemaFiles = append(schemaFiles, path) 168 } 169 return nil 170 }) 171 if err != nil { 172 return fmt.Errorf("error walking schema directory: %w", err) 173 } 174 175 // Try to load schemas one by one 176 for _, schemaFile := range schemaFiles { 177 if verbose { 178 fmt.Printf(" Loading: %s\n", schemaFile) 179 } 180 181 // Create a temporary catalog for this file 182 tempCatalog := lexicon.NewBaseCatalog() 183 if err := tempCatalog.LoadDirectory(filepath.Dir(schemaFile)); err != nil { 184 return fmt.Errorf("failed to load schema file %s: %w", schemaFile, err) 185 } 186 } 187 188 // If all individual files loaded OK, try loading the whole directory 189 return catalog.LoadDirectory(schemaPath) 190} 191 192// extractAllSchemaIDs walks the schema directory and returns all schema IDs 193func extractAllSchemaIDs(schemaPath string) []string { 194 var schemaIDs []string 195 196 if err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error { 197 if err != nil { 198 return err 199 } 200 201 // Skip test-data directory 202 if info.IsDir() && info.Name() == "test-data" { 203 return filepath.SkipDir 204 } 205 206 // Only process .json files 207 if !info.IsDir() && filepath.Ext(path) == ".json" { 208 // Convert file path to schema ID 209 relPath, err := filepath.Rel(schemaPath, path) 210 if err != nil { 211 return err 212 } 213 schemaID := filepath.ToSlash(relPath) 214 schemaID = schemaID[:len(schemaID)-5] // Remove .json extension 215 schemaID = strings.ReplaceAll(schemaID, "/", ".") 216 217 // Only include record schemas (not procedures) 218 if strings.Contains(schemaID, ".record") || 219 strings.Contains(schemaID, ".profile") || 220 strings.Contains(schemaID, ".rules") || 221 strings.Contains(schemaID, ".wiki") || 222 strings.Contains(schemaID, ".subscription") || 223 strings.Contains(schemaID, ".membership") || 224 strings.Contains(schemaID, ".vote") || 225 strings.Contains(schemaID, ".tag") || 226 strings.Contains(schemaID, ".comment") || 227 strings.Contains(schemaID, ".share") || 228 strings.Contains(schemaID, ".tribunalVote") || 229 strings.Contains(schemaID, ".ruleProposal") || 230 strings.Contains(schemaID, ".ban") { 231 schemaIDs = append(schemaIDs, schemaID) 232 } 233 } 234 return nil 235 }); err != nil { 236 log.Printf("Warning: failed to walk schema directory: %v", err) 237 } 238 239 return schemaIDs 240} 241 242// validateTestData validates test JSON data files against their corresponding schemas 243func validateTestData(catalog *lexicon.BaseCatalog, testDataPath string, verbose, strict bool, allSchemas []string) error { 244 // Check if test data directory exists 245 if _, err := os.Stat(testDataPath); os.IsNotExist(err) { 246 return fmt.Errorf("test data path does not exist: %s", testDataPath) 247 } 248 249 var validationErrors []string 250 validFiles := 0 251 invalidFiles := 0 252 validSuccessCount := 0 253 invalidFailCount := 0 254 testedTypes := make(map[string]bool) 255 256 // Walk through test data directory 257 err := filepath.Walk(testDataPath, func(path string, info os.FileInfo, err error) error { 258 if err != nil { 259 return err 260 } 261 262 // Only process .json files 263 if !info.IsDir() && filepath.Ext(path) == ".json" { 264 filename := filepath.Base(path) 265 isInvalidTest := strings.Contains(filename, "-invalid-") 266 267 if verbose { 268 if isInvalidTest { 269 fmt.Printf("\n Testing (expect failure): %s\n", filename) 270 } else { 271 fmt.Printf("\n Testing: %s\n", filename) 272 } 273 } 274 275 // Read the test file 276 file, err := os.Open(path) 277 if err != nil { 278 validationErrors = append(validationErrors, fmt.Sprintf("Failed to open %s: %v", path, err)) 279 return nil 280 } 281 defer func() { 282 if closeErr := file.Close(); closeErr != nil { 283 validationErrors = append(validationErrors, fmt.Sprintf("Failed to close %s: %v", path, closeErr)) 284 } 285 }() 286 287 data, readErr := io.ReadAll(file) 288 if readErr != nil { 289 validationErrors = append(validationErrors, fmt.Sprintf("Failed to read %s: %v", path, readErr)) 290 return nil 291 } 292 293 // Parse JSON data using Decoder to handle numbers properly 294 var recordData map[string]interface{} 295 decoder := json.NewDecoder(bytes.NewReader(data)) 296 decoder.UseNumber() // This preserves numbers as json.Number instead of float64 297 if decodeErr := decoder.Decode(&recordData); decodeErr != nil { 298 validationErrors = append(validationErrors, fmt.Sprintf("Failed to parse JSON in %s: %v", path, decodeErr)) 299 return nil 300 } 301 302 // Convert json.Number values to appropriate types 303 recordData = convertNumbers(recordData).(map[string]interface{}) 304 305 // Extract $type field 306 recordType, ok := recordData["$type"].(string) 307 if !ok { 308 validationErrors = append(validationErrors, fmt.Sprintf("Missing or invalid $type field in %s", path)) 309 return nil 310 } 311 312 // Set validation flags 313 flags := lexicon.ValidateFlags(0) 314 if strict { 315 flags |= lexicon.StrictRecursiveValidation 316 } else { 317 flags |= lexicon.AllowLenientDatetime 318 } 319 320 // Validate the record 321 validateErr := lexicon.ValidateRecord(catalog, recordData, recordType, flags) 322 323 if isInvalidTest { 324 // This file should fail validation 325 invalidFiles++ 326 if validateErr != nil { 327 invalidFailCount++ 328 if verbose { 329 fmt.Printf(" ✅ Correctly rejected invalid %s record: %v\n", recordType, validateErr) 330 } 331 } else { 332 validationErrors = append(validationErrors, fmt.Sprintf("Invalid test file %s passed validation when it should have failed", path)) 333 if verbose { 334 fmt.Printf(" ❌ ERROR: Invalid record passed validation!\n") 335 } 336 } 337 } else { 338 // This file should pass validation 339 validFiles++ 340 if validateErr != nil { 341 validationErrors = append(validationErrors, fmt.Sprintf("Validation failed for %s (type: %s): %v", path, recordType, validateErr)) 342 if verbose { 343 fmt.Printf(" ❌ Failed: %v\n", validateErr) 344 } 345 } else { 346 validSuccessCount++ 347 testedTypes[recordType] = true 348 if verbose { 349 fmt.Printf(" ✅ Valid %s record\n", recordType) 350 } 351 } 352 } 353 } 354 return nil 355 }) 356 if err != nil { 357 return fmt.Errorf("error walking test data directory: %w", err) 358 } 359 360 if len(validationErrors) > 0 { 361 fmt.Println("\n❌ Test data validation errors found:") 362 for _, errMsg := range validationErrors { 363 fmt.Printf(" %s\n", errMsg) 364 } 365 return fmt.Errorf("found %d validation errors", len(validationErrors)) 366 } 367 368 totalFiles := validFiles + invalidFiles 369 if totalFiles == 0 { 370 fmt.Println(" ⚠️ No test data files found") 371 } else { 372 // Show validation summary 373 fmt.Printf("\n📋 Validation Summary:\n") 374 fmt.Printf(" Valid test files: %d/%d passed\n", validSuccessCount, validFiles) 375 fmt.Printf(" Invalid test files: %d/%d correctly rejected\n", invalidFailCount, invalidFiles) 376 377 if validSuccessCount == validFiles && invalidFailCount == invalidFiles { 378 fmt.Printf("\n ✅ All test files behaved as expected!\n") 379 } 380 381 // Show test coverage summary (only for valid files) 382 fmt.Printf("\n📊 Test Data Coverage Summary:\n") 383 fmt.Printf(" - Records with test data: %d types\n", len(testedTypes)) 384 fmt.Printf(" - Valid test files: %d\n", validFiles) 385 fmt.Printf(" - Invalid test files: %d (for error validation)\n", invalidFiles) 386 387 fmt.Printf("\n Tested record types:\n") 388 for recordType := range testedTypes { 389 fmt.Printf(" ✓ %s\n", recordType) 390 } 391 392 // Show untested schemas 393 untestedCount := 0 394 fmt.Printf("\n ⚠️ Record types without test data:\n") 395 for _, schema := range allSchemas { 396 if !testedTypes[schema] { 397 fmt.Printf(" - %s\n", schema) 398 untestedCount++ 399 } 400 } 401 402 if untestedCount == 0 { 403 fmt.Println(" (None - full test coverage!)") 404 } else { 405 fmt.Printf("\n Coverage: %d/%d record types have test data (%.1f%%)\n", 406 len(testedTypes), len(allSchemas), 407 float64(len(testedTypes))/float64(len(allSchemas))*100) 408 } 409 } 410 return nil 411} 412 413// validateCrossReferences validates that all schema references resolve correctly 414func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error { 415 knownRefs := []string{ 416 // Rich text facets 417 "social.coves.richtext.facet", 418 "social.coves.richtext.facet#byteSlice", 419 "social.coves.richtext.facet#mention", 420 "social.coves.richtext.facet#link", 421 "social.coves.richtext.facet#bold", 422 "social.coves.richtext.facet#italic", 423 "social.coves.richtext.facet#strikethrough", 424 "social.coves.richtext.facet#spoiler", 425 426 // Post types and views 427 "social.coves.community.post.get#postView", 428 "social.coves.community.post.get#authorView", 429 "social.coves.community.post.get#communityRef", 430 "social.coves.community.post.get#postStats", 431 "social.coves.community.post.get#viewerState", 432 "social.coves.community.post.get#notFoundPost", 433 "social.coves.community.post.get#blockedPost", 434 435 // Post record types (removed - no longer exists in new structure) 436 437 // Actor definitions 438 "social.coves.actor.defs#profileView", 439 "social.coves.actor.defs#profileViewDetailed", 440 "social.coves.actor.defs#profileStats", 441 "social.coves.actor.defs#viewerState", 442 443 // Community definitions 444 "social.coves.community.defs#communityView", 445 "social.coves.community.defs#communityViewDetailed", 446 "social.coves.community.defs#communityStats", 447 "social.coves.community.defs#viewerState", 448 "social.coves.community.rules#rule", 449 } 450 451 var errors []string 452 if verbose { 453 fmt.Println("\n🔍 Validating cross-references between schemas:") 454 } 455 456 for _, ref := range knownRefs { 457 if _, err := catalog.Resolve(ref); err != nil { 458 errors = append(errors, fmt.Sprintf("Failed to resolve reference %s: %v", ref, err)) 459 } else if verbose { 460 fmt.Printf(" ✅ %s\n", ref) 461 } 462 } 463 464 if len(errors) > 0 { 465 return fmt.Errorf("cross-reference validation failed:\n%s", strings.Join(errors, "\n")) 466 } 467 468 return nil 469} 470 471// convertNumbers recursively converts json.Number values to int64 or float64 472func convertNumbers(v interface{}) interface{} { 473 switch vv := v.(type) { 474 case map[string]interface{}: 475 result := make(map[string]interface{}) 476 for k, val := range vv { 477 result[k] = convertNumbers(val) 478 } 479 return result 480 case []interface{}: 481 result := make([]interface{}, len(vv)) 482 for i, val := range vv { 483 result[i] = convertNumbers(val) 484 } 485 return result 486 case json.Number: 487 // Try to convert to int64 first 488 if i, err := vv.Int64(); err == nil { 489 return i 490 } 491 // If that fails, convert to float64 492 if f, err := vv.Float64(); err == nil { 493 return f 494 } 495 // If both fail, return as string 496 return vv.String() 497 default: 498 return v 499 } 500}