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}