Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added multiple validation files parsing support #455

Merged
merged 5 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2
github.com/authzed/authzed-go v1.2.2-0.20250107172318-7fd4159ab2b7
github.com/authzed/grpcutil v0.0.0-20240123194739-2ea1e3d2d98b
github.com/authzed/spicedb v1.39.1-0.20250108165209-c18b1656bdd0
github.com/authzed/spicedb v1.39.1-0.20250114225336-a80f596434e3
github.com/brianvoe/gofakeit/v6 v6.28.0
github.com/ccoveille/go-safecast v1.5.0
github.com/cenkalti/backoff/v4 v4.3.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -678,8 +678,8 @@ github.com/authzed/consistent v0.1.0 h1:tlh1wvKoRbjRhMm2P+X5WQQyR54SRoS4MyjLOg17
github.com/authzed/consistent v0.1.0/go.mod h1:plwHlrN/EJUCwQ+Bca0MhM1KnisPs7HEkZI5giCXrcc=
github.com/authzed/grpcutil v0.0.0-20240123194739-2ea1e3d2d98b h1:wbh8IK+aMLTCey9sZasO7b6BWLAJnHHvb79fvWCXwxw=
github.com/authzed/grpcutil v0.0.0-20240123194739-2ea1e3d2d98b/go.mod h1:s3qC7V7XIbiNWERv7Lfljy/Lx25/V1Qlexb0WJuA8uQ=
github.com/authzed/spicedb v1.39.1-0.20250108165209-c18b1656bdd0 h1:ewOiKCJmuLU7/+HyUrJD/oIMgd7NG0NrpHfl4nzeW/s=
github.com/authzed/spicedb v1.39.1-0.20250108165209-c18b1656bdd0/go.mod h1:/UVC4ZJkMUZFN4MVjjOLAU7m/fqitkBP57ZPffyicOs=
github.com/authzed/spicedb v1.39.1-0.20250114225336-a80f596434e3 h1:2bnplEAOp5f8Z4OuH+fm09TbZU2GxxSZnEhPpAzkwW0=
github.com/authzed/spicedb v1.39.1-0.20250114225336-a80f596434e3/go.mod h1:/UVC4ZJkMUZFN4MVjjOLAU7m/fqitkBP57ZPffyicOs=
github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw=
github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/config v1.28.7 h1:GduUnoTXlhkgnxTD93g1nv4tVPILbdNQOzav+Wpg7AE=
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func importCmdFunc(cmd *cobra.Command, args []string) error {
return err
}

decoder, err := decode.DecoderForURL(u)
decoder, err := decode.DecoderForURL(u, args)
if err != nil {
return err
}
Expand Down
168 changes: 99 additions & 69 deletions internal/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ var validateCmd = &cobra.Command{

From a devtools instance:
zed validate https://localhost:8443/download`,
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

ValidArgsFunction: commands.FileExtensionCompletions("zed", "yaml", "zaml"),
PreRunE: validatePreRunE,
RunE: validateCmdFunc,
Expand All @@ -90,92 +90,122 @@ func validatePreRunE(cmd *cobra.Command, _ []string) error {
}

func validateCmdFunc(cmd *cobra.Command, args []string) error {
// Parse the URL of the validation document to import.
u, err := url.Parse(args[0])
if err != nil {
return err
}

decoder, err := decode.DecoderForURL(u)
if err != nil {
return err
}
// Initialize variables for multiple files
var (
onlySchemaFiles = true
validateContentsFiles = make([][]byte, 0, len(args))
parsedFiles = make([]validationfile.ValidationFile, 0, len(args))
totalAssertions int
totalRelationsValidated int
totalFiles = len(args)
successfullyValidatedFiles = 0
)

// Decode the validation document.
var parsed validationfile.ValidationFile
validateContents, isOnlySchema, err := decoder(&parsed)
if err != nil {
var errWithSource spiceerrors.WithSourceError
if errors.As(err, &errWithSource) {
ouputErrorWithSource(validateContents, errWithSource)
for _, arg := range args {
u, err := url.Parse(arg)
if err != nil {
return err
}

return err
}
decoder, err := decode.DecoderForURL(u, args)
if err != nil {
return err
}

// Create the development context.
ctx := cmd.Context()
tuples := make([]*core.RelationTuple, 0, len(parsed.Relationships.Relationships))
for _, rel := range parsed.Relationships.Relationships {
tuples = append(tuples, rel.ToCoreTuple())
}
devCtx, devErrs, err := development.NewDevContext(ctx, &devinterface.RequestContext{
Schema: parsed.Schema.Schema,
Relationships: tuples,
})
if err != nil {
return err
}
if devErrs != nil {
schemaOffset := 1 /* for the 'schema:' */
if isOnlySchema {
schemaOffset = 0
var parsed validationfile.ValidationFile
validateContents, isOnlySchema, err := decoder(&parsed)
if err != nil {
var errWithSource spiceerrors.WithSourceError
if errors.As(err, &errWithSource) {
ouputErrorWithSource(validateContents, errWithSource)
}
return err
}

outputDeveloperErrorsWithLineOffset(validateContents, devErrs.InputErrors, schemaOffset)
}
onlySchemaFiles = isOnlySchema && onlySchemaFiles

// Run assertions.
adevErrs, aerr := development.RunAllAssertions(devCtx, &parsed.Assertions)
if aerr != nil {
return aerr
}
if adevErrs != nil {
outputDeveloperErrors(validateContents, adevErrs)
// Store the contents and parsed file
validateContentsFiles = append(validateContentsFiles, validateContents)
parsedFiles = append(parsedFiles, parsed)
}

// Run expected relations.
_, erDevErrs, rerr := development.RunValidation(devCtx, &parsed.ExpectedRelations)
if rerr != nil {
return rerr
}
if erDevErrs != nil {
outputDeveloperErrors(validateContents, erDevErrs)
}
// Create the development context for all parsed files
ctx := cmd.Context()
tuples := make([]*core.RelationTuple, 0)

// Print out any warnings.
warnings, err := development.GetWarnings(ctx, devCtx)
if err != nil {
return err
}
for _, parsed := range parsedFiles {
for _, rel := range parsed.Relationships.Relationships {
tuples = append(tuples, rel.ToCoreTuple())
}
devCtx, devErrs, err := development.NewDevContext(ctx, &devinterface.RequestContext{
Schema: parsed.Schema.Schema,
Relationships: tuples,
})
if err != nil {
return err
}
if devErrs != nil {
schemaOffset := 1 /* for the 'schema:' */
if onlySchemaFiles {
schemaOffset = 0
}

if len(warnings) > 0 {
for _, warning := range warnings {
console.Printf("%s%s\n", warningPrefix(), warning.Message)
outputForLine(validateContents, uint64(warning.Line), warning.SourceCode, uint64(warning.Column)) // warning.LineNumber is 1-indexed
console.Printf("\n")
// Output errors for all files
for _, validateContents := range validateContentsFiles {
outputDeveloperErrorsWithLineOffset(validateContents, devErrs.InputErrors, schemaOffset)
}
}
// Run assertions for all parsed files
adevErrs, aerr := development.RunAllAssertions(devCtx, &parsed.Assertions)
if aerr != nil {
return aerr
}
if adevErrs != nil {
for _, validateContents := range validateContentsFiles {
outputDeveloperErrors(validateContents, adevErrs)
}
}
successfullyValidatedFiles++

console.Print(complete())
} else {
console.Print(success())
// Run expected relations for all parsed files
_, erDevErrs, rerr := development.RunValidation(devCtx, &parsed.ExpectedRelations)
if rerr != nil {
return rerr
}
if erDevErrs != nil {
for _, validateContents := range validateContentsFiles {
outputDeveloperErrors(validateContents, erDevErrs)
}
}
// Print out any warnings for all files
warnings, err := development.GetWarnings(ctx, devCtx)
if err != nil {
return err
}
if len(warnings) > 0 {
for _, warning := range warnings {
console.Printf("%s%s\n", warningPrefix(), warning.Message)
for _, validateContents := range validateContentsFiles {
outputForLine(validateContents, uint64(warning.Line), warning.SourceCode, uint64(warning.Column)) // warning.LineNumber is 1-indexed
}
console.Printf("\n")
}

console.Print(complete())
} else {
console.Print(success())
}
totalAssertions += len(parsed.Assertions.AssertTrue) + len(parsed.Assertions.AssertFalse)
totalRelationsValidated += len(parsed.ExpectedRelations.ValidationMap)
}

console.Printf(" - %d relationships loaded, %d assertions run, %d expected relations validated\n",
len(tuples),
len(parsed.Assertions.AssertTrue)+len(parsed.Assertions.AssertFalse),
len(parsed.ExpectedRelations.ValidationMap),
totalAssertions,
totalRelationsValidated,
)

console.Printf("total files: %d, successfully validated files: %d\n", totalFiles, successfullyValidatedFiles)
return nil
}

Expand Down
80 changes: 60 additions & 20 deletions internal/decode/decoder.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package decode

import (
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -33,21 +34,21 @@ type Func func(out interface{}) ([]byte, bool, error)

// DecoderForURL returns the appropriate decoder for a given URL.
// Some URLs have special handling to dereference to the actual file.
func DecoderForURL(u *url.URL) (d Func, err error) {
func DecoderForURL(u *url.URL, args []string) (d Func, err error) {
switch s := u.Scheme; s {
case "file":
d = fileDecoder(u)
d = fileDecoder(u, args)
case "http", "https":
d = httpDecoder(u)
case "":
d = fileDecoder(u)
d = fileDecoder(u, args)
default:
err = fmt.Errorf("%s scheme not supported", s)
}
return
}

func fileDecoder(u *url.URL) Func {
func fileDecoder(u *url.URL, args []string) Func {
return func(out interface{}) ([]byte, bool, error) {
file, err := os.Open(u.Path)
if err != nil {
Expand All @@ -57,7 +58,7 @@ func fileDecoder(u *url.URL) Func {
if err != nil {
return nil, false, err
}
isOnlySchema, err := unmarshalAsYAMLOrSchema(data, out)
isOnlySchema, err := unmarshalAsYAMLOrSchemaWithFile(data, out, args)
return data, isOnlySchema, err
}
}
Expand Down Expand Up @@ -105,32 +106,71 @@ func directHTTPDecoder(u *url.URL) Func {
}
}

// Uses the files passed in the args and looks for the specified schemaFile to parse the YAML.
func unmarshalAsYAMLOrSchemaWithFile(data []byte, out interface{}, args []string) (bool, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add validation for the case where a user specifies both the schema and the schemafile?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When both schema and schemaFIle are provided, it directly goes to yaml.Unmarshal() which prefers schema over schemaFile.

if strings.Contains(string(data), "schemaFile:") && !strings.Contains(string(data), "schema:") {
if err := yaml.Unmarshal(data, out); err != nil {
return false, err
}
schema := out.(*validationfile.ValidationFile)

for _, arg := range args {
// Check if the file specified in schemaFile is passed as an arguement.
if strings.Contains(arg, schema.SchemaFile) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this check do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checks if the file is present in the arguments provided to validate

file, err := os.Open(arg)
if err != nil {
return false, err
}
data, err = io.ReadAll(file)
if err != nil {
return false, err
}
}
}
}
return unmarshalAsYAMLOrSchema(data, out)
Comment on lines +124 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I suppose that one implication of doing it this way is that one yaml file could reference another. Is that desirable? Genuine question; I don't know the answer. I think I'm fine with it, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add maybe some identifiers to prevent this if not desirable.

}

func unmarshalAsYAMLOrSchema(data []byte, out interface{}) (bool, error) {
// Check for indications of a schema-only file.
if !strings.Contains(string(data), "schema:") {
compiled, serr := compiler.Compile(compiler.InputSchema{
Source: input.Source("schema"),
SchemaString: string(data),
}, compiler.AllowUnprefixedObjectType())
if serr != nil {
return false, serr
if !strings.Contains(string(data), "schema:") && !strings.Contains(string(data), "relationships:") {
if err := compileSchemaFromData(data, out); err != nil {
return false, err
}
return true, nil
}

// If that succeeds, return the compiled schema.
vfile := *out.(*validationfile.ValidationFile)
vfile.Schema = blocks.ParsedSchema{
CompiledSchema: compiled,
Schema: string(data),
SourcePosition: spiceerrors.SourcePosition{LineNumber: 1, ColumnPosition: 1},
if !strings.Contains(string(data), "schema:") && !strings.Contains(string(data), "schemaFile:") {
// If there is no schema and no schemaFile and it doesn't compile then it must be yaml with missing fields
if err := compileSchemaFromData(data, out); err != nil {
return false, errors.New("either schema or schemaFile must be present")
}
*out.(*validationfile.ValidationFile) = vfile
return true, nil
}

// Try to unparse as YAML for the validation file format.
if err := yaml.Unmarshal(data, out); err != nil {
return false, err
}

return false, nil
}

func compileSchemaFromData(data []byte, out interface{}) error {
compiled, serr := compiler.Compile(compiler.InputSchema{
Source: input.Source("schema"),
SchemaString: string(data),
}, compiler.AllowUnprefixedObjectType())
if serr != nil {
return serr
}

// If that succeeds, return the compiled schema.
vfile := *out.(*validationfile.ValidationFile)
vfile.Schema = blocks.ParsedSchema{
CompiledSchema: compiled,
Schema: string(data),
SourcePosition: spiceerrors.SourcePosition{LineNumber: 1, ColumnPosition: 1},
}
*out.(*validationfile.ValidationFile) = vfile
return nil
}
Loading