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}