diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index f374a1c..202d847 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -20,15 +20,22 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - - name: Module cache - uses: actions/cache@v4 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - name: Run tests run: go test ./... + + docs-check: + name: Validate docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - run: make generate-docs + - name: Check no diff + run: | + if [ ! -z "$(git status --porcelain)" ]; then echo "::error file=Makefile::Doc generation produced diff. Run 'make generate-docs' and commit results."; exit 1; fi \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e8c8563 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ + +.PHONY: test +test: + @echo "Running tests..." + go test -v ./... + @echo "Tests passed successfully!" + +generate-docs: + @echo "Generating documentation..." + go run ./docs/generate-commands.go + @echo "Documentation generated successfully!" \ No newline at end of file diff --git a/cmd/docs.go b/cmd/docs.go new file mode 100644 index 0000000..4ae3175 --- /dev/null +++ b/cmd/docs.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra/doc" +) + +// GenerateDoc generates cobra docs of the cmd +func GenerateDoc(path string) error { + linkHandler := func(name string) string { + return name + } + + filePrepender := func(filename string) string { + return "\n\n" + } + + if err := doc.GenMarkdownTreeCustom(rootCmd, path, filePrepender, linkHandler); err != nil { + return fmt.Errorf("error generating docs: %w", err) + } + return nil +} diff --git a/cmd/generate/testdata/success_react.golden b/cmd/generate/testdata/success_react.golden index 9a68ec5..4d2a173 100644 --- a/cmd/generate/testdata/success_react.golden +++ b/cmd/generate/testdata/success_react.golden @@ -1,9 +1,10 @@ 'use client'; import { - useBooleanFlagDetails, - useNumberFlagDetails, - useStringFlagDetails, + type ReactFlagEvaluationOptions, + type ReactFlagEvaluationNoSuspenseOptions, + useFlag, + useSuspenseFlag, } from "@openfeature/react-sdk"; /** @@ -14,8 +15,35 @@ import { * - default value: `0.15` * - type: `number` */ -export const useDiscountPercentage = (options: Parameters[2]) => { - return useNumberFlagDetails("discountPercentage", 0.15, options); +export const useDiscountPercentage = (options: ReactFlagEvaluationOptions) => { + return useFlag("discountPercentage", 0.15, options); +}; + +/** +* Discount percentage applied to purchases. +* +* **Details:** +* - flag key: `discountPercentage` +* - default value: `0.15` +* - type: `number` +* +* Equivalent to useFlag with options: `{ suspend: true }` +* @experimental — Suspense is an experimental feature subject to change in future versions. +*/ +export const useSuspenseDiscountPercentage = (options: ReactFlagEvaluationNoSuspenseOptions) => { + return useSuspenseFlag("discountPercentage", 0.15, options); +}; + +/** +* Controls whether Feature A is enabled. +* +* **Details:** +* - flag key: `enableFeatureA` +* - default value: `false` +* - type: `boolean` +*/ +export const useEnableFeatureA = (options: ReactFlagEvaluationOptions) => { + return useFlag("enableFeatureA", false, options); }; /** @@ -25,9 +53,12 @@ export const useDiscountPercentage = (options: Parameters[2]) => { - return useBooleanFlagDetails("enableFeatureA", false, options); +export const useSuspenseEnableFeatureA = (options: ReactFlagEvaluationNoSuspenseOptions) => { + return useSuspenseFlag("enableFeatureA", false, options); }; /** @@ -38,8 +69,35 @@ export const useEnableFeatureA = (options: Parameters[2]) => { - return useStringFlagDetails("greetingMessage", "Hello there!", options); +export const useGreetingMessage = (options: ReactFlagEvaluationOptions) => { + return useFlag("greetingMessage", "Hello there!", options); +}; + +/** +* The message to use for greeting users. +* +* **Details:** +* - flag key: `greetingMessage` +* - default value: `Hello there!` +* - type: `string` +* +* Equivalent to useFlag with options: `{ suspend: true }` +* @experimental — Suspense is an experimental feature subject to change in future versions. +*/ +export const useSuspenseGreetingMessage = (options: ReactFlagEvaluationNoSuspenseOptions) => { + return useSuspenseFlag("greetingMessage", "Hello there!", options); +}; + +/** +* Maximum allowed length for usernames. +* +* **Details:** +* - flag key: `usernameMaxLength` +* - default value: `50` +* - type: `number` +*/ +export const useUsernameMaxLength = (options: ReactFlagEvaluationOptions) => { + return useFlag("usernameMaxLength", 50, options); }; /** @@ -49,7 +107,10 @@ export const useGreetingMessage = (options: Parameters[2]) => { - return useNumberFlagDetails("usernameMaxLength", 50, options); +export const useSuspenseUsernameMaxLength = (options: ReactFlagEvaluationNoSuspenseOptions) => { + return useSuspenseFlag("usernameMaxLength", 50, options); }; diff --git a/cmd/root.go b/cmd/root.go index c30fa50..f4d71e8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,7 @@ import ( ) var ( - Version string + Version = "dev" Commit string Date string ) @@ -20,6 +20,7 @@ var rootCmd = &cobra.Command{ Use: "openfeature", Short: "CLI for OpenFeature.", Long: `CLI for OpenFeature related functionalities.`, + DisableAutoGenTag: true, } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/docs/commands/openfeature.md b/docs/commands/openfeature.md new file mode 100644 index 0000000..08d5ce1 --- /dev/null +++ b/docs/commands/openfeature.md @@ -0,0 +1,21 @@ + + +## openfeature + +CLI for OpenFeature. + +### Synopsis + +CLI for OpenFeature related functionalities. + +### Options + +``` + -h, --help help for openfeature +``` + +### SEE ALSO + +* [openfeature generate](openfeature_generate.md) - Code generation for flag accessors for OpenFeature. +* [openfeature version](openfeature_version.md) - Print the version number of the OpenFeature CLI + diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md new file mode 100644 index 0000000..68f9447 --- /dev/null +++ b/docs/commands/openfeature_generate.md @@ -0,0 +1,24 @@ + + +## openfeature generate + +Code generation for flag accessors for OpenFeature. + +### Synopsis + +Code generation for flag accessors for OpenFeature. + +### Options + +``` + --flag_manifest_path string Path to the flag manifest. + -h, --help help for generate + --output_path string Output path for the codegen +``` + +### SEE ALSO + +* [openfeature](openfeature.md) - CLI for OpenFeature. +* [openfeature generate go](openfeature_generate_go.md) - Generate Golang flag accessors for OpenFeature. +* [openfeature generate react](openfeature_generate_react.md) - Generate typesafe React Hooks. + diff --git a/docs/commands/openfeature_generate_go.md b/docs/commands/openfeature_generate_go.md new file mode 100644 index 0000000..bac03e5 --- /dev/null +++ b/docs/commands/openfeature_generate_go.md @@ -0,0 +1,32 @@ + + +## openfeature generate go + +Generate Golang flag accessors for OpenFeature. + +### Synopsis + +Generate Golang flag accessors for OpenFeature. + +``` +openfeature generate go [flags] +``` + +### Options + +``` + -h, --help help for go + --package_name string Name of the Go package to be generated. +``` + +### Options inherited from parent commands + +``` + --flag_manifest_path string Path to the flag manifest. + --output_path string Output path for the codegen +``` + +### SEE ALSO + +* [openfeature generate](openfeature_generate.md) - Code generation for flag accessors for OpenFeature. + diff --git a/docs/commands/openfeature_generate_react.md b/docs/commands/openfeature_generate_react.md new file mode 100644 index 0000000..2514b18 --- /dev/null +++ b/docs/commands/openfeature_generate_react.md @@ -0,0 +1,31 @@ + + +## openfeature generate react + +Generate typesafe React Hooks. + +### Synopsis + +Generate typesafe React Hooks compatible with the OpenFeature React SDK. + +``` +openfeature generate react [flags] +``` + +### Options + +``` + -h, --help help for react +``` + +### Options inherited from parent commands + +``` + --flag_manifest_path string Path to the flag manifest. + --output_path string Output path for the codegen +``` + +### SEE ALSO + +* [openfeature generate](openfeature_generate.md) - Code generation for flag accessors for OpenFeature. + diff --git a/docs/commands/openfeature_version.md b/docs/commands/openfeature_version.md new file mode 100644 index 0000000..44eba8e --- /dev/null +++ b/docs/commands/openfeature_version.md @@ -0,0 +1,20 @@ + + +## openfeature version + +Print the version number of the OpenFeature CLI + +``` +openfeature version [flags] +``` + +### Options + +``` + -h, --help help for version +``` + +### SEE ALSO + +* [openfeature](openfeature.md) - CLI for OpenFeature. + diff --git a/docs/generate-commands.go b/docs/generate-commands.go new file mode 100644 index 0000000..f420f0f --- /dev/null +++ b/docs/generate-commands.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + + "github.com/open-feature/cli/cmd" +) + +const docPath = "./docs/commands" + +// GenerateDoc generates cobra docs of the cmd +func main() { + if err := cmd.GenerateDoc(docPath); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 9d306d5..2eae4f6 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,14 @@ require ( ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index 8d43603..ee6e884 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -30,6 +31,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= diff --git a/internal/generate/manifestutils/manifestutils.go b/internal/generate/manifestutils/manifestutils.go index 35f6aa9..7669ba0 100644 --- a/internal/generate/manifestutils/manifestutils.go +++ b/internal/generate/manifestutils/manifestutils.go @@ -7,10 +7,10 @@ import ( "sort" "strconv" - flagmanifest "github.com/open-feature/cli/docs/schema/v0" "github.com/open-feature/cli/internal/filesystem" "github.com/open-feature/cli/internal/flagkeys" "github.com/open-feature/cli/internal/generate/types" + flagmanifest "github.com/open-feature/cli/schema/v0" "github.com/santhosh-tekuri/jsonschema/v5" "github.com/spf13/afero" diff --git a/internal/generate/plugins/react/react.go b/internal/generate/plugins/react/react.go index c8bb4b9..827f25c 100644 --- a/internal/generate/plugins/react/react.go +++ b/internal/generate/plugins/react/react.go @@ -2,7 +2,6 @@ package react import ( _ "embed" - "sort" "strconv" "text/template" @@ -48,32 +47,6 @@ func flagInitParam(flagName string) string { return strconv.Quote(flagName) } -func flagAccessFunc(t types.FlagType) string { - switch t { - case types.IntType, types.FloatType: - return "useNumberFlagDetails" - case types.BoolType: - return "useBooleanFlagDetails" - case types.StringType: - return "useStringFlagDetails" - default: - return "" - } -} - -func supportImports(flags []*types.FlagTmplData) []string { - imports := make(map[string]struct{}) - for _, flag := range flags { - imports[flagAccessFunc(flag.Type)] = struct{}{} - } - var result []string - for k := range imports { - result = append(result, k) - } - sort.Strings(result) - return result -} - func defaultValueLiteral(flag *types.FlagTmplData) string { switch flag.Type { case types.StringType: @@ -100,8 +73,6 @@ func (g *genImpl) Generate(input types.Input) error { funcs := template.FuncMap{ "FlagVarName": flagVarName, "FlagInitParam": flagInitParam, - "FlagAccessFunc": flagAccessFunc, - "SupportImports": supportImports, "DefaultValueLiteral": defaultValueLiteral, "TypeString": typeString, } diff --git a/internal/generate/plugins/react/react.tmpl b/internal/generate/plugins/react/react.tmpl index d37581b..d4927e4 100644 --- a/internal/generate/plugins/react/react.tmpl +++ b/internal/generate/plugins/react/react.tmpl @@ -1,9 +1,10 @@ 'use client'; import { -{{- range $_, $p := SupportImports .Flags}} - {{$p}}, -{{- end}} + type ReactFlagEvaluationOptions, + type ReactFlagEvaluationNoSuspenseOptions, + useFlag, + useSuspenseFlag, } from "@openfeature/react-sdk"; {{ range .Flags}} /** @@ -11,10 +12,25 @@ import { * * **Details:** * - flag key: `{{ .Name}}` -* - default value: `{{ .DefaultValue}}` +* - default value: `{{ .DefaultValue }}` * - type: `{{TypeString .Type}}` */ -export const use{{FlagVarName .Name}} = (options: Parameters[2]) => { - return {{FlagAccessFunc .Type}}({{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, options); +export const use{{FlagVarName .Name}} = (options: ReactFlagEvaluationOptions) => { + return useFlag({{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, options); +}; + +/** +* {{.Docs}} +* +* **Details:** +* - flag key: `{{ .Name}}` +* - default value: `{{ .DefaultValue }}` +* - type: `{{TypeString .Type}}` +* +* Equivalent to useFlag with options: `{ suspend: true }` +* @experimental — Suspense is an experimental feature subject to change in future versions. +*/ +export const useSuspense{{FlagVarName .Name}} = (options: ReactFlagEvaluationNoSuspenseOptions) => { + return useSuspenseFlag({{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, options); }; {{ end}} \ No newline at end of file diff --git a/docs/schema/v0/flag_manifest.json b/schema/v0/flag_manifest.json similarity index 76% rename from docs/schema/v0/flag_manifest.json rename to schema/v0/flag_manifest.json index da02e97..24eb811 100644 --- a/docs/schema/v0/flag_manifest.json +++ b/schema/v0/flag_manifest.json @@ -1,4 +1,5 @@ { + "$id": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag_manifest.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "Flag Manifest", "description": "Describes a configuration of OpenFeature flags, including info such as their types and default values.", @@ -13,10 +14,13 @@ "$ref": "#/$defs/flag" } }, - "additionalProperties": false + "additionalProperties": false, + "minProperties": 1 } }, - "required": ["flags"], + "required": [ + "flags" + ], "$defs": { "flag": { "oneOf": [ @@ -36,30 +40,37 @@ "$ref": "#/$defs/objectType" } ], - "required": ["flagType", "defaultValue"] + "required": [ + "flagType", + "defaultValue" + ] }, "booleanType": { "type": "object", "properties": { "flagType": { "type": "string", - "enum": ["boolean"] + "enum": [ + "boolean" + ] }, "defaultValue": { + "description": "The default value returned in code if a flag evaluation is unsuccessful", "type": "boolean" }, "description": { "type": "string" } - }, - "additionalProperties": false + } }, "stringType": { "type": "object", "properties": { "flagType": { "type": "string", - "enum": ["string"] + "enum": [ + "string" + ] }, "defaultValue": { "type": "string" @@ -67,15 +78,16 @@ "description": { "type": "string" } - }, - "additionalProperties": false + } }, "integerType": { "type": "object", "properties": { "flagType": { "type": "string", - "enum": ["integer"] + "enum": [ + "integer" + ] }, "defaultValue": { "type": "integer" @@ -83,15 +95,16 @@ "description": { "type": "string" } - }, - "additionalProperties": false + } }, "floatType": { "type": "object", "properties": { "flagType": { "type": "string", - "enum": ["float"] + "enum": [ + "float" + ] }, "defaultValue": { "type": "number" @@ -99,15 +112,16 @@ "description": { "type": "string" } - }, - "additionalProperties": false + } }, "objectType": { "type": "object", "properties": { "flagType": { "type": "string", - "enum": ["object"] + "enum": [ + "object" + ] }, "defaultValue": { "type": "object" @@ -115,8 +129,7 @@ "description": { "type": "string" } - }, - "additionalProperties": false + } } } -} +} \ No newline at end of file diff --git a/docs/schema/v0/flagmanifest.go b/schema/v0/flagmanifest.go similarity index 60% rename from docs/schema/v0/flagmanifest.go rename to schema/v0/flagmanifest.go index dd66f32..8d0a596 100644 --- a/docs/schema/v0/flagmanifest.go +++ b/schema/v0/flagmanifest.go @@ -8,5 +8,5 @@ import _ "embed" //go:embed flag_manifest.json var Schema string -// SchemaPath proviees the current path and version of flag manifest. -const SchemaPath = "github.com/open-feature/cli/docs/schema/v0/flag_manifest.json" +// SchemaPath provides the current path and version of flag manifest. +const SchemaPath = "github.com/open-feature/cli/schema/v0/flag_manifest.json" diff --git a/schema/v0/flagmanifest_test.go b/schema/v0/flagmanifest_test.go new file mode 100644 index 0000000..cfffa27 --- /dev/null +++ b/schema/v0/flagmanifest_test.go @@ -0,0 +1,72 @@ +package flagmanifest + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +var compiledFlagManifestSchema *jsonschema.Schema + +func init() { + sch, err := jsonschema.CompileString(SchemaPath, Schema) + if err != nil { + log.Fatal(fmt.Errorf("error compiling JSON schema: %v", err)) + } + compiledFlagManifestSchema = sch +} + +func TestPositiveFlagManifest(t *testing.T) { + if err := walkPath(true, "./tests/positive"); err != nil { + t.Error(err) + t.FailNow() + } +} + +func TestNegativeFlagManifest(t *testing.T) { + if err := walkPath(false, "./tests/negative"); err != nil { + t.Error(err) + t.FailNow() + } +} + + +func walkPath(shouldPass bool, root string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + ps := strings.Split(path, ".") + if ps[len(ps)-1] != "json" { + return nil + } + + file, err := os.ReadFile(path) + if err != nil { + return err + } + + var v interface{} + if err := json.Unmarshal([]byte(file), &v); err != nil { + log.Fatal(err) + } + + err = compiledFlagManifestSchema.Validate(v) + + if (err != nil && shouldPass == true) { + return fmt.Errorf("file %s should not have failed validation, but did: %s", path, err) + } + + if (err == nil && shouldPass == false) { + return fmt.Errorf("file %s should have failed validation, but did not", path) + } + + return nil + }) +} diff --git a/schema/v0/tests/negative/missing-flag-type.json b/schema/v0/tests/negative/missing-flag-type.json new file mode 100644 index 0000000..3e37e76 --- /dev/null +++ b/schema/v0/tests/negative/missing-flag-type.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../flag_manifest.json", + "flags": { + "booleanFlag": { + "codeDefault": true + } + } +} \ No newline at end of file diff --git a/schema/v0/tests/negative/no-flags-in-manifest.json b/schema/v0/tests/negative/no-flags-in-manifest.json new file mode 100644 index 0000000..818c777 --- /dev/null +++ b/schema/v0/tests/negative/no-flags-in-manifest.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../flag_manifest.json", + "flags": {} +} \ No newline at end of file diff --git a/schema/v0/tests/positive/min-flag-manifest.json b/schema/v0/tests/positive/min-flag-manifest.json new file mode 100644 index 0000000..2a91cff --- /dev/null +++ b/schema/v0/tests/positive/min-flag-manifest.json @@ -0,0 +1,28 @@ +{ + "$schema": "../../flag_manifest.json", + "flags": { + "booleanFlag": { + "flagType": "boolean", + "defaultValue": true + }, + "stringFlag": { + "flagType": "string", + "defaultValue": "default" + }, + "integerFlag": { + "flagType": "integer", + "defaultValue": 50 + }, + "floatFlag": { + "flagType": "float", + "defaultValue": 0.15 + }, + "objectFlag": { + "flagType": "object", + "defaultValue": { + "primaryColor": "#007bff", + "secondaryColor": "#6c757d" + } + } + } +} \ No newline at end of file