Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 162 additions & 1 deletion schema/test/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
)

const schemaDir = ".."
const extensionFile = "extension.json"

type SchemaFile struct {
Path string
Expand Down Expand Up @@ -106,7 +107,9 @@ var _ = Describe("Metaschema validation", func() {
{Dir: filepath.Join(schemaDir, "modules"), Schema: filepath.Join(metaschemaDir, "class.schema.json")},
{Dir: filepath.Join(schemaDir, "objects"), Schema: filepath.Join(metaschemaDir, "object.schema.json")},
{Dir: filepath.Join(schemaDir, "profiles"), Schema: filepath.Join(metaschemaDir, "profile.schema.json")},
{Dir: filepath.Join(schemaDir, "extensions"), Schema: filepath.Join(metaschemaDir, "extension.schema.json")},
// Extensions are deliberately absent here. An extension directory holds files of
// several different entity types, each governed by its own metaschema, so they are
// validated by the "Extension metaschema validation" spec below.
}

for _, target := range directories {
Expand Down Expand Up @@ -136,6 +139,64 @@ var _ = Describe("Metaschema validation", func() {
})
})

var _ = Describe("Extension metaschema validation", func() {
It("should validate extension files against the metaschema for their entity type", func() {
var errors []string

metaschemaDir := filepath.Join(schemaDir, "metaschema")
extensionsDir := filepath.Join(schemaDir, "extensions")

dirInfo, err := os.Stat(extensionsDir)
if err != nil || !dirInfo.IsDir() {
AddWarning("%s directory does not exist\n", extensionsDir)
return
}

roots, err := FindExtensionRoots(extensionsDir)
Expect(err).NotTo(HaveOccurred())

unclaimed, err := FindUnclaimedDirs(extensionsDir, roots)
Expect(err).NotTo(HaveOccurred())

for _, dir := range unclaimed {
AddWarning(
"Skipping %s: it holds JSON files but no %s, so it is not an extension and nothing in it was validated",
dir, extensionFile,
)
}

if len(roots) == 0 {
AddWarning("no extensions found in %s (an extension is a directory containing %s)", extensionsDir, extensionFile)
return
}

for _, root := range roots {
for _, file := range cache.Files {
if filepath.Ext(file.Path) != ".json" || !strings.HasPrefix(file.Path, root+string(os.PathSeparator)) {
continue
}

relPath, err := filepath.Rel(root, file.Path)
Expect(err).NotTo(HaveOccurred())

metaschema, known := ExtensionMetaschema(metaschemaDir, relPath)
if !known {
AddWarning("Skipping %s: no metaschema is defined for this location within an extension", file.Path)
continue
}

if err := ValidateDataAgainstSchema(file.Data, metaschema, file.Path); err != nil {
errors = append(errors, fmt.Sprintf("File %s failed validation: %s", file.Path, err))
}
}
}

if len(errors) > 0 {
Fail("Errors found:\n" + strings.Join(errors, "\n"))
}
})
})

var _ = Describe("JSON content checks", func() {
targets := []struct {
Dir string
Expand Down Expand Up @@ -457,3 +518,103 @@ func ValidateDataAgainstSchema(data []byte, schemaPath, filePath string) error {
}
return nil
}

// FindExtensionRoots returns every directory under dir that directly contains an
// extension.json file. This mirrors the schema server's extension discovery, which
// registers a directory as an extension as soon as it finds extension.json and does
// not descend any further (see server/lib/schema/json_reader.ex, find_extensions/2).
func FindExtensionRoots(dir string) ([]string, error) {
var roots []string
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if _, err := os.Stat(filepath.Join(path, extensionFile)); err == nil {
roots = append(roots, path)
return filepath.SkipDir
}
return nil
})
return roots, err
}

// FindUnclaimedDirs returns every directory under dir that holds JSON files directly but
// belongs to no extension root. That is the shape a misspelled extension.json produces: the
// schema server does not register the directory as an extension, so nothing in it is read,
// and this spec does not validate it either. Reporting it turns a silent omission into a
// warning without changing what counts as an extension. Directories that merely group
// extensions hold no JSON of their own and are not reported.
func FindUnclaimedDirs(dir string, roots []string) ([]string, error) {
var unclaimed []string
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
for _, root := range roots {
if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) {
return filepath.SkipDir
}
}
hasJSON, err := DirHasJSONFile(path)
if err != nil {
return err
}
if hasJSON {
unclaimed = append(unclaimed, path)
}
return nil
})
return unclaimed, err
}

// DirHasJSONFile reports whether dir directly contains a .json file, ignoring subdirectories.
func DirHasJSONFile(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
return true, nil
}
}
return false, nil
}

// ExtensionMetaschema maps a path relative to an extension root to the metaschema that
// governs it. An extension mirrors the layout of the core schema directory, so each of
// its files must be validated against the same metaschema as its core counterpart
// rather than against extension.schema.json (see CONTRIBUTING.md). The second return
// value reports whether a metaschema is defined for the given location.
func ExtensionMetaschema(metaschemaDir, relPath string) (string, bool) {
relPath = filepath.ToSlash(relPath)

switch relPath {
case extensionFile:
return filepath.Join(metaschemaDir, "extension.schema.json"), true
case "dictionary.json":
return filepath.Join(metaschemaDir, "dictionary.schema.json"), true
}

top, _, nested := strings.Cut(relPath, "/")
if !nested {
return "", false
}

switch top {
case "skills", "domains", "modules":
return filepath.Join(metaschemaDir, "class.schema.json"), true
case "objects":
return filepath.Join(metaschemaDir, "object.schema.json"), true
case "profiles":
return filepath.Join(metaschemaDir, "profile.schema.json"), true
}

return "", false
}
Loading