feat: add doc gen, move schema path, add tests, fix react gen (#68)

## This PR

- moves JSON schema to a dedicated directory
- added schema validation tests
- fixed React code gen (and tests)
- automate CLI doc generation
- Loosen JSON schema
- ~~Rename default value~~

### Related Issues

Fixes #66

### Notes

It's a big PR that I could break into smaller changes if necessary.

---------

Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
Signed-off-by: Michael Beemer <michael.beemer@dynatrace.com>
This commit is contained in:
Michael Beemer 2025-01-27 10:20:19 -05:00 committed by GitHub
parent 60955af1a9
commit 68a72ee929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 441 additions and 78 deletions

View File

@ -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

11
Makefile Normal file
View File

@ -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!"

23
cmd/docs.go Normal file
View File

@ -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 "<!-- markdownlint-disable-file -->\n<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->\n"
}
if err := doc.GenMarkdownTreeCustom(rootCmd, path, filePrepender, linkHandler); err != nil {
return fmt.Errorf("error generating docs: %w", err)
}
return nil
}

View File

@ -1,9 +1,10 @@
'use client';
import {
useBooleanFlagDetails,
useNumberFlagDetails,
useStringFlagDetails,
type ReactFlagEvaluationOptions,
type ReactFlagEvaluationNoSuspenseOptions,
useFlag,
useSuspenseFlag,
} from "@openfeature/react-sdk";
/**
@ -14,8 +15,23 @@ import {
* - default value: `0.15`
* - type: `number`
*/
export const useDiscountPercentage = (options: Parameters<typeof useNumberFlagDetails>[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);
};
/**
@ -26,8 +42,23 @@ export const useDiscountPercentage = (options: Parameters<typeof useNumberFlagDe
* - default value: `false`
* - type: `boolean`
*/
export const useEnableFeatureA = (options: Parameters<typeof useBooleanFlagDetails>[2]) => {
return useBooleanFlagDetails("enableFeatureA", false, options);
export const useEnableFeatureA = (options: ReactFlagEvaluationOptions) => {
return useFlag("enableFeatureA", false, options);
};
/**
* Controls whether Feature A is enabled.
*
* **Details:**
* - flag key: `enableFeatureA`
* - default value: `false`
* - type: `boolean`
*
* Equivalent to useFlag with options: `{ suspend: true }`
* @experimental — Suspense is an experimental feature subject to change in future versions.
*/
export const useSuspenseEnableFeatureA = (options: ReactFlagEvaluationNoSuspenseOptions) => {
return useSuspenseFlag("enableFeatureA", false, options);
};
/**
@ -38,8 +69,23 @@ export const useEnableFeatureA = (options: Parameters<typeof useBooleanFlagDetai
* - default value: `Hello there!`
* - type: `string`
*/
export const useGreetingMessage = (options: Parameters<typeof useStringFlagDetails>[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);
};
/**
@ -50,6 +96,21 @@ export const useGreetingMessage = (options: Parameters<typeof useStringFlagDetai
* - default value: `50`
* - type: `number`
*/
export const useUsernameMaxLength = (options: Parameters<typeof useNumberFlagDetails>[2]) => {
return useNumberFlagDetails("usernameMaxLength", 50, options);
export const useUsernameMaxLength = (options: ReactFlagEvaluationOptions) => {
return useFlag("usernameMaxLength", 50, options);
};
/**
* Maximum allowed length for usernames.
*
* **Details:**
* - flag key: `usernameMaxLength`
* - default value: `50`
* - type: `number`
*
* Equivalent to useFlag with options: `{ suspend: true }`
* @experimental — Suspense is an experimental feature subject to change in future versions.
*/
export const useSuspenseUsernameMaxLength = (options: ReactFlagEvaluationNoSuspenseOptions) => {
return useSuspenseFlag("usernameMaxLength", 50, options);
};

View File

@ -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.

View File

@ -0,0 +1,21 @@
<!-- markdownlint-disable-file -->
<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->
## 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

View File

@ -0,0 +1,24 @@
<!-- markdownlint-disable-file -->
<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->
## 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.

View File

@ -0,0 +1,32 @@
<!-- markdownlint-disable-file -->
<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->
## 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.

View File

@ -0,0 +1,31 @@
<!-- markdownlint-disable-file -->
<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->
## 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.

View File

@ -0,0 +1,20 @@
<!-- markdownlint-disable-file -->
<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->
## 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.

16
docs/generate-commands.go Normal file
View File

@ -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)
}
}

2
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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"

View File

@ -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,
}

View File

@ -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<typeof {{FlagAccessFunc .Type}}>[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}}

View File

@ -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
}
}
}
}
}

View File

@ -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"

View File

@ -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
})
}

View File

@ -0,0 +1,8 @@
{
"$schema": "../../flag_manifest.json",
"flags": {
"booleanFlag": {
"codeDefault": true
}
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "../../flag_manifest.json",
"flags": {}
}

View File

@ -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"
}
}
}
}