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