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