diff --git a/cmd/generate/generate.go b/cmd/generate/generate.go new file mode 100644 index 00000000..5e683713 --- /dev/null +++ b/cmd/generate/generate.go @@ -0,0 +1,13 @@ +package generate + +import "github.com/spf13/cobra" + +var GenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate code", + Long: "Generate code to facilitate testing, modeling and working with OpenFGA.", +} + +func init() { + GenerateCmd.AddCommand(pklCmd) +} diff --git a/cmd/generate/pkl.go b/cmd/generate/pkl.go new file mode 100644 index 00000000..6e339fab --- /dev/null +++ b/cmd/generate/pkl.go @@ -0,0 +1,85 @@ +package generate + +import ( + "encoding/json" + "fmt" + "github.com/openfga/cli/internal/authorizationmodel" + "github.com/openfga/cli/internal/cmdutils" + "github.com/openfga/cli/internal/generate" + "github.com/openfga/cli/internal/output" + openfga "github.com/openfga/go-sdk" + "github.com/spf13/cobra" +) + +var pklCmd = &cobra.Command{ + Use: "pkl", + Short: "Generate pkl test utilities", + Long: "Generate pkl test utilities based on the given model", + Example: `fga generate pkl --file=model.json +fga generate --file=fga.mod +fga generate '{"type_definitions":[{"type":"user"},{"type":"document","relations":{"can_view":{"this":{}}},"metadata":{"relations":{"can_view":{"directly_related_user_types":[{"type":"user"}]}}}}],"schema_version":"1.1"}' --format=json +fga generate --file=fga.mod --out=testing +fga generate --file=fga.mod --out=testing --config='{"user": {"base_type_name": "Awesome"}}'`, //nolint:lll + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + clientConfig := cmdutils.GetClientConfig(cmd) + + _, err := clientConfig.GetFgaClient() + if err != nil { + return fmt.Errorf("failed to initialize FGA Client due to %w", err) + } + + var inputModel string + if err := authorizationmodel.ReadFromInputFileOrArg( + cmd, + args, + "file", + false, + &inputModel, + openfga.PtrString(""), + &writeInputFormat); err != nil { + return err //nolint:wrapcheck + } + + authModel := authorizationmodel.AuthzModel{} + + err = authModel.ReadModelFromString(inputModel, writeInputFormat) + if err != nil { + return err //nolint:wrapcheck + } + + out, err := cmd.Flags().GetString("out") + if err != nil { + return fmt.Errorf("failed to parse output directory due to %w", err) + } + config, err := cmd.Flags().GetString("config") + if err != nil { + return fmt.Errorf("failed to parse config due to %w", err) + } + cn := make(map[string]generate.PklConventionConfig) + err = json.Unmarshal([]byte(config), &cn) + if err != nil { + return fmt.Errorf("failed to parse config content due to %w", err) + } + + g := &generate.PklGenerator{ + Model: authModel.TypeDefinitions, + Convention: &generate.PklConvention{Config: cn}, + } + files, err := g.Generate() + err = files.SaveAll(out) + if err != nil { + return fmt.Errorf("failed to save generated files due to %w", err) + } + return output.Display(fmt.Sprintf("generated files in directory %v successfully", out)) + }, +} + +var writeInputFormat = authorizationmodel.ModelFormatDefault + +func init() { + pklCmd.Flags().String("out", "testing", "Output of testing directory") + pklCmd.Flags().String("config", "{}", "Generator configurations") + pklCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON or DSL format") + pklCmd.Flags().Var(&writeInputFormat, "format", `Authorization model input format. Can be "fga", "json", or "modular"`) //nolint:lll +} diff --git a/cmd/root.go b/cmd/root.go index 74eacc24..7576c002 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/openfga/cli/cmd/generate" "github.com/openfga/cli/cmd/model" "github.com/openfga/cli/cmd/query" "github.com/openfga/cli/cmd/store" @@ -81,6 +82,7 @@ func init() { rootCmd.AddCommand(model.ModelCmd) rootCmd.AddCommand(tuple.TupleCmd) rootCmd.AddCommand(query.QueryCmd) + rootCmd.AddCommand(generate.GenerateCmd) } // initConfig reads in config file and ENV variables if set. diff --git a/go.mod b/go.mod index 7707d797..073c6cc7 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.3 require ( github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/hashicorp/go-multierror v1.1.1 + github.com/iancoleman/strcase v0.3.0 github.com/mattn/go-isatty v0.0.20 github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 @@ -19,6 +20,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 go.uber.org/mock v0.4.0 + golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 google.golang.org/protobuf v1.34.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -70,7 +72,6 @@ require ( go.opentelemetry.io/proto/otlp v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect diff --git a/go.sum b/go.sum index 22c33419..552127a2 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,8 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/generate/generate.go b/internal/generate/generate.go new file mode 100644 index 00000000..68c90ed4 --- /dev/null +++ b/internal/generate/generate.go @@ -0,0 +1,94 @@ +package generate + +import ( + "errors" + "fmt" + "os" + "path" +) + +type GeneratedFile struct { + Path string + Content string + OverrideIfExists bool +} + +type GeneratedFiles struct { + Files []GeneratedFile +} + +func (f *GeneratedFiles) SaveAll(out string) error { + for _, file := range f.Files { + if err := file.Save(out); err != nil { + return err + } + } + return nil +} + +func (f *GeneratedFile) Save(out string) error { + if f.OverrideIfExists { + if err := f.writeFile(out, f.Path, f.Content); err != nil { + return err + } + } else { + if err := f.writeFileIfNotExists(out, f.Path, f.Content); err != nil { + return err + } + } + return nil +} + +func (f *GeneratedFile) writeFileIfNotExists(out, filePath, content string) error { + pwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get Working Directory %v", err) + } + + file := path.Join(pwd, out, filePath) + if _, err = os.Stat(file); errors.Is(err, os.ErrNotExist) { + dir := path.Dir(file) + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + + f, err := os.Create(file) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(content) + if err != nil { + return err + } + } + return nil +} + +func (f *GeneratedFile) writeFile(out, filePath, content string) error { + pwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get Working Directory %v", err) + } + + fp := path.Join(pwd, out, filePath) + dir := path.Dir(fp) + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + + file, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(0644)) + if err != nil { + return fmt.Errorf("failed to open or create file %v", err) + } + defer file.Close() + + _, err = file.WriteString(content) + if err != nil { + return err + } + return nil +} diff --git a/internal/generate/pkl.go b/internal/generate/pkl.go new file mode 100644 index 00000000..ccda055e --- /dev/null +++ b/internal/generate/pkl.go @@ -0,0 +1,645 @@ +package generate + +import ( + "fmt" + "github.com/iancoleman/strcase" + "github.com/openfga/cli/internal/build" + openfga "github.com/openfga/go-sdk" + "golang.org/x/exp/maps" + "slices" + "strings" +) + +var versionStr = fmt.Sprintf("v`%s` (commit: `%s`, date: `%s`)", build.Version, build.Commit, build.Date) + +type PklConvention struct { + Config map[string]PklConventionConfig +} + +type PklConventionConfig struct { + BaseTypeName string `json:"base_type_name"` + BaseAssertionsName string `json:"base_assertions_name"` + TypeName string `json:"type_name"` + AssertionsName string `json:"assertions_name"` + RelationFnPrefix string `json:"relation_fn_prefix"` + + AssertHasAllRelations string `json:"assert_has_all_relations"` + AssertDoNotHaveAnyRelations string `json:"assert_do_not_have_any_relations"` + AssertHasAllRelationsForNouns string `json:"assert_has_all_relations_for_nouns"` + AssertDoNotHaveAnyRelationsForNouns string `json:"assert_do_not_have_any_relations_for_nouns"` + AssertHasAllRelationsForVerbs string `json:"assert_has_all_relations_for_verbs"` + AssertDoNotHaveAnyRelationsForVerbs string `json:"assert_do_not_have_any_relations_for_verbs"` + + AssertHasRelationPrefix string `json:"assert_has_relation_prefix"` + AssertDoNotHaveRelationPrefix string `json:"assert_do_not_have_relation_prefix"` + + AssertPositiveForNounPrefix string `json:"assert_positive_for_noun_prefix"` + AssertNegativeForNounPrefix string `json:"assert_negative_for_noun_prefix"` + + AssertPositiveForVerbPrefix string `json:"assert_positive_for_verb_prefix"` + AssertNegativeForVerbPrefix string `json:"assert_negative_for_verb_prefix"` + + NounMatches []string `json:"noun_matches"` +} + +type PklGenerator struct { + Model *[]openfga.TypeDefinition + Convention *PklConvention +} + +type genAssignment struct { + name string + relations map[string]genTypeRel +} + +type genAssertion struct { + name string + relations []string +} + +type genTypeRel struct { + name string + objects map[string]genTypeRelObj +} + +type genTypeRelObj struct { + name string + with *string +} + +func (g *PklGenerator) Generate() (*GeneratedFiles, error) { + var files []GeneratedFile + + files = append(files, GeneratedFile{ + Path: "run", + Content: strings.ReplaceAll(runFileContent, "[backtick]", "`"), + OverrideIfExists: false, + }) + files = append(files, GeneratedFile{ + Path: "test_example.pkl", + Content: exampleFileContent, + OverrideIfExists: false, + }) + files = append(files, GeneratedFile{ + Path: "lib/test.pkl", + Content: testLibFileContent, + OverrideIfExists: true, + }) + + gAssignments := g.collectGenAssignments() + gAssertions := g.collectGenAssertions() + + var gen strings.Builder + gen.WriteString(fmt.Sprintf("// Code generated by fga generate pkl %v DO NOT EDIT.\n", versionStr)) + g.generateBaseTypes(&gAssignments, &gen) + g.generateBaseAssertions(&gAssertions, &gen) + files = append(files, GeneratedFile{ + Path: "lib/gen.pkl", + Content: gen.String(), + OverrideIfExists: true, + }) + + var typeDefs strings.Builder + typeDefs.WriteString(`import "lib/gen.pkl"`) + typeDefs.WriteString("\n") + typeDefs.WriteString(`import "assertions.pkl"`) + typeDefs.WriteString("\n") + g.generateTypeDefinitionContent(&gAssignments, &typeDefs) + files = append(files, GeneratedFile{ + Path: "type.pkl", + Content: typeDefs.String(), + OverrideIfExists: false, + }) + + var assertionsDefs strings.Builder + assertionsDefs.WriteString(`import "lib/gen.pkl"`) + assertionsDefs.WriteString("\n") + g.generateAssertionsDefinitionContent(&gAssertions, &assertionsDefs) + files = append(files, GeneratedFile{ + Path: "type.pkl", + Content: assertionsDefs.String(), + OverrideIfExists: false, + }) + + return &GeneratedFiles{Files: files}, nil +} + +func (g *PklGenerator) generateTypeDefinitionContent(assignments *[]genAssignment, gen *strings.Builder) { + for _, t := range *assignments { + baseClassName := g.Convention.getBaseTypeName(t.name) + + writeLine(gen, "") + writeLine(gen, "class %v extends gen.%v {", g.Convention.getTypeName(t.name), baseClassName) + writeLine(gen, " // -------------------------------------------------------") + writeLine(gen, " // pkl not support parent's properties yet, this help in case we want to use id") + writeLine(gen, " // also if we use some union types we could change here.") + writeLine(gen, " id: String") + writeLine(gen, ` hidden type: String = "%v"`, t.name) + writeLine(gen, ` hidden fgaType: String = "%v"`, t.name) + writeLine(gen, ` function toFGAType() = "\(type):\(id)"`) + writeLine(gen, ` function toFGATypeWith(relation: String) = "\(type):\(id)#\(relation)"`) + writeLine(gen, " // -------------------------------------------------------") + writeLine(gen, "") + writeLine(gen, " // this is where you make your tests look nicer, for example:") + writeLine(gen, " // ") + writeLine(gen, " // Using a function with descriptive name make setup of test is easier to understand") + writeLine(gen, " // function has_user(i: User) = i.relation_member(this)") + writeLine(gen, " // ") + writeLine(gen, " // Using Assertions with descriptive name make the test easier to understand") + writeLine(gen, ` // function in_org(org: Org, asserts: (assertions.OrgAssertions) -> Listing) =`) + writeLine(gen, ` // assert_org("Test user \(id) in org \(org.id)", org, new assertions.OrgAssertions {}, asserts)`) //nolint:lll + writeLine(gen, "") + writeLine(gen, "}") + } +} + +func (g *PklGenerator) generateAssertionsDefinitionContent(assertions *[]genAssertion, gen *strings.Builder) { + for _, t := range *assertions { + baseClassName := g.Convention.getBaseAssertionName(t.name) + writeLine(gen, "") + writeLine(gen, "class %v extends gen.%v {", g.Convention.getAssertionsName(t.name), baseClassName) + writeLine(gen, " // you can put some custom assertions in this class") + writeLine(gen, "") + writeLine(gen, "}") + } +} + +func (g *PklGenerator) generateBaseTypes(assignments *[]genAssignment, gen *strings.Builder) { + for _, t := range *assignments { + baseClassName := g.Convention.getBaseTypeName(t.name) + assertTypes := make(map[string]bool) + + writeLine(gen, "") + writeLine(gen, "abstract class %v {", baseClassName) + writeLine(gen, " id: String") + writeLine(gen, ` hidden type: String = "%v"`, t.name) + writeLine(gen, ` hidden fgaType: String = "%v"`, t.name) + writeLine(gen, "") + writeLine(gen, ` function toFGAType() = "\(type):\(id)"`) + writeLine(gen, ` function toFGATypeWith(relation: String) = "\(type):\(id)#\(relation)"`) + + // sorted key to make the generated relation uniformed + rels := maps.Keys(t.relations) + slices.Sort(rels) + for _, k := range rels { + rel := t.relations[k] + + fn := g.Convention.getRelationFnName(rel.name) + var args []string + var exprs []string + var argsType []string + + // sorted object to make the generated object if conditions uniformed + objs := maps.Keys(rel.objects) + slices.Sort(objs) + for _, o := range objs { + obj := rel.objects[o] + + if obj.with != nil { + exprs = append(exprs, fmt.Sprintf(`i.toFGATypeWith("%v")`, *obj.with)) + } else { + exprs = append(exprs, `i.toFGAType()`) + } + args = append(args, g.Convention.getBaseTypeName(obj.name)) + argsType = append(argsType, obj.name) + assertTypes[obj.name] = true + } + + if len(args) == 0 { + continue + } + + writeLine(gen, "") + writeLine(gen, ` function %v(i: %v): Mapping = new Mapping {`, fn, strings.Join(args, "|")) + writeLine(gen, ` ["user"] = toFGAType()`) + writeLine(gen, ` ["relation"] = "%v"`, rel.name) + if len(args) == 1 { + writeLine(gen, ` ["object"] = %v`, exprs[0]) + } else { + write(gen, ` ["object"] = `) + for i, _ := range args { + write(gen, `if (i.fgaType == "%v") %v`+"\n", argsType[i], exprs[i]) + write(gen, ` else `) + } + writeLine(gen, `""`) + } + writeLine(gen, " }") + + /* + + + + .fold((new Mapping {}).toMap(), (r: Map, m: Mapping) -> r + m.toMap() + ) + */ + } + + for target, _ := range assertTypes { + writeLine(gen, "") + writeLine(gen, " function assert_%v(", strcase.ToSnake(target)) + writeLine(gen, " name: String,") + writeLine(gen, " object: %v,", g.Convention.getBaseTypeName(target)) + writeLine(gen, " customAssertions: %v,", g.Convention.getBaseAssertionName(target)) + writeLine(gen, " asserts: (%v) -> Listing", g.Convention.getBaseAssertionName(target)) + writeLine(gen, " ): Mapping = new Mapping {") + writeLine(gen, ` ["name"] = name`) + writeLine(gen, ` ["check"] = new Listing {`) + writeLine(gen, ` new Mapping {`) + writeLine(gen, ` ["user"] = toFGAType()`) + writeLine(gen, ` ["object"] = object.toFGAType()`) + writeLine(gen, ` ["assertions"] = asserts.apply(customAssertions)`) + writeLine(gen, ` .fold((new Mapping {}).toMap(), (r: Map, m: Mapping) -> r + m.toMap())`) + writeLine(gen, ` }`) + writeLine(gen, ` }`) + writeLine(gen, " }") + } + writeLine(gen, "}") + } +} + +func (g *PklGenerator) generateBaseAssertions(assertions *[]genAssertion, gen *strings.Builder) { + for _, t := range *assertions { + baseClassName := g.Convention.getBaseAssertionName(t.name) + + writeLine(gen, "") + write(gen, "abstract class %v {", baseClassName) + var nouns []string + var verbs []string + + for _, rel := range t.relations { + if g.Convention.isNoun(t.name, rel) { + p := g.Convention.getAssertPositiveForNounPrefix(t.name) + n := g.Convention.getAssertNegativeForNounPrefix(t.name) + g.writeRelationAssertions(gen, p, n, rel) + nouns = append(nouns, rel) + } else { + p := g.Convention.getAssertPositiveForVerbPrefix(t.name) + n := g.Convention.getAssertNegativeForVerbPrefix(t.name) + g.writeRelationAssertions(gen, p, n, rel) + verbs = append(verbs, rel) + } + + p := g.Convention.getAssertHasRelationPrefix(t.name) + n := g.Convention.getAssertDoNotHaveRelationPrefix(t.name) + g.writeRelationAssertions(gen, p, n, rel) + } + + p := g.Convention.getAssertHasAllRelations(t.name) + n := g.Convention.getAssertDoNotHaveAnyRelations(t.name) + g.writeAllRelationAssertions(gen, p, n, t.relations) + + p = g.Convention.getAssertHasAllRelationsForNouns(t.name) + n = g.Convention.getAssertDoNotHaveAnyRelationsForNouns(t.name) + g.writeAllRelationAssertions(gen, p, n, nouns) + + p = g.Convention.getAssertHasAllRelationsForVerbs(t.name) + n = g.Convention.getAssertDoNotHaveAnyRelationsForVerbs(t.name) + g.writeAllRelationAssertions(gen, p, n, verbs) + + writeLine(gen, "}") + } +} + +func (g *PklGenerator) writeRelationAssertions(gen *strings.Builder, p, n, rel string) { + if p != "" || n != "" { + writeLine(gen, "") + } + if p != "" { + writeLine(gen, ` %v%v = %v%v("")`, p, rel, p, rel) + writeLine(gen, ` function %v%v(reason: String) = new Mapping { ["%v"] = true }`, p, rel, rel) + } + if n != "" { + writeLine(gen, ` %v%v = %v%v("")`, n, rel, n, rel) + writeLine(gen, ` function %v%v(reason: String) = new Mapping { ["%v"] = false }`, n, rel, rel) + } +} + +func (g *PklGenerator) writeAllRelationAssertions(gen *strings.Builder, p, n string, rels []string) { + if len(rels) == 0 { + return + } + if p != "" || n != "" { + writeLine(gen, "") + } + if p != "" { + writeLine(gen, ` %v = %v("")`, p, p) + writeLine(gen, ` function %v(reason: String) = new Mapping {`, p) + for _, rel := range rels { + writeLine(gen, ` ["%v"] = true`, rel) + } + writeLine(gen, ` }`) + } + if n != "" { + writeLine(gen, ` %v = %v("")`, n, n) + writeLine(gen, ` function %v(reason: String) = new Mapping {`, n) + for _, rel := range rels { + writeLine(gen, ` ["%v"] = false`, rel) + } + writeLine(gen, ` }`) + } +} + +func (g *PklGenerator) collectGenAssignments() []genAssignment { + m := make(map[string]genAssignment) + for _, defs := range *g.Model { + // always add types in models, in case it's the latest one, we still have empty Base class + m[defs.Type] = genAssignment{name: defs.Type, relations: make(map[string]genTypeRel, 0)} + + if defs.Metadata == nil { + continue + } + if defs.Metadata.Relations == nil { + continue + } + + for relName, r := range *defs.Metadata.Relations { + if r.DirectlyRelatedUserTypes == nil { + continue + } + + for _, ut := range *r.DirectlyRelatedUserTypes { + w := ut.Relation + t, ok := m[ut.Type] + if !ok { + t = genAssignment{name: ut.Type, relations: make(map[string]genTypeRel, 0)} + m[ut.Type] = t + } + + rel, ok := t.relations[relName] + if !ok { + rel = genTypeRel{name: relName, objects: make(map[string]genTypeRelObj, 0)} + t.relations[relName] = rel + } + + k := defs.Type + if w != nil { + k = fmt.Sprintf("%v#%v", k, *w) + } + t.relations[relName].objects[k] = genTypeRelObj{name: defs.Type, with: w} + } + } + } + + keys := maps.Keys(m) + slices.Sort(keys) + var result []genAssignment + for _, key := range keys { + result = append(result, m[key]) + } + return result +} + +func (g *PklGenerator) collectGenAssertions() []genAssertion { + m := make(map[string]genAssertion) + for _, defs := range *g.Model { + if defs.Metadata == nil { + continue + } + if defs.Metadata.Relations == nil { + continue + } + + rels := maps.Keys(*defs.Metadata.Relations) + slices.Sort(rels) + m[defs.Type] = genAssertion{name: defs.Type, relations: rels} + } + + keys := maps.Keys(m) + slices.Sort(keys) + var result []genAssertion + for _, key := range keys { + result = append(result, m[key]) + } + return result +} + +func (c *PklConvention) getTypeName(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.TypeName != "" { + return cf.TypeName + } + return strcase.ToCamel(userType) +} + +func (c *PklConvention) getAssertionsName(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertionsName != "" { + return cf.AssertionsName + } + return strcase.ToCamel(userType) + "Assertions" +} + +func (c *PklConvention) getBaseTypeName(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.BaseTypeName != "" { + return cf.BaseTypeName + } + return "Base" + strcase.ToCamel(userType) +} + +func (c *PklConvention) getBaseAssertionName(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.BaseAssertionsName != "" { + return cf.BaseAssertionsName + } + return "Base" + strcase.ToCamel(userType) + "Assertions" +} + +func (c *PklConvention) getRelationFnName(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.RelationFnPrefix != "" { + return cf.RelationFnPrefix + userType + } + return "relation_" + userType +} + +func (c *PklConvention) allowNone(s string) string { + if strings.ToLower(s) == "none" { + return "" + } + return s +} + +func (c *PklConvention) getAssertHasAllRelations(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertHasAllRelations != "" { + return c.allowNone(cf.AssertHasAllRelations) + } + return "has_all_relations" +} + +func (c *PklConvention) getAssertDoNotHaveAnyRelations(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertDoNotHaveAnyRelations != "" { + return c.allowNone(cf.AssertDoNotHaveAnyRelations) + } + return "do_not_have_any_relations" +} + +func (c *PklConvention) getAssertHasAllRelationsForNouns(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertHasAllRelationsForNouns != "" { + return c.allowNone(cf.AssertHasAllRelationsForNouns) + } + return "should_be_everything" +} + +func (c *PklConvention) getAssertDoNotHaveAnyRelationsForNouns(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertDoNotHaveAnyRelationsForNouns != "" { + return c.allowNone(cf.AssertDoNotHaveAnyRelationsForNouns) + } + return "should_not_be_anything" +} + +func (c *PklConvention) getAssertHasAllRelationsForVerbs(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertHasAllRelationsForVerbs != "" { + return c.allowNone(cf.AssertHasAllRelationsForVerbs) + } + return "can_do_everything" +} + +func (c *PklConvention) getAssertDoNotHaveAnyRelationsForVerbs(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertDoNotHaveAnyRelationsForVerbs != "" { + return c.allowNone(cf.AssertDoNotHaveAnyRelationsForVerbs) + } + return "cannot_do_anything" +} + +func (c *PklConvention) getAssertHasRelationPrefix(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertHasRelationPrefix != "" { + return c.allowNone(cf.AssertHasRelationPrefix) + } + return "has_relation_" +} + +func (c *PklConvention) getAssertDoNotHaveRelationPrefix(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertDoNotHaveRelationPrefix != "" { + return c.allowNone(cf.AssertDoNotHaveRelationPrefix) + } + return "do_not_have_relation_" +} + +func (c *PklConvention) getAssertPositiveForNounPrefix(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertPositiveForNounPrefix != "" { + return c.allowNone(cf.AssertPositiveForNounPrefix) + } + return "should_be_" +} + +func (c *PklConvention) getAssertNegativeForNounPrefix(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertNegativeForNounPrefix != "" { + return c.allowNone(cf.AssertNegativeForNounPrefix) + } + return "should_not_be_" +} + +func (c *PklConvention) getAssertPositiveForVerbPrefix(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertPositiveForVerbPrefix != "" { + return c.allowNone(cf.AssertPositiveForVerbPrefix) + } + return "can_" +} + +func (c *PklConvention) getAssertNegativeForVerbPrefix(userType string) string { + if cf, ok := c.Config[userType]; ok && cf.AssertNegativeForVerbPrefix != "" { + return c.allowNone(cf.AssertNegativeForVerbPrefix) + } + return "cannot_" +} + +func (c *PklConvention) isNoun(userType, name string) bool { + n := strings.ToLower(name) + if cf, ok := c.Config[userType]; ok && len(cf.NounMatches) != 0 { + matches := cf.NounMatches + for _, match := range matches { + parts := strings.Split(match, ":") + if len(parts) == 2 { + if parts[0] == "suffix" && strings.HasSuffix(name, parts[1]) { + return true + } + + if parts[0] == "prefix" && strings.HasPrefix(name, parts[1]) { + return true + } + + if parts[0] == "is" && name == parts[1] { + return true + } + } + if name == match { + return true + } + } + return false + } + return strings.HasSuffix(n, "or") || strings.HasSuffix(n, "er") +} + +func write(gen *strings.Builder, str string, args ...any) { + gen.WriteString(fmt.Sprintf(str, args...)) +} + +func writeLine(gen *strings.Builder, str string, args ...any) { + gen.WriteString(fmt.Sprintf(str+"\n", args...)) +} + +// ` in the content is [backtick] will be replaced before save to disk +const runFileContent = `#!/bin/bash +param=$1 +if [ "$param" == "all" ]; then + tests=[backtick]ls ./test*.pkl[backtick] + for test in $tests + do + echo "--- running test file: $test" + pkl eval -f yaml $test | fga model test --tests /dev/stdin + echo "--- finished" + done +else + pkl eval -f yaml $param | fga model test --tests /dev/stdin +fi +` + +const exampleFileContent = `import "type.pkl" +import "lib/test.pkl" + +// NOTE: this is where you define your type for testing, for example: +// local Anna: type.User = new type.User { id = "Anna" } +// local OpenFGA: type.Org = new type.Org { id = "OpenFGA" } + +suite: test.OpenFGATestSuite = new { + name = "Awesome test - RENAME ME" + model = read("../model.fga") + setup { + // NOTE: setup your test here, for example: + // Anna.relation_owner(OpenFGA) + } + tests { + // NOTE: write your assertions here, for example: + // Anna.in_org(OpenFGA, (she) -> new Listing { + // she.should_be_member + // she.should_be_owner("comment or reason") + // }) + } +} + +output { value = test.output_value(suite) } + +` + +const testLibFileContent = `// Code generated by fga generate pkl, DO NOT EDIT. + +class OpenFGATestSuite { + name: String + model: Resource + setup: Listing + tests: Listing +} + +function output_value(suite: OpenFGATestSuite) = new Mapping { + ["name"] = suite.name + ["model"] = suite.model.text + ["tuples"] = flatten(suite.setup) + ["tests"] = suite.tests +} + +local function flatten(tuples: Listing) = tuples.fold( + (new Listing {}).toList(), + (list: List, item: Any) -> + if (item.getClass().simpleName == "Mapping") + list.add(item) + else + item.fold(list, (l: List, i: Mapping) -> l.add(i)) +) +`