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}