From 1c368e4501732ee7bcce8b67077397d3c914d724 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Fri, 8 May 2026 07:50:01 -0400 Subject: [PATCH] feat: add support for typesafe object flags Signed-off-by: Michael Beemer --- README.md | 31 +- docs/custom-templates.md | 53 +- docs/object-flag-schemas.md | 247 +++++++ internal/cmd/generate.go | 63 +- internal/cmd/generate_test.go | 251 ++++++- internal/cmd/testdata/schema_angular.golden | 693 ++++++++++++++++++ internal/cmd/testdata/schema_csharp.golden | 335 +++++++++ internal/cmd/testdata/schema_go.golden | 178 +++++ internal/cmd/testdata/schema_java.golden | 230 ++++++ internal/cmd/testdata/schema_manifest.golden | 48 ++ internal/cmd/testdata/schema_nestjs.golden | 176 +++++ internal/cmd/testdata/schema_nodejs.golden | 243 ++++++ internal/cmd/testdata/schema_python.golden | 378 ++++++++++ internal/cmd/testdata/schema_react.golden | 179 +++++ internal/config/flags.go | 42 +- internal/flagset/flagset.go | 58 +- internal/generators/angular/angular.go | 18 +- internal/generators/angular/angular.tmpl | 35 +- internal/generators/csharp/csharp.go | 301 +++++++- internal/generators/csharp/csharp.tmpl | 56 +- internal/generators/generators.go | 7 +- internal/generators/golang/golang.go | 245 ++++++- internal/generators/golang/golang.tmpl | 39 +- internal/generators/java/java.go | 267 ++++++- internal/generators/java/java.tmpl | 66 ++ internal/generators/nestjs/nestjs.go | 15 +- internal/generators/nestjs/nestjs.tmpl | 13 +- internal/generators/nodejs/nodejs.go | 18 +- internal/generators/nodejs/nodejs.tmpl | 48 +- internal/generators/python/python.go | 268 ++++++- internal/generators/python/python.tmpl | 79 +- internal/generators/react/react.go | 18 +- internal/generators/react/react.tmpl | 45 +- internal/generators/schema.go | 44 ++ internal/generators/schema_test.go | 106 +++ .../generators/typescriptgen/typescriptgen.go | 233 ++++++ internal/manifest/json-schema.go | 60 +- internal/manifest/manage.go | 6 + internal/manifest/validate.go | 61 ++ internal/manifest/validate_test.go | 286 ++++++++ sample/sample_manifest.json | 10 +- schema/v0/flag-manifest.json | 47 ++ .../negative/schema-invalid-type-value.json | 12 + .../negative/schema-missing-type.json | 14 + .../negative/schema-nested-missing-type.json | 21 + .../mixed-flags-with-and-without-schema.json | 32 + .../object-flag-with-full-schema.json | 40 + .../positive/object-flag-without-schema.json | 13 + 48 files changed, 5564 insertions(+), 164 deletions(-) create mode 100644 docs/object-flag-schemas.md create mode 100644 internal/cmd/testdata/schema_angular.golden create mode 100644 internal/cmd/testdata/schema_csharp.golden create mode 100644 internal/cmd/testdata/schema_go.golden create mode 100644 internal/cmd/testdata/schema_java.golden create mode 100644 internal/cmd/testdata/schema_manifest.golden create mode 100644 internal/cmd/testdata/schema_nestjs.golden create mode 100644 internal/cmd/testdata/schema_nodejs.golden create mode 100644 internal/cmd/testdata/schema_python.golden create mode 100644 internal/cmd/testdata/schema_react.golden create mode 100644 internal/generators/schema.go create mode 100644 internal/generators/schema_test.go create mode 100644 internal/generators/typescriptgen/typescriptgen.go create mode 100644 schema/v0/testdata/negative/schema-invalid-type-value.json create mode 100644 schema/v0/testdata/negative/schema-missing-type.json create mode 100644 schema/v0/testdata/negative/schema-nested-missing-type.json create mode 100644 schema/v0/testdata/positive/mixed-flags-with-and-without-schema.json create mode 100644 schema/v0/testdata/positive/object-flag-with-full-schema.json create mode 100644 schema/v0/testdata/positive/object-flag-without-schema.json diff --git a/README.md b/README.md index d763d55b..b6bd546b 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,9 @@ The flag manifest file should follow the [JSON schema](https://raw.githubusercon - `flags` - An object containing the feature flags - `flagKey` - A unique key for the flag - `description` - A description of what the flag does - - `type` - The type of the flag (`boolean`, `string`, `number`, `object`) + - `flagType` - The type of the flag (`boolean`, `string`, `integer`, `float`, `object`) - `defaultValue` - The default value of the flag + - `schema` - *(Optional, object flags only)* A [JSON Schema subset](./docs/object-flag-schemas.md) describing the object shape for type-safe code generation ### Example Flag Manifest @@ -303,15 +304,35 @@ The flag manifest file should follow the [JSON schema](https://raw.githubusercon { "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", "flags": { - "uniqueFlagKey": { - "description": "Description of what this flag does", - "type": "boolean|string|number|object", - "defaultValue": "default-value", + "enableFeatureA": { + "flagType": "boolean", + "defaultValue": false, + "description": "Controls whether Feature A is enabled." + }, + "themeCustomization": { + "flagType": "object", + "defaultValue": { + "primaryColor": "#007bff", + "secondaryColor": "#6c757d" + }, + "description": "Allows customization of theme colors.", + "schema": { + "type": "object", + "properties": { + "primaryColor": { "type": "string" }, + "secondaryColor": { "type": "string" } + }, + "required": ["primaryColor"] + } } } } ``` +> **_NOTE:_** +> The `schema` field is optional. Object flags without a `schema` continue to generate generic types. +> See the [Object Flag Schemas](./docs/object-flag-schemas.md) documentation for details on supported JSON Schema keywords, runtime validation hooks, per-language behavior, and limitations. + ## Remote Flag Management The OpenFeature CLI supports synchronizing flags with remote flag management services through a standardized OpenAPI-based approach. This enables teams to: diff --git a/docs/custom-templates.md b/docs/custom-templates.md index 34876263..fe11d003 100644 --- a/docs/custom-templates.md +++ b/docs/custom-templates.md @@ -48,16 +48,26 @@ type TemplateData struct { Flags []Flag } Params struct { - OutputPath string - Custom any // Language-specific parameters + OutputPath string + RuntimeValidation bool // Whether to generate runtime validation hooks (--runtime-validation flag) + Custom any // Language-specific parameters } } type Flag struct { - Key string // The flag key (e.g., "enable-feature") - Type FlagType // The flag type (boolean, string, integer, float, object) - Description string // Optional description of the flag - DefaultValue any // The default value for the flag + Key string // The flag key (e.g., "enable-feature") + Type FlagType // The flag type (boolean, string, integer, float, object) + Description string // Optional description of the flag + DefaultValue any // The default value for the flag + Schema *ObjectSchema // Optional JSON Schema subset for object flags (nil if not provided) +} + +type ObjectSchema struct { + Type string // JSON Schema type: "object", "array", "string", "number", "integer", "boolean" + Properties map[string]*ObjectSchema // Object properties (only for type "object") + Required []string // Required property names (only for type "object") + Items *ObjectSchema // Array element schema (only for type "array") + AdditionalProperties *bool // Whether extra properties are allowed (only for type "object") } ``` @@ -94,6 +104,15 @@ These functions are available in all templates: | `Quote` | Add double quotes | `{{ .Key \| Quote }}` → `"enable-feature"` | | `QuoteString` | Quote if string type | `{{ .DefaultValue \| QuoteString }}` | +### Schema-Related Functions (Available in All Templates) + +These functions are available in all language templates for working with [object flag schemas](./object-flag-schemas.md): + +| Function | Description | +|----------|-------------| +| `HasSchema` | Returns `true` if a flag has an object schema defined. Usage: `{{ if HasSchema . }}` | +| `HasObjectFlagsWithSchema` | Returns `true` if any flag in the list has a schema. Usage: `{{ if HasObjectFlagsWithSchema .Flagset.Flags }}` | + ### Go-Specific Functions | Function | Description | @@ -102,13 +121,21 @@ These functions are available in all templates: | `TypeString` | Convert flag type to Go type (`bool`, `string`, `int64`, `float64`, `map[string]any`) | | `SupportImports` | Generate required imports based on flags | | `ToMapLiteral` | Convert object value to Go map literal | +| `GoTypeDef` | Generate a Go struct type definition from a flag's object schema | +| `GoHookDef` | Generate a Go validation hook struct with `After` method for a flag's object schema | +| `GoFlagReturnType` | Return the Go type for a flag (typed struct name if schema exists, generic type otherwise) | +| `GoHookName` | Return the hook variable name for a typed object flag | -### React/Node.js/NestJS-Specific Functions +### React/Node.js/Angular/NestJS-Specific Functions | Function | Description | |----------|-------------| | `OpenFeatureType` | Convert flag type to TypeScript type (`boolean`, `string`, `number`, `object`) | | `ToJSONString` | Convert value to JSON string | +| `TSInterfaceDef` | Generate a TypeScript interface definition from a flag's object schema | +| `TSValidationHookDef` | Generate a TypeScript validation hook function for a flag's object schema | +| `TSFlagReturnType` | Return the TypeScript type for a flag (interface name if schema exists, generic type otherwise) | +| `TSValidationHookName` | Return the hook function name for a typed object flag | ### Python-Specific Functions @@ -121,6 +148,10 @@ These functions are available in all templates: | `TypedDetailsMethodAsync` | Get async details method name | | `PythonBoolLiteral` | Convert boolean to Python literal (`True`/`False`) | | `ToPythonDict` | Convert object value to Python dict literal | +| `PythonTypedDictDef` | Generate Python TypedDict class definitions from a flag's object schema | +| `PythonHookDef` | Generate a Python Hook class for runtime validation of a flag's object schema | +| `PythonFlagReturnType` | Return the Python type for a flag (TypedDict name if schema exists, generic type otherwise) | +| `PythonHookName` | Return the hook class name for a typed object flag | ### C#-Specific Functions @@ -129,6 +160,10 @@ These functions are available in all templates: | `OpenFeatureType` | Convert flag type to C# type (`bool`, `string`, `int`, `double`, `object`) | | `FormatDefaultValue` | Format default value for C# | | `ToCSharpDict` | Convert object value to C# dictionary literal | +| `CSharpRecordDef` | Generate C# record definitions from a flag's object schema | +| `CSharpHookDef` | Generate a C# Hook class for runtime validation of a flag's object schema | +| `CSharpFlagReturnType` | Return the C# type for a flag (record name if schema exists, generic type otherwise) | +| `CSharpHookName` | Return the hook class name for a typed object flag | ### Java-Specific Functions @@ -137,6 +172,10 @@ These functions are available in all templates: | `OpenFeatureType` | Convert flag type to Java type (`Boolean`, `String`, `Integer`, `Double`, `Object`) | | `FormatDefaultValue` | Format default value for Java | | `ToMapLiteral` | Convert object value to Java Map literal | +| `JavaRecordDef` | Generate Java record definitions from a flag's object schema | +| `JavaHookDef` | Generate a Java Hook class for runtime validation of a flag's object schema | +| `JavaFlagReturnType` | Return the Java type for a flag (record name if schema exists, generic type otherwise) | +| `JavaHookName` | Return the hook class name for a typed object flag | ## Example: Simple Go Template diff --git a/docs/object-flag-schemas.md b/docs/object-flag-schemas.md new file mode 100644 index 00000000..55c1819e --- /dev/null +++ b/docs/object-flag-schemas.md @@ -0,0 +1,247 @@ +# Object Flag Schemas + +The OpenFeature CLI supports an optional `schema` field on object-type flags that enables **type-safe code generation** and **runtime validation**. When a schema is provided, the generated code includes language-specific typed structures instead of generic object types, and optionally includes runtime validation hooks that verify provider responses at evaluation time. + +## Overview + +By default, object flags return generic types in generated code (`any` in Go, `JsonValue` in TypeScript, `object` in Python, etc.). This forces developers to manually cast values and provides no compile-time safety. + +With the `schema` field, generated code includes: + +- **Typed structures**: Language-native types (Go structs, TypeScript interfaces, Java records, C# records, Python TypedDicts) +- **Runtime validation hooks**: OpenFeature `after` hooks that validate the provider's response matches the expected shape +- **Compile-time safety**: IDE autocomplete, type checking, and refactoring support + +## Schema Definition + +The `schema` field uses a **subset of JSON Schema** to describe the shape of an object flag's value. Add it to any object-type flag in your manifest: + +```json +{ + "flags": { + "themeCustomization": { + "flagType": "object", + "defaultValue": { + "primaryColor": "#007bff", + "secondaryColor": "#6c757d", + "header": { + "fontSize": 16, + "visible": true + } + }, + "description": "Allows customization of theme colors.", + "schema": { + "type": "object", + "properties": { + "primaryColor": { "type": "string" }, + "secondaryColor": { "type": "string" }, + "header": { + "type": "object", + "properties": { + "fontSize": { "type": "integer" }, + "visible": { "type": "boolean" } + }, + "required": ["fontSize"] + } + }, + "required": ["primaryColor"] + } + } + } +} +``` + +### Supported JSON Schema Keywords + +The schema field supports the following JSON Schema keywords: + +| Keyword | Description | Applies To | +|---------|-------------|------------| +| `type` | **Required.** The data type. | All schemas | +| `properties` | Map of property names to their schemas. | `object` | +| `required` | Array of property names that must be present. | `object` | +| `items` | Schema for array elements. | `array` | +| `additionalProperties` | Whether extra properties are allowed (boolean). | `object` | + +### Supported Types + +| Type | Description | Language Mapping | +|------|-------------|-----------------| +| `string` | Text value | Go: `string`, TS: `string`, Java: `String`, C#: `string`, Python: `str` | +| `number` | Any numeric value | Go: `float64`, TS: `number`, Java: `Double`, C#: `double`, Python: `float` | +| `integer` | Whole number | Go: `int64`, TS: `number`, Java: `Integer`, C#: `int`, Python: `int` | +| `boolean` | True/false | Go: `bool`, TS: `boolean`, Java: `Boolean`, C#: `bool`, Python: `bool` | +| `object` | Nested object (recursive) | Go: inline struct, TS: inline interface, Java/C#: nested record, Python: nested TypedDict | +| `array` | List of items | Go: slice, TS: `T[]`, Java: `List`, C#: `List`, Python: `list[T]` | + +### Nesting + +Schemas support arbitrary nesting. Nested `object` types generate fully typed nested structures: + +```json +{ + "schema": { + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "fontSize": { "type": "integer" }, + "visible": { "type": "boolean" } + }, + "required": ["fontSize"] + } + } + } +} +``` + +For languages that support inline anonymous types (Go, TypeScript), nesting is expressed inline. For languages that require named types (Java, C#, Python), the CLI generates depth-first named types with compound names (e.g., `ThemeCustomizationHeader` for a `header` property on a `ThemeCustomization` flag). + +### Arrays + +Array types use the `items` keyword to define the element schema: + +```json +{ + "schema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "limits": { + "type": "array", + "items": { "type": "number" } + } + } + } +} +``` + +## Default Value Validation + +When a `schema` is provided, the CLI validates the flag's `defaultValue` against the schema at manifest load time. This catches authoring mistakes early: + +- Missing required properties +- Type mismatches (e.g., string where integer is expected) +- Additional properties when `additionalProperties: false` +- Nested validation (recursive) +- Array element type validation + +If validation fails, the CLI reports errors with paths like `flags.myFlag.defaultValue.header.fontSize`. + +## Runtime Validation + +When code is generated with `--runtime-validation` (enabled by default), the CLI generates OpenFeature `after` hooks that validate the provider's response at evaluation time. This catches cases where a feature flag provider returns an object that doesn't match the expected schema. + +### What the Hooks Validate + +The generated hooks recursively validate the full schema: + +1. **Object type check**: The resolved value is an object (map/dict/structure), not null, not an array, and not a primitive +2. **Required property check**: Each property listed in `schema.required` exists at every level of nesting +3. **Property type validation**: Each present property's value matches its declared type (`string`, `number`, `integer`, `boolean`) +4. **Nested object validation**: Nested objects are validated recursively with the same checks +5. **Array validation**: Array properties are type-checked, and each element is validated against `schema.items` +6. **Additional properties check**: When `additionalProperties: false`, any unexpected keys cause a validation error + +If validation fails, the hook raises an error. The OpenFeature SDK specification defines that when an `after` hook errors, the SDK should return the default value instead. This means malformed provider responses gracefully fall back to the declared `defaultValue`. + +### Disabling Runtime Validation + +To generate code with compile-time types only (no validation hooks): + +```bash +openfeature generate go --runtime-validation=false +openfeature generate react --runtime-validation=false +``` + +This is useful when: +- You trust the provider always returns correctly shaped objects +- You want to minimize generated code size +- You want to handle validation in your own application logic + +### Per-Language Behavior + +| Language | Compile-Time Types | Runtime Hooks | Notes | +|----------|-------------------|---------------|-------| +| Go | Structs with JSON tags | `After` hook on `UnimplementedHook` | Uses `json.Marshal`/`Unmarshal` round-trip for type conversion. Type name uses `Value` suffix (e.g., `ThemeCustomizationValue`) to avoid conflict with the generated variable. | +| Node.js | TypeScript interfaces | `Hook` with `after` callback | Hooks injected via spread into `FlagEvaluationOptions`. | +| React | TypeScript interfaces | `Hook` with `after` callback | Hooks injected into `useFlag`/`useSuspenseFlag` options. | +| Angular | TypeScript interfaces | None | Compile-time types only. Angular directive/service architecture doesn't support per-evaluation hooks. | +| NestJS | TypeScript interfaces (imported from Node.js file) | None | Compile-time types only. Decorators don't support per-evaluation hooks. Runtime validation is handled by the Node.js generated client. | +| Java | Records with `@Nullable` annotations | `Hook` with `after` method | Uses `ObjectMapper.convertValue()` for type conversion. Nested types are static inner records. | +| C# | Records | `Hook` with `AfterAsync` method | Uses `JsonSerializer.Deserialize` for type conversion. Nested types are nested records. | +| Python | TypedDicts with `Required[]` | `Hook` subclass with `after` method | Hooks injected via `FlagEvaluationOptions`. Nested types are separate TypedDict classes. | + +## Backward Compatibility + +The `schema` field is entirely optional: + +- Manifests without `schema` on object flags continue to work unchanged +- Object flags without `schema` still generate generic types (`any`, `JsonValue`, `Value`, etc.) +- The `--runtime-validation` flag has no effect on flags without schemas + +## Limitations + +### JSON Schema Subset + +The `schema` field supports a **limited subset** of JSON Schema. The following JSON Schema features are **not supported**: + +- `enum` / `const` (string/number constraints) +- `pattern` (regex validation for strings) +- `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` (numeric ranges) +- `minLength` / `maxLength` (string length) +- `minItems` / `maxItems` / `uniqueItems` (array constraints) +- `minProperties` / `maxProperties` (object property count) +- `oneOf` / `anyOf` / `allOf` / `not` (composition) +- `$ref` / `$defs` (schema references, except internally for the manifest JSON Schema itself) +- `format` (e.g., `date-time`, `email`, `uri`) +- `default` (property-level defaults) +- `if` / `then` / `else` (conditional schemas) +- `patternProperties` (regex-based property schemas) +- `nullable` / type arrays (e.g., `"type": ["string", "null"]`) + +The schema format is designed to be forward-compatible with future additions like `enum` support. + +### Go SDK `ObjectValueDetails` Behavior + +The Go SDK's `ObjectValueDetails` method has an inconsistency where the `Value` field in the returned details contains the resolved value (not the default) even when an `after` hook returns an error. This differs from all other typed `*ValueDetails` methods in the Go SDK. This is a known upstream issue and will be fixed in the Go SDK. No workaround is applied in the generated code. + +### Angular and NestJS + +Angular and NestJS generators produce compile-time types only. The Angular `FeatureFlagDirective` and NestJS decorator patterns don't support injecting per-evaluation hooks through the generated code. If you need runtime validation for Angular or NestJS, use the Node.js generated client with runtime validation enabled and consume its output. + +## Example: Generated Code + +Given the `themeCustomization` schema from the example above: + +### Go + +```go +type ThemeCustomizationValue struct { + Header struct { + FontSize int64 `json:"fontSize"` + Visible bool `json:"visible,omitempty"` + } `json:"header,omitempty"` + PrimaryColor string `json:"primaryColor"` + SecondaryColor string `json:"secondaryColor,omitempty"` +} +``` + +### TypeScript (Node.js / React) + +```typescript +export interface ThemeCustomization { + header?: { + fontSize: number; + visible?: boolean; + }; + primaryColor: string; + secondaryColor?: string; +} +``` + +Run the generator for your target language to see the full typed output for Java, C#, and Python. diff --git a/internal/cmd/generate.go b/internal/cmd/generate.go index 925f4867..8905a07c 100644 --- a/internal/cmd/generate.go +++ b/internal/cmd/generate.go @@ -82,13 +82,15 @@ func getGenerateNodeJSCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("Node.js") params := generators.Params[nodejs.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, - Custom: nodejs.Params{}, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, + Custom: nodejs.Params{}, } flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { @@ -128,13 +130,15 @@ func getGenerateReactCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("React") params := generators.Params[react.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, - Custom: react.Params{}, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, + Custom: react.Params{}, } flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { @@ -174,6 +178,7 @@ func GetGenerateNestJsCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("NestJS") @@ -183,9 +188,10 @@ func GetGenerateNestJsCmd() *cobra.Command { } nestjsParams := generators.Params[nestjs.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, - Custom: nestjs.Params{}, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, + Custom: nestjs.Params{}, } nestjsGenerator := nestjs.NewGenerator(flagset) logger.Default.Debug("Executing NestJS generator") @@ -195,8 +201,9 @@ func GetGenerateNestJsCmd() *cobra.Command { } nodejsParams := generators.Params[nodejs.Params]{ - OutputPath: outputPath, - Custom: nodejs.Params{}, + OutputPath: outputPath, + RuntimeValidation: runtimeValidation, + Custom: nodejs.Params{}, } nodeGenerator := nodejs.NewGenerator(flagset) err = nodeGenerator.Generate(&nodejsParams) @@ -231,12 +238,14 @@ func getGenerateCSharpCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("C#") params := generators.Params[csharp.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, Custom: csharp.Params{ Namespace: namespace, }, @@ -283,12 +292,14 @@ func getGenerateJavaCmd() *cobra.Command { javaPackageName := config.GetJavaPackageName(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("Java") params := generators.Params[java.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, Custom: java.Params{ JavaPackage: javaPackageName, }, @@ -336,12 +347,14 @@ func getGenerateGoCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("Go") params := generators.Params[golang.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, Custom: golang.Params{ GoPackage: goPackageName, CLIVersion: Version, @@ -386,13 +399,15 @@ func getGeneratePythonCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("Python") params := generators.Params[python.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, - Custom: python.Params{}, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, + Custom: python.Params{}, } flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { @@ -432,13 +447,15 @@ func getGenerateAngularCmd() *cobra.Command { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) templatePath := config.GetTemplatePath(cmd) + runtimeValidation := config.GetRuntimeValidation(cmd) logger.Default.GenerationStarted("Angular") params := generators.Params[angular.Params]{ - OutputPath: outputPath, - TemplatePath: templatePath, - Custom: angular.Params{}, + OutputPath: outputPath, + TemplatePath: templatePath, + RuntimeValidation: runtimeValidation, + Custom: angular.Params{}, } flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { diff --git a/internal/cmd/generate_test.go b/internal/cmd/generate_test.go index fb88bc9f..b7e7ba64 100644 --- a/internal/cmd/generate_test.go +++ b/internal/cmd/generate_test.go @@ -14,14 +14,15 @@ import ( // generateTestCase holds the configuration for each generate test type generateTestCase struct { - name string // test case name - command string // generator to run - manifestGolden string // path to the golden manifest file - outputGolden string // path to the golden output file - outputPath string // output directory (optional, defaults to "output") - outputFile string // output file name - packageName string // optional, used for Go (package-name), Java (package-name) and C# (namespace) - templateFile string // optional, path to a custom template file + name string // test case name + command string // generator to run + manifestGolden string // path to the golden manifest file + outputGolden string // path to the golden output file + outputPath string // output directory (optional, defaults to "output") + outputFile string // output file name + packageName string // optional, used for Go (package-name), Java (package-name) and C# (namespace) + templateFile string // optional, path to a custom template file + runtimeValidation *bool // optional, defaults to true if nil } func TestGenerate(t *testing.T) { @@ -152,6 +153,66 @@ func TestGenerate(t *testing.T) { outputFile: "openfeature-decorators.ts", templateFile: "testdata/custom_template/custom_nestjs.tmpl", }, + // Schema-typed object flag tests + { + name: "Go generation with schema types", + command: "go", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_go.golden", + outputFile: "testpackage_gen.go", + packageName: "testpackage", + }, + { + name: "NodeJS generation with schema types", + command: "nodejs", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_nodejs.golden", + outputFile: "openfeature.ts", + }, + { + name: "React generation with schema types", + command: "react", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_react.golden", + outputFile: "openfeature.ts", + }, + { + name: "Angular generation with schema types", + command: "angular", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_angular.golden", + outputFile: "openfeature.generated.ts", + }, + { + name: "Python generation with schema types", + command: "python", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_python.golden", + outputFile: "openfeature.py", + }, + { + name: "CSharp generation with schema types", + command: "csharp", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_csharp.golden", + outputFile: "OpenFeature.g.cs", + packageName: "TestNamespace", + }, + { + name: "Java generation with schema types", + command: "java", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_java.golden", + outputFile: "OpenFeature.java", + packageName: "com.example.openfeature", + }, + { + name: "NestJS generation with schema types", + command: "nestjs", + manifestGolden: "testdata/schema_manifest.golden", + outputGolden: "testdata/schema_nestjs.golden", + outputFile: "openfeature-decorators.ts", + }, // Add more test cases here as needed } @@ -204,6 +265,15 @@ func TestGenerate(t *testing.T) { args = append(args, "--template", memoryTemplatePath) } + // Add runtime-validation flag if specified + if tc.runtimeValidation != nil { + if *tc.runtimeValidation { + args = append(args, "--runtime-validation") + } else { + args = append(args, "--runtime-validation=false") + } + } + cmd.SetArgs(args) // Run command @@ -218,6 +288,171 @@ func TestGenerate(t *testing.T) { } } +// TestRuntimeValidationDisabled verifies that when --runtime-validation=false is set, +// generated code with schema-typed object flags includes type definitions but omits +// validation hooks. This ensures invalid provider objects won't be caught at runtime +// (compile-time safety only). +func TestRuntimeValidationDisabled(t *testing.T) { + type runtimeValidationTestCase struct { + name string + command string + outputFile string + packageName string + // hookPatterns are strings that should be present when validation is ENABLED + // and absent when validation is DISABLED + hookPatterns []string + // typePatterns are strings that should be present regardless of validation setting + typePatterns []string + } + + testCases := []runtimeValidationTestCase{ + { + name: "Go: hooks omitted when runtime-validation=false", + command: "go", + outputFile: "testpackage_gen.go", + packageName: "testpackage", + hookPatterns: []string{ + "themeCustomizationHook", + "UnimplementedHook", + "openfeature.WithHooks", + }, + typePatterns: []string{ + "ThemeCustomizationValue", + "PrimaryColor", + }, + }, + { + name: "Node.js: hooks omitted when runtime-validation=false", + command: "nodejs", + outputFile: "openfeature.ts", + hookPatterns: []string{ + "createThemeCustomizationHook", + "HookContext", + }, + typePatterns: []string{ + "interface ThemeCustomization", + "primaryColor: string", + }, + }, + { + name: "React: hooks omitted when runtime-validation=false", + command: "react", + outputFile: "openfeature.ts", + hookPatterns: []string{ + "createThemeCustomizationHook", + "HookContext", + }, + typePatterns: []string{ + "interface ThemeCustomization", + "primaryColor: string", + }, + }, + { + name: "Python: hooks omitted when runtime-validation=false", + command: "python", + outputFile: "openfeature.py", + hookPatterns: []string{ + "ThemeCustomizationHook", + "class ThemeCustomizationHook(Hook)", + }, + typePatterns: []string{ + "class ThemeCustomization(TypedDict", + "primaryColor: Required[str]", + }, + }, + { + name: "C#: hooks omitted when runtime-validation=false", + command: "csharp", + outputFile: "OpenFeature.g.cs", + packageName: "TestNamespace", + hookPatterns: []string{ + "ThemeCustomizationHook", + "class ThemeCustomizationHook : Hook", + }, + typePatterns: []string{ + "record ThemeCustomization(", + "string PrimaryColor", + }, + }, + { + name: "Java: hooks omitted when runtime-validation=false", + command: "java", + outputFile: "OpenFeature.java", + packageName: "com.example.openfeature", + hookPatterns: []string{ + "ThemeCustomizationHook", + "class ThemeCustomizationHook implements Hook", + }, + typePatterns: []string{ + "record ThemeCustomization(", + "String primaryColor", + }, + }, + } + + runtimeValidationFalse := false + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := GetGenerateCmd() + config.AddRootFlags(cmd) + + const memoryManifestPath = "manifest/path.json" + outputPath := "output" + + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + readOsFileAndWriteToMemMap(t, "testdata/schema_manifest.golden", memoryManifestPath, fs) + + args := []string{ + tc.command, + "--manifest", memoryManifestPath, + "--output", outputPath, + "--runtime-validation=false", + } + + if tc.packageName != "" { + switch tc.command { + case "csharp": + args = append(args, "--namespace", tc.packageName) + case "go": + args = append(args, "--package-name", tc.packageName) + case "java": + args = append(args, "--package-name", tc.packageName) + } + } + + cmd.SetArgs(args) + err := cmd.Execute() + if err != nil { + t.Fatalf("generation failed: %v", err) + } + + got, err := afero.ReadFile(fs, filepath.Join(outputPath, tc.outputFile)) + if err != nil { + t.Fatalf("error reading output file: %v", err) + } + output := string(got) + + // Type definitions should still be present + for _, pattern := range tc.typePatterns { + if !strings.Contains(output, pattern) { + t.Errorf("expected type pattern %q to be present in output (types should exist even without runtime validation)", pattern) + } + } + + // Hook patterns should be absent + for _, pattern := range tc.hookPatterns { + if strings.Contains(output, pattern) { + t.Errorf("expected hook pattern %q to be absent in output when runtime-validation=false", pattern) + } + } + + _ = runtimeValidationFalse // referenced for documentation + }) + } +} + func readOsFileAndWriteToMemMap(t *testing.T, inputPath string, memPath string, memFs afero.Fs) { data, err := os.ReadFile(inputPath) if err != nil { diff --git a/internal/cmd/testdata/schema_angular.golden b/internal/cmd/testdata/schema_angular.golden new file mode 100644 index 00000000..006f55ba --- /dev/null +++ b/internal/cmd/testdata/schema_angular.golden @@ -0,0 +1,693 @@ +/** + * AUTOMATICALLY GENERATED BY OPENFEATURE CLI. DO NOT MODIFY MANUALLY. + * + * This file contains generated typesafe Angular services and directives + * for feature flags defined in your OpenFeature flag manifest. + * + * Requires @openfeature/angular-sdk >= 1.1.0. + * + * @see https://openfeature.dev/docs/reference/other-technologies/cli + */ + +import { + ChangeDetectorRef, + Directive, + inject, + Injectable, + Input, + OnChanges, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { + AngularFlagEvaluationOptions, + EvaluationDetails, + FeatureFlagDirective, + FeatureFlagDirectiveContext, + FeatureFlagService, + JsonValue, +} from '@openfeature/angular-sdk'; +import { Observable, map } from 'rxjs'; + +// ============================================================================ +// TYPED OBJECT FLAG INTERFACES +// ============================================================================ + + +export interface ThemeCustomization { + header?: { + fontSize: number; + visible?: boolean; +}; + primaryColor: string; + secondaryColor?: string; + tags?: string[]; +} + + +// ============================================================================ +// FLAG KEYS +// ============================================================================ + +/** + * Constant object containing all feature flag keys. + * Use these constants to reference flag keys in a type-safe manner. + */ +export const FlagKeys = { + /** + * Flag key for Controls whether Feature A is enabled.. + * - Type: `boolean` + * - Default: `false` + */ + ENABLE_FEATURE_A: "enableFeatureA", + /** + * Flag key for The message to use for greeting users.. + * - Type: `string` + * - Default: `Hello there!` + */ + GREETING_MESSAGE: "greetingMessage", + /** + * Flag key for Allows customization of theme colors.. + * - Type: `object` + * - Default: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` + */ + THEME_CUSTOMIZATION: "themeCustomization", +} as const; + +/** + * Type representing all available flag keys. + */ +export type FlagKey = (typeof FlagKeys)[keyof typeof FlagKeys]; + +// ============================================================================ +// GENERATED FEATURE FLAG SERVICE +// ============================================================================ + +/** + * Generated typesafe feature flag service. + * Provides strongly-typed methods for each feature flag defined in the manifest. + * + * @example + * ```typescript + * @Component({ + * selector: 'app-my-component', + * template: ` + *
Feature enabled!
+ * ` + * }) + * export class MyComponent { + * private flags = inject(GeneratedFeatureFlagService); + * myFlag$ = this.flags.getMyFlagDetails(); + * } + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class GeneratedFeatureFlagService { + private readonly flagService = inject(FeatureFlagService); + + + /** + * Get evaluation details for the `enableFeatureA` flag. + * + * Controls whether Feature A is enabled. + * + * **Details:** + * - Flag key: `enableFeatureA` + * - Type: `boolean` + * - Default value: `false` + * + * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). + * @param options - Optional configuration for the flag evaluation. + * @returns An Observable that emits EvaluationDetails whenever the flag value changes. + */ + getEnableFeatureADetails( + domain?: string, + options?: AngularFlagEvaluationOptions + ): Observable> { + return this.flagService.getBooleanDetails( + "enableFeatureA", + false, + domain, + { + updateOnConfigurationChanged: options?.updateOnConfigurationChanged ?? true, + updateOnContextChanged: options?.updateOnContextChanged ?? true, + } + ); + } + + /** + * Get the value of the `enableFeatureA` flag. + * + * Controls whether Feature A is enabled. + * + * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). + * @param options - Optional configuration for the flag evaluation. + * @returns An Observable that emits the flag value whenever it changes. + */ + getEnableFeatureA( + domain?: string, + options?: AngularFlagEvaluationOptions + ): Observable { + return this.getEnableFeatureADetails(domain, options).pipe( + map((details) => details.value) + ); + } + + /** + * Get evaluation details for the `greetingMessage` flag. + * + * The message to use for greeting users. + * + * **Details:** + * - Flag key: `greetingMessage` + * - Type: `string` + * - Default value: `Hello there!` + * + * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). + * @param options - Optional configuration for the flag evaluation. + * @returns An Observable that emits EvaluationDetails whenever the flag value changes. + */ + getGreetingMessageDetails( + domain?: string, + options?: AngularFlagEvaluationOptions + ): Observable> { + return this.flagService.getStringDetails( + "greetingMessage", + "Hello there!", + domain, + { + updateOnConfigurationChanged: options?.updateOnConfigurationChanged ?? true, + updateOnContextChanged: options?.updateOnContextChanged ?? true, + } + ); + } + + /** + * Get the value of the `greetingMessage` flag. + * + * The message to use for greeting users. + * + * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). + * @param options - Optional configuration for the flag evaluation. + * @returns An Observable that emits the flag value whenever it changes. + */ + getGreetingMessage( + domain?: string, + options?: AngularFlagEvaluationOptions + ): Observable { + return this.getGreetingMessageDetails(domain, options).pipe( + map((details) => details.value) + ); + } + + /** + * Get evaluation details for the `themeCustomization` flag. + * + * Allows customization of theme colors. + * + * **Details:** + * - Flag key: `themeCustomization` + * - Type: `ThemeCustomization` + * - Default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` + * + * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). + * @param options - Optional configuration for the flag evaluation. + * @returns An Observable that emits EvaluationDetails whenever the flag value changes. + */ + getThemeCustomizationDetails( + domain?: string, + options?: AngularFlagEvaluationOptions + ): Observable> { + return this.flagService.getObjectDetails( + "themeCustomization", + {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}, + domain, + { + updateOnConfigurationChanged: options?.updateOnConfigurationChanged ?? true, + updateOnContextChanged: options?.updateOnContextChanged ?? true, + } + ) as unknown as Observable>; + } + + /** + * Get the value of the `themeCustomization` flag. + * + * Allows customization of theme colors. + * + * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). + * @param options - Optional configuration for the flag evaluation. + * @returns An Observable that emits the flag value whenever it changes. + */ + getThemeCustomization( + domain?: string, + options?: AngularFlagEvaluationOptions + ): Observable { + return this.getThemeCustomizationDetails(domain, options).pipe( + map((details) => details.value) + ); + } + +} + +// ============================================================================ +// GENERATED STRUCTURAL DIRECTIVES +// ============================================================================ + + + +/** + * Structural directive for the `enableFeatureA` feature flag. + * + * Controls whether Feature A is enabled. + * + * This directive extends `FeatureFlagDirective` from @openfeature/angular-sdk + * with a pre-configured flag key and default value. + * + * **Details:** + * - Flag key: `enableFeatureA` + * - Type: `boolean` + * - Default value: `false` + * + * + * @example + * Explicit `ng-template` (no `*`), bind inputs directly + * ```html + * + *
Content shown when flag is enabled.
+ *
+ * + * Content shown when flag is disabled. + * + * ``` + * + * @example + * Microsyntax `*` form, start with `let` + * ```html + *
+ * Content shown when flag is enabled. + *
+ * ``` + * + * @example + * Simple `*` usage (no else/initializing/reconciling) + * ```html + *
+ * Content shown when flag is enabled. + *
+ * ``` + * + * @remarks + * Note: Angular's microsyntax parser requires the first segment to be a primary + * expression or a `let` declaration. Because the flag key is preconfigured, start with + * `let v` or `let details = evaluationDetails` (or `let _` if you do not need the value) + * before any `else`/`initializing`/`reconciling`/`default` segments. + * This is an Angular microsyntax parsing constraint, not a directive limitation. + * `*enableFeatureAFeatureFlag="else elseTemplate"` will not parse because `else` is a + * secondary segment. If you do not need the value, use `let _`, or use the + * explicit `ng-template` form above. + */ +@Directive({ + selector: '[enableFeatureAFeatureFlag]', + standalone: true, +}) +export class EnableFeatureAFeatureFlagDirective extends FeatureFlagDirective implements OnChanges { + override _changeDetectorRef = inject(ChangeDetectorRef); + override _viewContainerRef = inject(ViewContainerRef); + override _thenTemplateRef = inject>>(TemplateRef); + + constructor() { + super(); + + this._featureFlagKey = "enableFeatureA"; + this._featureFlagDefault = false; + + this._featureFlagValue = true; + + } + + /** + * The domain of the boolean feature flag. + */ + @Input({ required: false }) + set enableFeatureAFeatureFlagDomain(domain: string | undefined) { + super.featureFlagDomain = domain; + } + + /** + * Update the component if the provider emits a ConfigurationChanged event. + * Set to false to prevent components from re-rendering when flag value changes + * are received by the associated provider. + * Defaults to true. + */ + @Input({ required: false }) + set enableFeatureAFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) { + this._updateOnConfigurationChanged = enabled ?? true; + } + + /** + * Update the component when the OpenFeature context changes. + * Set to false to prevent components from re-rendering when attributes which + * may be factors in flag evaluation change. + * Defaults to true. + */ + @Input({ required: false }) + set enableFeatureAFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) { + this._updateOnContextChanged = enabled ?? true; + } + + /** + * Template to be displayed when the feature flag is false. + */ + @Input() + set enableFeatureAFeatureFlagElse(tpl: TemplateRef>) { + this._elseTemplateRef = tpl; + } + + /** + * Template to be displayed when the provider is not ready. + */ + @Input() + set enableFeatureAFeatureFlagInitializing(tpl: TemplateRef>) { + this._initializingTemplateRef = tpl; + } + + /** + * Template to be displayed when the provider is reconciling. + */ + @Input() + set enableFeatureAFeatureFlagReconciling(tpl: TemplateRef>) { + this._reconcilingTemplateRef = tpl; + } +} + + + +/** + * Structural directive for the `greetingMessage` feature flag. + * + * The message to use for greeting users. + * + * This directive extends `FeatureFlagDirective` from @openfeature/angular-sdk + * with a pre-configured flag key and default value. + * + * **Details:** + * - Flag key: `greetingMessage` + * - Type: `string` + * - Default value: `Hello there!` + * + * + * @example + * Explicit `ng-template` (no `*`), bind inputs directly + * ```html + * + *
Content shown when flag is matched.
+ *
+ * + * Content shown when flag is not matched. + * + * ``` + * + * @example + * Microsyntax `*` form, start with `let` + * ```html + *
+ * Content shown when flag is matched. + *
+ * ``` + * + * @example + * Simple `*` usage (no else/initializing/reconciling) + * ```html + *
+ * Content shown when flag is matched. + *
+ * ``` + * + * @remarks + * Note: Angular's microsyntax parser requires the first segment to be a primary + * expression or a `let` declaration. Because the flag key is preconfigured, start with + * `let v` or `let details = evaluationDetails` (or `let _` if you do not need the value) + * before any `else`/`initializing`/`reconciling`/`default` segments. + * This is an Angular microsyntax parsing constraint, not a directive limitation. + * `*greetingMessageFeatureFlag="else elseTemplate"` will not parse because `else` is a + * secondary segment. If you do not need the value, use `let _`, or use the + * explicit `ng-template` form above. + */ +@Directive({ + selector: '[greetingMessageFeatureFlag]', + standalone: true, +}) +export class GreetingMessageFeatureFlagDirective extends FeatureFlagDirective implements OnChanges { + override _changeDetectorRef = inject(ChangeDetectorRef); + override _viewContainerRef = inject(ViewContainerRef); + override _thenTemplateRef = inject>>(TemplateRef); + + /** + * The expected value of this string feature flag, for which the `then` template should be rendered. + */ + @Input({ required: false }) greetingMessageFeatureFlagValue?: string; + + constructor() { + super(); + + this._featureFlagKey = "greetingMessage"; + this._featureFlagDefault = "Hello there!"; + + } + + override ngOnChanges() { + super.ngOnChanges(); + + this._featureFlagValue = this.greetingMessageFeatureFlagValue; + } + + /** + * The domain of the string feature flag. + */ + @Input({ required: false }) + set greetingMessageFeatureFlagDomain(domain: string | undefined) { + super.featureFlagDomain = domain; + } + + /** + * Update the component if the provider emits a ConfigurationChanged event. + * Set to false to prevent components from re-rendering when flag value changes + * are received by the associated provider. + * Defaults to true. + */ + @Input({ required: false }) + set greetingMessageFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) { + this._updateOnConfigurationChanged = enabled ?? true; + } + + /** + * Update the component when the OpenFeature context changes. + * Set to false to prevent components from re-rendering when attributes which + * may be factors in flag evaluation change. + * Defaults to true. + */ + @Input({ required: false }) + set greetingMessageFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) { + this._updateOnContextChanged = enabled ?? true; + } + + /** + * Template to be displayed when the feature flag does not match value. + */ + @Input() + set greetingMessageFeatureFlagElse(tpl: TemplateRef>) { + this._elseTemplateRef = tpl; + } + + /** + * Template to be displayed when the provider is not ready. + */ + @Input() + set greetingMessageFeatureFlagInitializing(tpl: TemplateRef>) { + this._initializingTemplateRef = tpl; + } + + /** + * Template to be displayed when the provider is reconciling. + */ + @Input() + set greetingMessageFeatureFlagReconciling(tpl: TemplateRef>) { + this._reconcilingTemplateRef = tpl; + } +} + + + +/** + * Structural directive for the `themeCustomization` feature flag. + * + * Allows customization of theme colors. + * + * This directive extends `FeatureFlagDirective` from @openfeature/angular-sdk + * with a pre-configured flag key and default value. + * + * **Details:** + * - Flag key: `themeCustomization` + * - Type: `ThemeCustomization` + * - Default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` + * + * + * @example + * Explicit `ng-template` (no `*`), bind inputs directly + * ```html + * + *
Content shown when flag is matched.
+ *
+ * + * Content shown when flag is not matched. + * + * ``` + * + * @example + * Microsyntax `*` form, start with `let` + * ```html + *
+ * Content shown when flag is matched. + *
+ * ``` + * + * @example + * Simple `*` usage (no else/initializing/reconciling) + * ```html + *
+ * Content shown when flag is matched. + *
+ * ``` + * + * @remarks + * Note: Angular's microsyntax parser requires the first segment to be a primary + * expression or a `let` declaration. Because the flag key is preconfigured, start with + * `let v` or `let details = evaluationDetails` (or `let _` if you do not need the value) + * before any `else`/`initializing`/`reconciling`/`default` segments. + * This is an Angular microsyntax parsing constraint, not a directive limitation. + * `*themeCustomizationFeatureFlag="else elseTemplate"` will not parse because `else` is a + * secondary segment. If you do not need the value, use `let _`, or use the + * explicit `ng-template` form above. + */ +@Directive({ + selector: '[themeCustomizationFeatureFlag]', + standalone: true, +}) +export class ThemeCustomizationFeatureFlagDirective extends FeatureFlagDirective implements OnChanges { + override _changeDetectorRef = inject(ChangeDetectorRef); + override _viewContainerRef = inject(ViewContainerRef); + override _thenTemplateRef = inject>>(TemplateRef); + + /** + * The expected value of this object feature flag, for which the `then` template should be rendered. + */ + @Input({ required: false }) themeCustomizationFeatureFlagValue?: ThemeCustomization; + + constructor() { + super(); + + this._featureFlagKey = "themeCustomization"; + this._featureFlagDefault = {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}; + + } + + override ngOnChanges() { + super.ngOnChanges(); + + this._featureFlagValue = this.themeCustomizationFeatureFlagValue; + } + + /** + * The domain of the object feature flag. + */ + @Input({ required: false }) + set themeCustomizationFeatureFlagDomain(domain: string | undefined) { + super.featureFlagDomain = domain; + } + + /** + * Update the component if the provider emits a ConfigurationChanged event. + * Set to false to prevent components from re-rendering when flag value changes + * are received by the associated provider. + * Defaults to true. + */ + @Input({ required: false }) + set themeCustomizationFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) { + this._updateOnConfigurationChanged = enabled ?? true; + } + + /** + * Update the component when the OpenFeature context changes. + * Set to false to prevent components from re-rendering when attributes which + * may be factors in flag evaluation change. + * Defaults to true. + */ + @Input({ required: false }) + set themeCustomizationFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) { + this._updateOnContextChanged = enabled ?? true; + } + + /** + * Template to be displayed when the feature flag does not match value. + */ + @Input() + set themeCustomizationFeatureFlagElse(tpl: TemplateRef>) { + this._elseTemplateRef = tpl; + } + + /** + * Template to be displayed when the provider is not ready. + */ + @Input() + set themeCustomizationFeatureFlagInitializing(tpl: TemplateRef>) { + this._initializingTemplateRef = tpl; + } + + /** + * Template to be displayed when the provider is reconciling. + */ + @Input() + set themeCustomizationFeatureFlagReconciling(tpl: TemplateRef>) { + this._reconcilingTemplateRef = tpl; + } +} + + + +// ============================================================================ +// EXPORTS +// ============================================================================ + +/** + * Array of all generated feature flag directives. + * Import this in your module or standalone component to use the directives. + * + * @example + * ```typescript + * @Component({ + * standalone: true, + * imports: [GeneratedFeatureFlagDirectives], + * template: `
...
` + * }) + * export class MyComponent {} + * ``` + */ +export const GeneratedFeatureFlagDirectives = [ + EnableFeatureAFeatureFlagDirective, + GreetingMessageFeatureFlagDirective, + ThemeCustomizationFeatureFlagDirective, +] as const; diff --git a/internal/cmd/testdata/schema_csharp.golden b/internal/cmd/testdata/schema_csharp.golden new file mode 100644 index 00000000..35baca32 --- /dev/null +++ b/internal/cmd/testdata/schema_csharp.golden @@ -0,0 +1,335 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +#nullable enable +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.Model; +using OpenFeature.Extension; + +namespace TestNamespace +{ + + /// + /// Typed object for the "themeCustomization" flag. + /// + public record ThemeCustomizationHeader( + int FontSize, + bool? Visible + ); + + /// + /// Typed object for the "themeCustomization" flag. + /// + public record ThemeCustomization( + ThemeCustomizationHeader? Header, + string PrimaryColor, + string? SecondaryColor, + List? Tags + ); + + + /// + /// Validation hook for the "themeCustomization" flag. + /// + public class ThemeCustomizationHook : Hook + { + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null) + { + return new ValueTask(EvaluationContext.Empty); + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null) + { + if (details.Value is not Value value) + { + throw new InvalidOperationException("themeCustomization: expected Value type"); + } + var themeCustomizationStruct = value.AsStructure; + if (themeCustomizationStruct == null) + { + throw new InvalidOperationException("themeCustomization: expected object structure"); + } + if (!themeCustomizationStruct.ContainsKey("primaryColor")) + { + throw new InvalidOperationException("themeCustomization: missing required property 'primaryColor'"); + } + if (themeCustomizationStruct.ContainsKey("header")) + { + var themeCustomization_headerVal = themeCustomizationStruct["header"]; + var themeCustomization_headerStruct = themeCustomization_headerVal.AsStructure; + if (themeCustomization_headerStruct == null) + { + throw new InvalidOperationException("themeCustomization.header: expected object structure"); + } + if (!themeCustomization_headerStruct.ContainsKey("fontSize")) + { + throw new InvalidOperationException("themeCustomization.header: missing required property 'fontSize'"); + } + if (themeCustomization_headerStruct.ContainsKey("fontSize")) + { + var themeCustomization_header_fontSizeVal = themeCustomization_headerStruct["fontSize"]; + if (themeCustomization_header_fontSizeVal.AsInteger == null) + { + throw new InvalidOperationException("themeCustomization.header.fontSize: expected integer"); + } + } + if (themeCustomization_headerStruct.ContainsKey("visible")) + { + var themeCustomization_header_visibleVal = themeCustomization_headerStruct["visible"]; + if (themeCustomization_header_visibleVal.AsBoolean == null) + { + throw new InvalidOperationException("themeCustomization.header.visible: expected boolean"); + } + } + var themeCustomization_headerAllowed = new HashSet { "fontSize", "visible" }; + foreach (var key in themeCustomization_headerStruct.Keys) + { + if (!themeCustomization_headerAllowed.Contains(key)) + { + throw new InvalidOperationException($"themeCustomization.header: unexpected property '{key}'"); + } + } + } + if (themeCustomizationStruct.ContainsKey("primaryColor")) + { + var themeCustomization_primaryColorVal = themeCustomizationStruct["primaryColor"]; + if (themeCustomization_primaryColorVal.AsString == null) + { + throw new InvalidOperationException("themeCustomization.primaryColor: expected string"); + } + } + if (themeCustomizationStruct.ContainsKey("secondaryColor")) + { + var themeCustomization_secondaryColorVal = themeCustomizationStruct["secondaryColor"]; + if (themeCustomization_secondaryColorVal.AsString == null) + { + throw new InvalidOperationException("themeCustomization.secondaryColor: expected string"); + } + } + if (themeCustomizationStruct.ContainsKey("tags")) + { + var themeCustomization_tagsVal = themeCustomizationStruct["tags"]; + var themeCustomization_tagsList = themeCustomization_tagsVal.AsList; + if (themeCustomization_tagsList == null) + { + throw new InvalidOperationException("themeCustomization.tags: expected array"); + } + for (var themeCustomization_tagsIdx = 0; themeCustomization_tagsIdx < themeCustomization_tagsList.Count; themeCustomization_tagsIdx++) + { + if (themeCustomization_tagsList[themeCustomization_tagsIdx].AsString == null) + { + throw new InvalidOperationException($"themeCustomization.tags[{themeCustomization_tagsIdx}]: expected string"); + } + } + } + return new ValueTask(); + } + } + + /// + /// Flag key constants for programmatic access + /// + public static class FlagKeys + { + /// Flag key for Controls whether Feature A is enabled. + public const string ENABLE_FEATURE_A = "enableFeatureA"; + /// Flag key for The message to use for greeting users. + public const string GREETING_MESSAGE = "greetingMessage"; + /// Flag key for Allows customization of theme colors. + public const string THEME_CUSTOMIZATION = "themeCustomization"; + } + + /// + /// Service collection extensions for OpenFeature + /// + public static class OpenFeatureServiceExtensions + { + /// + /// Adds OpenFeature services to the service collection with the generated client + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient()) + .AddSingleton(); + } + + /// + /// Adds OpenFeature services to the service collection with the generated client for a specific domain + /// + /// The service collection to add services to + /// The domain to get the client for + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services, string domain) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient(domain)) + .AddSingleton(); + } + } + + /// + /// Generated OpenFeature client for typesafe flag access + /// + public class GeneratedClient + { + private readonly IFeatureClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The OpenFeature client to use for flag evaluations. + public GeneratedClient(IFeatureClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task EnableFeatureAAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanValueAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> EnableFeatureADetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanDetailsAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task GreetingMessageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> GreetingMessageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// Allows customization of theme colors. + /// + /// + /// Flag key: themeCustomization + /// Default value: new Value(Structure.Builder().Set("header", new Value(Structure.Builder().Set("fontSize", 16).Set("visible", true).Build())).Set("primaryColor", "#007bff").Set("secondaryColor", "#6c757d").Set("tags", new Value(new List{"light"})).Build()) + /// Type: object + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task ThemeCustomizationAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + var hookOptions = options ?? new FlagEvaluationOptions(new ThemeCustomizationHook()); + if (options != null) + { + var hooks = new List(options.HookList) { new ThemeCustomizationHook() }; + hookOptions = new FlagEvaluationOptions(hooks.ToArray()); + } + var value = await _client.GetObjectValueAsync("themeCustomization", new Value(Structure.Builder().Set("header", new Value(Structure.Builder().Set("fontSize", 16).Set("visible", true).Build())).Set("primaryColor", "#007bff").Set("secondaryColor", "#6c757d").Set("tags", new Value(new List{"light"})).Build()), evaluationContext, hookOptions); + var json = JsonSerializer.Serialize(value.AsStructure); + return JsonSerializer.Deserialize(json)!; + } + + /// + /// Allows customization of theme colors. + /// + /// + /// Flag key: themeCustomization + /// Default value: new Value(Structure.Builder().Set("header", new Value(Structure.Builder().Set("fontSize", 16).Set("visible", true).Build())).Set("primaryColor", "#007bff").Set("secondaryColor", "#6c757d").Set("tags", new Value(new List{"light"})).Build()) + /// Type: object + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> ThemeCustomizationDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + var hookOptions = options ?? new FlagEvaluationOptions(new ThemeCustomizationHook()); + if (options != null) + { + var hooks = new List(options.HookList) { new ThemeCustomizationHook() }; + hookOptions = new FlagEvaluationOptions(hooks.ToArray()); + } + return await _client.GetObjectDetailsAsync("themeCustomization", new Value(Structure.Builder().Set("header", new Value(Structure.Builder().Set("fontSize", 16).Set("visible", true).Build())).Set("primaryColor", "#007bff").Set("secondaryColor", "#6c757d").Set("tags", new Value(new List{"light"})).Build()), evaluationContext, hookOptions); + } + + + /// + /// Creates a new GeneratedClient using the default OpenFeature client + /// + /// A new GeneratedClient instance + public static GeneratedClient CreateClient() + { + return new GeneratedClient(Api.Instance.GetClient()); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client + /// + /// The domain to get the client for + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context + /// + /// The domain to get the client for + /// Default context to use for evaluations + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + } +} \ No newline at end of file diff --git a/internal/cmd/testdata/schema_go.golden b/internal/cmd/testdata/schema_go.golden new file mode 100644 index 00000000..f16c80ac --- /dev/null +++ b/internal/cmd/testdata/schema_go.golden @@ -0,0 +1,178 @@ +// Code generated by OpenFeature CLI. DO NOT EDIT. +// CLI version: dev + +// Package testpackage contains generated code produced by the OpenFeature CLI. +package testpackage + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/open-feature/go-sdk/openfeature" +) + +// stringer transforms a string to a Stringer +type stringer string + +// String implements the fmt.Stringer interface +func (s stringer) String() string { + return string(s) +} + +type ( + evaluationValue[T any] func(context.Context, openfeature.EvaluationContext) T + evaluationDetails[T any] func(context.Context, openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[T], error) +) + +var client = openfeature.NewDefaultClient() + +// ThemeCustomizationValue is the typed object for the "themeCustomization" flag. +type ThemeCustomizationValue struct { + Header struct { + FontSize int64 `json:"fontSize"` + Visible bool `json:"visible,omitempty"` + } `json:"header,omitempty"` + PrimaryColor string `json:"primaryColor"` + SecondaryColor string `json:"secondaryColor,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type themeCustomizationHook struct { + openfeature.UnimplementedHook +} + +func (h themeCustomizationHook) After(ctx context.Context, hookCtx openfeature.HookContext, details openfeature.InterfaceEvaluationDetails, hints openfeature.HookHints) error { + themeCustomizationMap, ok := details.Value.(map[string]any) + if !ok { + return fmt.Errorf("themeCustomization: expected object, got %T", details.Value) + } + if _, exists := themeCustomizationMap["primaryColor"]; !exists { + return fmt.Errorf("themeCustomization: missing required property %q", "primaryColor") + } + if themeCustomization_header, exists := themeCustomizationMap["header"]; exists { + themeCustomization_headerMap, ok := themeCustomization_header.(map[string]any) + if !ok { + return fmt.Errorf("themeCustomization.header: expected object, got %T", themeCustomization_header) + } + if _, exists := themeCustomization_headerMap["fontSize"]; !exists { + return fmt.Errorf("themeCustomization.header: missing required property %q", "fontSize") + } + if themeCustomization_header_fontSize, exists := themeCustomization_headerMap["fontSize"]; exists { + themeCustomization_header_fontSizeFloat, ok := themeCustomization_header_fontSize.(float64) + if !ok { + return fmt.Errorf("themeCustomization.header.fontSize: expected integer, got %T", themeCustomization_header_fontSize) + } + if themeCustomization_header_fontSizeFloat != float64(int64(themeCustomization_header_fontSizeFloat)) { + return fmt.Errorf("themeCustomization.header.fontSize: expected integer, got float") + } + } + if themeCustomization_header_visible, exists := themeCustomization_headerMap["visible"]; exists { + if _, ok := themeCustomization_header_visible.(bool); !ok { + return fmt.Errorf("themeCustomization.header.visible: expected boolean, got %T", themeCustomization_header_visible) + } + } + themeCustomization_headerAllowed := map[string]bool{"fontSize": true, "visible": true} + for k := range themeCustomization_headerMap { + if !themeCustomization_headerAllowed[k] { + return fmt.Errorf("themeCustomization.header: unexpected property %q", k) + } + } + } + if themeCustomization_primaryColor, exists := themeCustomizationMap["primaryColor"]; exists { + if _, ok := themeCustomization_primaryColor.(string); !ok { + return fmt.Errorf("themeCustomization.primaryColor: expected string, got %T", themeCustomization_primaryColor) + } + } + if themeCustomization_secondaryColor, exists := themeCustomizationMap["secondaryColor"]; exists { + if _, ok := themeCustomization_secondaryColor.(string); !ok { + return fmt.Errorf("themeCustomization.secondaryColor: expected string, got %T", themeCustomization_secondaryColor) + } + } + if themeCustomization_tags, exists := themeCustomizationMap["tags"]; exists { + themeCustomization_tagsArr, ok := themeCustomization_tags.([]any) + if !ok { + return fmt.Errorf("themeCustomization.tags: expected array, got %T", themeCustomization_tags) + } + for themeCustomization_tagsIdx, themeCustomization_tagsItem := range themeCustomization_tagsArr { + if _, ok := themeCustomization_tagsItem.(string); !ok { + return fmt.Errorf("themeCustomization.tags[%d]: expected string, got %T", themeCustomization_tagsIdx, themeCustomization_tagsItem) + } + } + } + return nil +} + +// EnableFeatureA returns the value of the "enableFeatureA" feature flag. +// Controls whether Feature A is enabled. +// +// The flag is a type of boolean and defaults to false. +var EnableFeatureA = struct { + fmt.Stringer + // Value returns the value of the [EnableFeatureA] flag. + Value evaluationValue[bool] + + // ValueWithDetails returns the evaluation details of the [EnableFeatureA] flag + // and the evaluation error, if any. + ValueWithDetails evaluationDetails[bool] +}{ + Stringer: stringer("enableFeatureA"), + Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) bool { + return client.Boolean(ctx, "enableFeatureA", false, evalCtx) + }, + ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[bool], error) { + return client.BooleanValueDetails(ctx, "enableFeatureA", false, evalCtx) + }, +} + +// GreetingMessage returns the value of the "greetingMessage" feature flag. +// The message to use for greeting users. +// +// The flag is a type of string and defaults to Hello there!. +var GreetingMessage = struct { + fmt.Stringer + // Value returns the value of the [GreetingMessage] flag. + Value evaluationValue[string] + + // ValueWithDetails returns the evaluation details of the [GreetingMessage] flag + // and the evaluation error, if any. + ValueWithDetails evaluationDetails[string] +}{ + Stringer: stringer("greetingMessage"), + Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) string { + return client.String(ctx, "greetingMessage", "Hello there!", evalCtx) + }, + ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[string], error) { + return client.StringValueDetails(ctx, "greetingMessage", "Hello there!", evalCtx) + }, +} + +// ThemeCustomization returns the value of the "themeCustomization" feature flag. +// Allows customization of theme colors. +// +// The flag is a type of object and defaults to map[header:map[fontSize:16 visible:true] primaryColor:#007bff secondaryColor:#6c757d tags:[light]]. +var ThemeCustomization = struct { + fmt.Stringer + // Value returns the value of the [ThemeCustomization] flag. + Value evaluationValue[ThemeCustomizationValue] + + // ValueWithDetails returns the evaluation details of the [ThemeCustomization] flag + // and the evaluation error, if any. + ValueWithDetails evaluationDetails[ThemeCustomizationValue] +}{ + Stringer: stringer("themeCustomization"), + Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) ThemeCustomizationValue { + raw := client.Object(ctx, "themeCustomization", map[string]any{"header": map[string]any{"fontSize": 16, "visible": true}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": []any{"light"}}, evalCtx, openfeature.WithHooks(themeCustomizationHook{})) + var result ThemeCustomizationValue + b, _ := json.Marshal(raw) + json.Unmarshal(b, &result) + return result + }, + ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[ThemeCustomizationValue], error) { + details, err := client.ObjectValueDetails(ctx, "themeCustomization", map[string]any{"header": map[string]any{"fontSize": 16, "visible": true}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": []any{"light"}}, evalCtx, openfeature.WithHooks(themeCustomizationHook{})) + var result ThemeCustomizationValue + b, _ := json.Marshal(details.Value) + json.Unmarshal(b, &result) + return openfeature.GenericEvaluationDetails[ThemeCustomizationValue]{Value: result, EvaluationDetails: details.EvaluationDetails}, err + }, +} diff --git a/internal/cmd/testdata/schema_java.golden b/internal/cmd/testdata/schema_java.golden new file mode 100644 index 00000000..e2e6f704 --- /dev/null +++ b/internal/cmd/testdata/schema_java.golden @@ -0,0 +1,230 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +package com.example.openfeature; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.FlagEvaluationOptions; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nullable; + +public final class OpenFeature { + + private OpenFeature() {} // prevent instantiation + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Flag key constants for programmatic access + */ + public static final class FlagKeys { + private FlagKeys() {} // prevent instantiation + + /** Flag key for Controls whether Feature A is enabled. */ + public static final String ENABLE_FEATURE_A = "enableFeatureA"; + /** Flag key for The message to use for greeting users. */ + public static final String GREETING_MESSAGE = "greetingMessage"; + /** Flag key for Allows customization of theme colors. */ + public static final String THEME_CUSTOMIZATION = "themeCustomization"; + } + + public record ThemeCustomizationHeader( + Integer fontSize, + @Nullable Boolean visible + ) {} + + public record ThemeCustomization( + @Nullable ThemeCustomizationHeader header, + String primaryColor, + @Nullable String secondaryColor, + @Nullable List tags + ) {} + + static class ThemeCustomizationHook implements Hook { + @Override + public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { + Object value = details.getValue(); + if (!(value instanceof Map)) { + throw new IllegalArgumentException("themeCustomization: expected object, got " + (value == null ? "null" : value.getClass().getName())); + } + @SuppressWarnings("unchecked") + Map themeCustomizationMap = (Map) value; + if (!themeCustomizationMap.containsKey("primaryColor")) { + throw new IllegalArgumentException("themeCustomization: missing required property 'primaryColor'"); + } + if (themeCustomizationMap.containsKey("header")) { + Object themeCustomization_header = themeCustomizationMap.get("header"); + if (!(themeCustomization_header instanceof Map)) { + throw new IllegalArgumentException("themeCustomization.header: expected object, got " + (themeCustomization_header == null ? "null" : themeCustomization_header.getClass().getName())); + } + @SuppressWarnings("unchecked") + Map themeCustomization_headerMap = (Map) themeCustomization_header; + if (!themeCustomization_headerMap.containsKey("fontSize")) { + throw new IllegalArgumentException("themeCustomization.header: missing required property 'fontSize'"); + } + if (themeCustomization_headerMap.containsKey("fontSize")) { + Object themeCustomization_header_fontSize = themeCustomization_headerMap.get("fontSize"); + if (!(themeCustomization_header_fontSize instanceof Integer) && !(themeCustomization_header_fontSize instanceof Long)) { + throw new IllegalArgumentException("themeCustomization.header.fontSize: expected Integer, got " + (themeCustomization_header_fontSize == null ? "null" : themeCustomization_header_fontSize.getClass().getName())); + } + } + if (themeCustomization_headerMap.containsKey("visible")) { + Object themeCustomization_header_visible = themeCustomization_headerMap.get("visible"); + if (!(themeCustomization_header_visible instanceof Boolean)) { + throw new IllegalArgumentException("themeCustomization.header.visible: expected Boolean, got " + (themeCustomization_header_visible == null ? "null" : themeCustomization_header_visible.getClass().getName())); + } + } + java.util.Set themeCustomization_headerAllowed = java.util.Set.of("fontSize", "visible"); + for (String key : themeCustomization_headerMap.keySet()) { + if (!themeCustomization_headerAllowed.contains(key)) { + throw new IllegalArgumentException("themeCustomization.header: unexpected property '" + key + "'"); + } + } + } + if (themeCustomizationMap.containsKey("primaryColor")) { + Object themeCustomization_primaryColor = themeCustomizationMap.get("primaryColor"); + if (!(themeCustomization_primaryColor instanceof String)) { + throw new IllegalArgumentException("themeCustomization.primaryColor: expected String, got " + (themeCustomization_primaryColor == null ? "null" : themeCustomization_primaryColor.getClass().getName())); + } + } + if (themeCustomizationMap.containsKey("secondaryColor")) { + Object themeCustomization_secondaryColor = themeCustomizationMap.get("secondaryColor"); + if (!(themeCustomization_secondaryColor instanceof String)) { + throw new IllegalArgumentException("themeCustomization.secondaryColor: expected String, got " + (themeCustomization_secondaryColor == null ? "null" : themeCustomization_secondaryColor.getClass().getName())); + } + } + if (themeCustomizationMap.containsKey("tags")) { + Object themeCustomization_tags = themeCustomizationMap.get("tags"); + if (!(themeCustomization_tags instanceof java.util.List)) { + throw new IllegalArgumentException("themeCustomization.tags: expected array, got " + (themeCustomization_tags == null ? "null" : themeCustomization_tags.getClass().getName())); + } + java.util.List themeCustomization_tagsList = (java.util.List) themeCustomization_tags; + for (int themeCustomization_tagsIdx = 0; themeCustomization_tagsIdx < themeCustomization_tagsList.size(); themeCustomization_tagsIdx++) { + if (!(themeCustomization_tagsList.get(themeCustomization_tagsIdx) instanceof String)) { + throw new IllegalArgumentException("themeCustomization.tags[" + themeCustomization_tagsIdx + "]: expected String"); + } + } + } + } + } + + public interface GeneratedClient { + + /** + * Controls whether Feature A is enabled. + * Details: + * - Flag key: enableFeatureA + * - Type: Boolean + * - Default value: false + * Returns the flag value + */ + Boolean enableFeatureA(EvaluationContext ctx); + + /** + * Controls whether Feature A is enabled. + * Details: + * - Flag key: enableFeatureA + * - Type: Boolean + * - Default value: false + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails enableFeatureADetails(EvaluationContext ctx); + /** + * The message to use for greeting users. + * Details: + * - Flag key: greetingMessage + * - Type: String + * - Default value: Hello there! + * Returns the flag value + */ + String greetingMessage(EvaluationContext ctx); + + /** + * The message to use for greeting users. + * Details: + * - Flag key: greetingMessage + * - Type: String + * - Default value: Hello there! + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails greetingMessageDetails(EvaluationContext ctx); + /** + * Allows customization of theme colors. + * Details: + * - Flag key: themeCustomization + * - Type: Object + * - Default value: Map.of("header", Map.of("fontSize", 16, "visible", true), "primaryColor", "#007bff", "secondaryColor", "#6c757d", "tags", List.of("light")) + * Returns the flag value + */ + ThemeCustomization themeCustomization(EvaluationContext ctx); + + /** + * Allows customization of theme colors. + * Details: + * - Flag key: themeCustomization + * - Type: Object + * - Default value: Map.of("header", Map.of("fontSize", 16, "visible", true), "primaryColor", "#007bff", "secondaryColor", "#6c757d", "tags", List.of("light")) + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails themeCustomizationDetails(EvaluationContext ctx); + } + + private static final class OpenFeatureGeneratedClient implements GeneratedClient { + private final Client client; + + private OpenFeatureGeneratedClient(Client client) { + this.client = client; + } + + + @Override + public Boolean enableFeatureA(EvaluationContext ctx) { + return client.getBooleanValue("enableFeatureA", false, ctx); + } + + @Override + public FlagEvaluationDetails enableFeatureADetails(EvaluationContext ctx) { + return client.getBooleanDetails("enableFeatureA", false, ctx); + } + @Override + public String greetingMessage(EvaluationContext ctx) { + return client.getStringValue("greetingMessage", "Hello there!", ctx); + } + + @Override + public FlagEvaluationDetails greetingMessageDetails(EvaluationContext ctx) { + return client.getStringDetails("greetingMessage", "Hello there!", ctx); + } + @Override + public ThemeCustomization themeCustomization(EvaluationContext ctx) { + Object raw = client.getObjectValue("themeCustomization", Map.of("header", Map.of("fontSize", 16, "visible", true), "primaryColor", "#007bff", "secondaryColor", "#6c757d", "tags", List.of("light")), ctx, FlagEvaluationOptions.builder().hook(new ThemeCustomizationHook()).build()); + return MAPPER.convertValue(raw, ThemeCustomization.class); + } + + @Override + public FlagEvaluationDetails themeCustomizationDetails(EvaluationContext ctx) { + FlagEvaluationDetails details = client.getObjectDetails("themeCustomization", Map.of("header", Map.of("fontSize", 16, "visible", true), "primaryColor", "#007bff", "secondaryColor", "#6c757d", "tags", List.of("light")), ctx, FlagEvaluationOptions.builder().hook(new ThemeCustomizationHook()).build()); + ThemeCustomization typed = MAPPER.convertValue(details.getValue(), ThemeCustomization.class); + FlagEvaluationDetails typedDetails = new FlagEvaluationDetails<>(); + typedDetails.setValue(typed); + typedDetails.setFlagKey(details.getFlagKey()); + typedDetails.setVariant(details.getVariant()); + typedDetails.setReason(details.getReason()); + typedDetails.setErrorCode(details.getErrorCode()); + typedDetails.setErrorMessage(details.getErrorMessage()); + typedDetails.setFlagMetadata(details.getFlagMetadata()); + return typedDetails; + } + } + + public static GeneratedClient getClient() { + return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient()); + } + + public static GeneratedClient getClient(String domain) { + return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient(domain)); + } +} diff --git a/internal/cmd/testdata/schema_manifest.golden b/internal/cmd/testdata/schema_manifest.golden new file mode 100644 index 00000000..7ea9ad43 --- /dev/null +++ b/internal/cmd/testdata/schema_manifest.golden @@ -0,0 +1,48 @@ +{ + "flags": { + "enableFeatureA": { + "flagType": "boolean", + "defaultValue": false, + "description": "Controls whether Feature A is enabled." + }, + "greetingMessage": { + "flagType": "string", + "defaultValue": "Hello there!", + "description": "The message to use for greeting users." + }, + "themeCustomization": { + "flagType": "object", + "defaultValue": { + "primaryColor": "#007bff", + "secondaryColor": "#6c757d", + "tags": ["light"], + "header": { + "fontSize": 16, + "visible": true + } + }, + "description": "Allows customization of theme colors.", + "schema": { + "type": "object", + "properties": { + "primaryColor": { "type": "string" }, + "secondaryColor": { "type": "string" }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "header": { + "type": "object", + "properties": { + "fontSize": { "type": "integer" }, + "visible": { "type": "boolean" } + }, + "required": ["fontSize"], + "additionalProperties": false + } + }, + "required": ["primaryColor"] + } + } + } + } \ No newline at end of file diff --git a/internal/cmd/testdata/schema_nestjs.golden b/internal/cmd/testdata/schema_nestjs.golden new file mode 100644 index 00000000..9b4fc343 --- /dev/null +++ b/internal/cmd/testdata/schema_nestjs.golden @@ -0,0 +1,176 @@ +import type { DynamicModule, FactoryProvider as NestFactoryProvider } from "@nestjs/common"; +import { Inject, Module } from "@nestjs/common"; +import type { Observable } from "rxjs"; + +import type { + OpenFeature, + Client, + EvaluationContext, + EvaluationDetails, + OpenFeatureModuleOptions, + JsonValue +} from "@openfeature/nestjs-sdk"; +import { OpenFeatureModule, BooleanFeatureFlag, StringFeatureFlag, NumberFeatureFlag, ObjectFeatureFlag } from "@openfeature/nestjs-sdk"; + +import type { GeneratedClient } from "./openfeature"; +import { getGeneratedClient, FlagKeys } from "./openfeature"; +import type { + ThemeCustomization, +} from "./openfeature"; + +// Re-export flag keys for convenience +export { FlagKeys }; + +/** + * Returns an injection token for a (domain scoped) generated OpenFeature client. + * @param {string} domain The domain of the generated OpenFeature client. + * @returns {string} The injection token. + */ +export function getOpenFeatureGeneratedClientToken(domain?: string): string { + return domain ? `OpenFeatureGeneratedClient_${domain}` : "OpenFeatureGeneratedClient_default"; +} + +/** + * Options for injecting an OpenFeature client into a constructor. + */ +interface FeatureClientProps { + /** + * The domain of the OpenFeature client, if a domain scoped client should be used. + * @see {@link Client.getBooleanDetails} + */ + domain?: string; +} + +/** + * Injects a generated typesafe feature client into a constructor or property of a class. + * @param {FeatureClientProps} [props] The options for injecting the client. + * @returns {PropertyDecorator & ParameterDecorator} The decorator function. + */ +export const GeneratedOpenFeatureClient = (props?: FeatureClientProps): PropertyDecorator & ParameterDecorator => + Inject(getOpenFeatureGeneratedClientToken(props?.domain)); + +/** + * GeneratedOpenFeatureModule is a generated typesafe NestJS wrapper for OpenFeature Server-SDK. + */ +@Module({}) +export class GeneratedOpenFeatureModule extends OpenFeatureModule { + static override forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule { + const module = super.forRoot({ useGlobalInterceptor, ...options }); + + const clientValueProviders: NestFactoryProvider[] = [ + { + provide: getOpenFeatureGeneratedClientToken(), + useFactory: () => getGeneratedClient(), + }, + ]; + + if (options?.providers) { + const domainClientProviders: NestFactoryProvider[] = Object.keys(options.providers).map( + (domain) => ({ + provide: getOpenFeatureGeneratedClientToken(domain), + useFactory: () => getGeneratedClient(domain), + }), + ); + + clientValueProviders.push(...domainClientProviders); + } + + return { + ...module, + providers: module.providers ? [...module.providers, ...clientValueProviders] : clientValueProviders, + exports: module.exports ? [...module.exports, ...clientValueProviders] : clientValueProviders, + }; + } +} + +/** + * Options for injecting a typed feature flag into a route handler. + */ +interface TypedFeatureProps { + /** + * The domain of the OpenFeature client, if a domain scoped client should be used. + * @see {@link OpenFeature#getClient} + */ + domain?: string; + /** + * The {@link EvaluationContext} for evaluating the feature flag. + * @see {@link OpenFeature#getClient} + */ + context?: EvaluationContext; +} + + +/** + * Gets the {@link EvaluationDetails} for `enableFeatureA` from a domain scoped or the default OpenFeature + * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. + * + * **Details:** + * - flag key: `enableFeatureA` + * - description: `Controls whether Feature A is enabled.` + * - default value: `false` + * - type: `boolean` + * + * Usage: + * ```typescript + * @Get("/") + * public async handleRequest( + * @EnableFeatureA() + * enableFeatureA: Observable>, + * ) + * ``` + * @param {TypedFeatureProps} props The options for injecting the feature flag. + * @returns {ParameterDecorator} The decorator function. + */ +export function EnableFeatureA(props?: TypedFeatureProps): ParameterDecorator { + return BooleanFeatureFlag({ flagKey: "enableFeatureA", defaultValue: false, ...props }); +} + +/** + * Gets the {@link EvaluationDetails} for `greetingMessage` from a domain scoped or the default OpenFeature + * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. + * + * **Details:** + * - flag key: `greetingMessage` + * - description: `The message to use for greeting users.` + * - default value: `Hello there!` + * - type: `string` + * + * Usage: + * ```typescript + * @Get("/") + * public async handleRequest( + * @GreetingMessage() + * greetingMessage: Observable>, + * ) + * ``` + * @param {TypedFeatureProps} props The options for injecting the feature flag. + * @returns {ParameterDecorator} The decorator function. + */ +export function GreetingMessage(props?: TypedFeatureProps): ParameterDecorator { + return StringFeatureFlag({ flagKey: "greetingMessage", defaultValue: "Hello there!", ...props }); +} + +/** + * Gets the {@link EvaluationDetails} for `themeCustomization` from a domain scoped or the default OpenFeature + * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. + * + * **Details:** + * - flag key: `themeCustomization` + * - description: `Allows customization of theme colors.` + * - default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` + * - type: `ThemeCustomization` + * + * Usage: + * ```typescript + * @Get("/") + * public async handleRequest( + * @ThemeCustomization() + * themeCustomization: Observable>, + * ) + * ``` + * @param {TypedFeatureProps} props The options for injecting the feature flag. + * @returns {ParameterDecorator} The decorator function. + */ +export function ThemeCustomization(props?: TypedFeatureProps): ParameterDecorator { + return ObjectFeatureFlag({ flagKey: "themeCustomization", defaultValue: {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}, ...props }); +} diff --git a/internal/cmd/testdata/schema_nodejs.golden b/internal/cmd/testdata/schema_nodejs.golden new file mode 100644 index 00000000..d9de9ccf --- /dev/null +++ b/internal/cmd/testdata/schema_nodejs.golden @@ -0,0 +1,243 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +import { + OpenFeature, + stringOrUndefined, + objectOrUndefined, + JsonValue, +} from "@openfeature/server-sdk"; +import type { + EvaluationContext, + EvaluationDetails, + FlagEvaluationOptions, + Hook, + HookContext, +} from "@openfeature/server-sdk"; + +export interface ThemeCustomization { + header?: { + fontSize: number; + visible?: boolean; +}; + primaryColor: string; + secondaryColor?: string; + tags?: string[]; +} + + +function createThemeCustomizationHook(): Hook { + return { + after: (_hookContext: Readonly, evaluationDetails: EvaluationDetails) => { + const value = evaluationDetails.value; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('themeCustomization: expected object'); + } + const themeCustomizationObj = value as Record; + if (themeCustomizationObj["primaryColor"] === undefined) { + throw new Error('themeCustomization: missing required property "primaryColor"'); + } + if (themeCustomizationObj["header"] !== undefined) { + if (typeof themeCustomizationObj["header"] !== 'object' || themeCustomizationObj["header"] === null || Array.isArray(themeCustomizationObj["header"])) { + throw new Error('themeCustomization.header: expected object'); + } + const themeCustomization_headerObj = themeCustomizationObj["header"] as Record; + if (themeCustomization_headerObj["fontSize"] === undefined) { + throw new Error('themeCustomization.header: missing required property "fontSize"'); + } + if (themeCustomization_headerObj["fontSize"] !== undefined) { + if (typeof themeCustomization_headerObj["fontSize"] !== 'number' || !Number.isInteger(themeCustomization_headerObj["fontSize"])) { + throw new Error('themeCustomization.header.fontSize: expected integer'); + } + } + if (themeCustomization_headerObj["visible"] !== undefined) { + if (typeof themeCustomization_headerObj["visible"] !== 'boolean') { + throw new Error('themeCustomization.header.visible: expected boolean'); + } + } + const themeCustomization_headerAllowed = new Set(["fontSize", "visible"]); + for (const key of Object.keys(themeCustomization_headerObj)) { + if (!themeCustomization_headerAllowed.has(key)) { + throw new Error(`themeCustomization.header: unexpected property "${key}"`); + } + } + } + if (themeCustomizationObj["primaryColor"] !== undefined) { + if (typeof themeCustomizationObj["primaryColor"] !== 'string') { + throw new Error('themeCustomization.primaryColor: expected string'); + } + } + if (themeCustomizationObj["secondaryColor"] !== undefined) { + if (typeof themeCustomizationObj["secondaryColor"] !== 'string') { + throw new Error('themeCustomization.secondaryColor: expected string'); + } + } + if (themeCustomizationObj["tags"] !== undefined) { + if (!Array.isArray(themeCustomizationObj["tags"])) { + throw new Error('themeCustomization.tags: expected array'); + } + for (let themeCustomization_tagsIdx = 0; themeCustomization_tagsIdx < themeCustomizationObj["tags"].length; themeCustomization_tagsIdx++) { + if (typeof themeCustomizationObj["tags"][themeCustomization_tagsIdx] !== 'string') { + throw new Error(`themeCustomization.tags[${themeCustomization_tagsIdx}]: expected string`); + } + } + } + }, + }; +} + + +// Flag key constants for programmatic access +export const FlagKeys = { + /** Flag key for Controls whether Feature A is enabled. */ + ENABLE_FEATURE_A: "enableFeatureA", + /** Flag key for The message to use for greeting users. */ + GREETING_MESSAGE: "greetingMessage", + /** Flag key for Allows customization of theme colors. */ + THEME_CUSTOMIZATION: "themeCustomization", +} as const; + +export interface GeneratedClient { + /** + * Controls whether Feature A is enabled. + * + * **Details:** + * - flag key: `enableFeatureA` + * - default value: `false` + * - type: `boolean` + * + * Performs a flag evaluation that returns a boolean. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + enableFeatureA(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * Controls whether Feature A is enabled. + * + * **Details:** + * - flag key: `enableFeatureA` + * - default value: `false` + * - type: `boolean` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + enableFeatureADetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + + /** + * The message to use for greeting users. + * + * **Details:** + * - flag key: `greetingMessage` + * - default value: `Hello there!` + * - type: `string` + * + * Performs a flag evaluation that returns a string. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + greetingMessage(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * The message to use for greeting users. + * + * **Details:** + * - flag key: `greetingMessage` + * - default value: `Hello there!` + * - type: `string` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + greetingMessageDetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + + /** + * Allows customization of theme colors. + * + * **Details:** + * - flag key: `themeCustomization` + * - default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` + * - type: `ThemeCustomization` + * + * Performs a flag evaluation that returns a object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + themeCustomization(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * Allows customization of theme colors. + * + * **Details:** + * - flag key: `themeCustomization` + * - default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` + * - type: `ThemeCustomization` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + themeCustomizationDetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; +} + +/** + * A factory function that returns a generated client that not bound to a domain. + * It was generated using the OpenFeature CLI and is compatible with `@openfeature/server-sdk`. + * + * All domainless or unbound clients use the default provider set via {@link OpenFeature.setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {GeneratedClient} Generated OpenFeature Client + */ +export function getGeneratedClient(context?: EvaluationContext): GeneratedClient +/** + * A factory function that returns a domain-bound generated client that was + * created using the OpenFeature CLI and is compatible with the `@openfeature/server-sdk`. + * + * If there is already a provider bound to this domain via {@link OpenFeature.setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {GeneratedClient} Generated OpenFeature Client + */ +export function getGeneratedClient(domain: string, context?: EvaluationContext): GeneratedClient +export function getGeneratedClient(domainOrContext?: string | EvaluationContext, contextOrUndefined?: EvaluationContext): GeneratedClient { + const domain = stringOrUndefined(domainOrContext); + const context = + objectOrUndefined(domainOrContext) ?? + objectOrUndefined(contextOrUndefined); + + const client = domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context) + + return { + enableFeatureA: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getBooleanValue("enableFeatureA", false, context, options); + }, + + enableFeatureADetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getBooleanDetails("enableFeatureA", false, context, options); + }, + + greetingMessage: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getStringValue("greetingMessage", "Hello there!", context, options); + }, + + greetingMessageDetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getStringDetails("greetingMessage", "Hello there!", context, options); + }, + + themeCustomization: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getObjectValue("themeCustomization", {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}, context, { ...options, hooks: [...(options?.hooks ?? []), createThemeCustomizationHook()] }) as Promise; + }, + + themeCustomizationDetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getObjectDetails("themeCustomization", {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}, context, { ...options, hooks: [...(options?.hooks ?? []), createThemeCustomizationHook()] }) as Promise>; + }, + } +} \ No newline at end of file diff --git a/internal/cmd/testdata/schema_python.golden b/internal/cmd/testdata/schema_python.golden new file mode 100644 index 00000000..a2c78dce --- /dev/null +++ b/internal/cmd/testdata/schema_python.golden @@ -0,0 +1,378 @@ +# AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +from typing import Optional, Any, Required, TypedDict + +from openfeature.client import OpenFeatureClient +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagEvaluationOptions +from openfeature.hook import Hook + +class ThemeCustomizationHeader(TypedDict, total=False): + fontSize: Required[int] + visible: bool + +class ThemeCustomization(TypedDict, total=False): + header: ThemeCustomizationHeader + primaryColor: Required[str] + secondaryColor: str + tags: list[str] + + + +class ThemeCustomizationHook(Hook): + def after( + self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict + ): + value = details.value + if not isinstance(value, dict): + raise ValueError("themeCustomization: expected dict") + if "primaryColor" not in value: + raise ValueError("themeCustomization: missing required property 'primaryColor'") + if "header" in value: + if not isinstance(value["header"], dict): + raise ValueError("themeCustomization.header: expected dict") + if "fontSize" not in value["header"]: + raise ValueError("themeCustomization.header: missing required property 'fontSize'") + if "fontSize" in value["header"]: + if not isinstance(value["header"]["fontSize"], int) or isinstance(value["header"]["fontSize"], bool): + raise ValueError("themeCustomization.header.fontSize: expected int") + if "visible" in value["header"]: + if not isinstance(value["header"]["visible"], bool): + raise ValueError("themeCustomization.header.visible: expected bool") + themeCustomization_header_allowed = {"fontSize", "visible"} + for _key in value["header"]: + if _key not in themeCustomization_header_allowed: + raise ValueError(f"themeCustomization.header: unexpected property '{_key}'") + if "primaryColor" in value: + if not isinstance(value["primaryColor"], str): + raise ValueError("themeCustomization.primaryColor: expected str") + if "secondaryColor" in value: + if not isinstance(value["secondaryColor"], str): + raise ValueError("themeCustomization.secondaryColor: expected str") + if "tags" in value: + if not isinstance(value["tags"], list): + raise ValueError("themeCustomization.tags: expected list") + for themeCustomization_tags_idx, themeCustomization_tags_item in enumerate(value["tags"]): + if not isinstance(themeCustomization_tags_item, str): + raise ValueError(f"themeCustomization.tags[{themeCustomization_tags_idx}]: expected str") + + + + +class FlagKeys: + """Flag key constants for programmatic access""" + ENABLE_FEATURE_A = "enableFeatureA" # Flag key for: Controls whether Feature A is enabled. + GREETING_MESSAGE = "greetingMessage" # Flag key for: The message to use for greeting users. + THEME_CUSTOMIZATION = "themeCustomization" # Flag key for: Allows customization of theme colors. + + +class GeneratedClient: + def __init__( + self, + client: OpenFeatureClient, + ) -> None: + self.client = client + + def enable_feature_a( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> bool: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation that returns a `bool`. + """ + return self.client.get_boolean_value( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def enable_feature_a_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.get_boolean_details( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def enable_feature_a_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> bool: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation asynchronously and returns a `bool`. + """ + return await self.client.get_boolean_value_async( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def enable_feature_a_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.get_boolean_details_async( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def greeting_message( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> str: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation that returns a `str`. + """ + return self.client.get_string_value( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def greeting_message_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.get_string_details( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def greeting_message_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> str: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation asynchronously and returns a `str`. + """ + return await self.client.get_string_value_async( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def greeting_message_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.get_string_details_async( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def theme_customization( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> ThemeCustomization: + """ + Allows customization of theme colors. + + **Details:** + - flag key: `themeCustomization` + - default value: `{"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}` + - type: `ThemeCustomization` + + Performs a flag evaluation that returns a `ThemeCustomization`. + """ + if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions(hooks=[ThemeCustomizationHook()]) + else: + flag_evaluation_options = FlagEvaluationOptions( + hooks=[*(flag_evaluation_options.hooks or []), ThemeCustomizationHook()], + ) + return self.client.get_object_value( + flag_key="themeCustomization", + default_value={"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) # type: ignore[return-value] + + def theme_customization_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Allows customization of theme colors. + + **Details:** + - flag key: `themeCustomization` + - default value: `{"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}` + - type: `ThemeCustomization` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions(hooks=[ThemeCustomizationHook()]) + else: + flag_evaluation_options = FlagEvaluationOptions( + hooks=[*(flag_evaluation_options.hooks or []), ThemeCustomizationHook()], + ) + return self.client.get_object_details( + flag_key="themeCustomization", + default_value={"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def theme_customization_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> ThemeCustomization: + """ + Allows customization of theme colors. + + **Details:** + - flag key: `themeCustomization` + - default value: `{"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}` + - type: `ThemeCustomization` + + Performs a flag evaluation asynchronously and returns a `ThemeCustomization`. + """ + if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions(hooks=[ThemeCustomizationHook()]) + else: + flag_evaluation_options = FlagEvaluationOptions( + hooks=[*(flag_evaluation_options.hooks or []), ThemeCustomizationHook()], + ) + return await self.client.get_object_value_async( + flag_key="themeCustomization", + default_value={"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) # type: ignore[return-value] + + async def theme_customization_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Allows customization of theme colors. + + **Details:** + - flag key: `themeCustomization` + - default value: `{"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}` + - type: `ThemeCustomization` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions(hooks=[ThemeCustomizationHook()]) + else: + flag_evaluation_options = FlagEvaluationOptions( + hooks=[*(flag_evaluation_options.hooks or []), ThemeCustomizationHook()], + ) + return await self.client.get_object_details_async( + flag_key="themeCustomization", + default_value={"header": {"fontSize": 16, "visible": True}, "primaryColor": "#007bff", "secondaryColor": "#6c757d", "tags": ["light"]}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + +def get_generated_client( + client: Optional[OpenFeatureClient] = None, + domain: Optional[str] = None, + version: Optional[str] = None, + context: Optional[EvaluationContext] = None, + hooks: Optional[list[Hook]] = None, +) -> GeneratedClient: + if not client: + client = OpenFeatureClient( + domain=domain, + version=version, + context=context, + hooks=hooks, + ) + return GeneratedClient(client) diff --git a/internal/cmd/testdata/schema_react.golden b/internal/cmd/testdata/schema_react.golden new file mode 100644 index 00000000..6f609528 --- /dev/null +++ b/internal/cmd/testdata/schema_react.golden @@ -0,0 +1,179 @@ +'use client'; + +import { + type ReactFlagEvaluationOptions, + type ReactFlagEvaluationNoSuspenseOptions, + type FlagQuery, + useFlag, + useSuspenseFlag, + JsonValue +} from "@openfeature/react-sdk"; +import type { + EvaluationDetails, + Hook, + HookContext, +} from "@openfeature/react-sdk"; + +export interface ThemeCustomization { + header?: { + fontSize: number; + visible?: boolean; +}; + primaryColor: string; + secondaryColor?: string; + tags?: string[]; +} + + +function createThemeCustomizationHook(): Hook { + return { + after: (_hookContext: Readonly, evaluationDetails: EvaluationDetails) => { + const value = evaluationDetails.value; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('themeCustomization: expected object'); + } + const themeCustomizationObj = value as Record; + if (themeCustomizationObj["primaryColor"] === undefined) { + throw new Error('themeCustomization: missing required property "primaryColor"'); + } + if (themeCustomizationObj["header"] !== undefined) { + if (typeof themeCustomizationObj["header"] !== 'object' || themeCustomizationObj["header"] === null || Array.isArray(themeCustomizationObj["header"])) { + throw new Error('themeCustomization.header: expected object'); + } + const themeCustomization_headerObj = themeCustomizationObj["header"] as Record; + if (themeCustomization_headerObj["fontSize"] === undefined) { + throw new Error('themeCustomization.header: missing required property "fontSize"'); + } + if (themeCustomization_headerObj["fontSize"] !== undefined) { + if (typeof themeCustomization_headerObj["fontSize"] !== 'number' || !Number.isInteger(themeCustomization_headerObj["fontSize"])) { + throw new Error('themeCustomization.header.fontSize: expected integer'); + } + } + if (themeCustomization_headerObj["visible"] !== undefined) { + if (typeof themeCustomization_headerObj["visible"] !== 'boolean') { + throw new Error('themeCustomization.header.visible: expected boolean'); + } + } + const themeCustomization_headerAllowed = new Set(["fontSize", "visible"]); + for (const key of Object.keys(themeCustomization_headerObj)) { + if (!themeCustomization_headerAllowed.has(key)) { + throw new Error(`themeCustomization.header: unexpected property "${key}"`); + } + } + } + if (themeCustomizationObj["primaryColor"] !== undefined) { + if (typeof themeCustomizationObj["primaryColor"] !== 'string') { + throw new Error('themeCustomization.primaryColor: expected string'); + } + } + if (themeCustomizationObj["secondaryColor"] !== undefined) { + if (typeof themeCustomizationObj["secondaryColor"] !== 'string') { + throw new Error('themeCustomization.secondaryColor: expected string'); + } + } + if (themeCustomizationObj["tags"] !== undefined) { + if (!Array.isArray(themeCustomizationObj["tags"])) { + throw new Error('themeCustomization.tags: expected array'); + } + for (let themeCustomization_tagsIdx = 0; themeCustomization_tagsIdx < themeCustomizationObj["tags"].length; themeCustomization_tagsIdx++) { + if (typeof themeCustomizationObj["tags"][themeCustomization_tagsIdx] !== 'string') { + throw new Error(`themeCustomization.tags[${themeCustomization_tagsIdx}]: expected string`); + } + } + } + }, + }; +} + + +// Flag key constants for programmatic access +export const FlagKeys = { + /** Flag key for Controls whether Feature A is enabled. */ + ENABLE_FEATURE_A: "enableFeatureA", + /** Flag key for The message to use for greeting users. */ + GREETING_MESSAGE: "greetingMessage", + /** Flag key for Allows customization of theme colors. */ + THEME_CUSTOMIZATION: "themeCustomization", +} as const; + + +/** +* Controls whether Feature A is enabled. +* +* **Details:** +* - flag key: `enableFeatureA` +* - default value: `false` +* - type: `boolean` +*/ +export const useEnableFeatureA = (options?: ReactFlagEvaluationOptions): FlagQuery => { + 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): FlagQuery => { + return useSuspenseFlag("enableFeatureA", false, options); +}; + +/** +* The message to use for greeting users. +* +* **Details:** +* - flag key: `greetingMessage` +* - default value: `Hello there!` +* - type: `string` +*/ +export const useGreetingMessage = (options?: ReactFlagEvaluationOptions): FlagQuery => { + 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): FlagQuery => { + return useSuspenseFlag("greetingMessage", "Hello there!", options); +}; + +/** +* Allows customization of theme colors. +* +* **Details:** +* - flag key: `themeCustomization` +* - default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` +* - type: `ThemeCustomization` +*/ +export const useThemeCustomization = (options?: ReactFlagEvaluationOptions): FlagQuery => { + return useFlag("themeCustomization", {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}, { ...options, hooks: [...(options?.hooks ?? []), createThemeCustomizationHook()] }) as FlagQuery; +}; + +/** +* Allows customization of theme colors. +* +* **Details:** +* - flag key: `themeCustomization` +* - default value: `{"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}` +* - type: `ThemeCustomization` +* +* Equivalent to useFlag with options: `{ suspend: true }` +* @experimental — Suspense is an experimental feature subject to change in future versions. +*/ +export const useSuspenseThemeCustomization = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery => { + return useSuspenseFlag("themeCustomization", {"header":{"fontSize":16,"visible":true},"primaryColor":"#007bff","secondaryColor":"#6c757d","tags":["light"]}, { ...options, hooks: [...(options?.hooks ?? []), createThemeCustomizationHook()] }) as FlagQuery; +}; diff --git a/internal/config/flags.go b/internal/config/flags.go index 40eaf51a..357a95f1 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -10,23 +10,24 @@ import ( // Flag name constants to avoid duplication const ( - DebugFlagName = "debug" - ManifestFlagName = "manifest" - OutputFlagName = "output" - NoInputFlagName = "no-input" - GoPackageFlagName = "package-name" - CSharpNamespaceName = "namespace" - OverrideFlagName = "override" - JavaPackageFlagName = "package-name" - ProviderURLFlagName = "provider-url" - FlagSourceURLFlagName = "flag-source-url" // Deprecated: use ProviderFlagName instead - AuthTokenFlagName = "auth-token" - NoPromptFlagName = "no-prompt" - DryRunFlagName = "dry-run" - TypeFlagName = "type" - DefaultValueFlagName = "default-value" - DescriptionFlagName = "description" - TemplateFlagName = "template" + DebugFlagName = "debug" + ManifestFlagName = "manifest" + OutputFlagName = "output" + NoInputFlagName = "no-input" + GoPackageFlagName = "package-name" + CSharpNamespaceName = "namespace" + OverrideFlagName = "override" + JavaPackageFlagName = "package-name" + ProviderURLFlagName = "provider-url" + FlagSourceURLFlagName = "flag-source-url" // Deprecated: use ProviderFlagName instead + AuthTokenFlagName = "auth-token" + NoPromptFlagName = "no-prompt" + DryRunFlagName = "dry-run" + TypeFlagName = "type" + DefaultValueFlagName = "default-value" + DescriptionFlagName = "description" + TemplateFlagName = "template" + RuntimeValidationFlagName = "runtime-validation" ) // Default values for flags @@ -49,6 +50,7 @@ func AddRootFlags(cmd *cobra.Command) { func AddGenerateFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringP(OutputFlagName, "o", DefaultOutputPath, "Path to where the generated files should be saved") cmd.PersistentFlags().StringP(TemplateFlagName, "t", "", "Path to a custom template file. If not specified, the default template is used") + cmd.PersistentFlags().Bool(RuntimeValidationFlagName, true, "Generate runtime validation hooks for typed object flags") } // AddGoGenerateFlags adds the go generator specific flags to the given command @@ -128,6 +130,12 @@ func GetTemplatePath(cmd *cobra.Command) string { return templatePath } +// GetRuntimeValidation gets the runtime-validation flag from the given command +func GetRuntimeValidation(cmd *cobra.Command) bool { + runtimeValidation, _ := cmd.Flags().GetBool(RuntimeValidationFlagName) + return runtimeValidation +} + // GetNoInput gets the no-input flag from the given command func GetNoInput(cmd *cobra.Command) bool { noInput, _ := cmd.Flags().GetBool(NoInputFlagName) diff --git a/internal/flagset/flagset.go b/internal/flagset/flagset.go index 60e66298..d1c4d73e 100644 --- a/internal/flagset/flagset.go +++ b/internal/flagset/flagset.go @@ -36,11 +36,29 @@ func (f FlagType) String() string { } } +// ObjectSchema represents a JSON Schema subset for describing the shape of object flag values. +// It supports objects with properties, arrays with item schemas, and primitive types. +type ObjectSchema struct { + // Type is the JSON Schema type: "object", "array", "string", "number", "integer", "boolean" + Type string `json:"type"` + // Properties defines the properties of an object type (only valid when Type is "object") + Properties map[string]*ObjectSchema `json:"properties,omitempty"` + // Required lists property names that must be present (only valid when Type is "object") + Required []string `json:"required,omitempty"` + // Items defines the schema for array elements (only valid when Type is "array") + Items *ObjectSchema `json:"items,omitempty"` + // AdditionalProperties controls whether extra properties are allowed (only valid when Type is "object") + AdditionalProperties *bool `json:"additionalProperties,omitempty"` +} + type Flag struct { Key string Type FlagType Description string DefaultValue any + // Schema is an optional JSON Schema subset describing the shape of an object flag's value. + // It is nil when no schema is provided (backward compatible). + Schema *ObjectSchema } type Flagset struct { @@ -80,9 +98,10 @@ func ParseFlagType(typeStr string) (FlagType, error) { func (fs *Flagset) UnmarshalJSON(data []byte) error { var manifest struct { Flags map[string]struct { - FlagType string `json:"flagType"` - Description string `json:"description"` - DefaultValue any `json:"defaultValue"` + FlagType string `json:"flagType"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` + Schema *ObjectSchema `json:"schema,omitempty"` } `json:"flags"` } @@ -96,12 +115,19 @@ func (fs *Flagset) UnmarshalJSON(data []byte) error { return err } - fs.Flags = append(fs.Flags, Flag{ + f := Flag{ Key: key, Type: flagType, Description: flag.Description, DefaultValue: flag.DefaultValue, - }) + } + + // Only attach schema to object flags + if flagType == ObjectType && flag.Schema != nil { + f.Schema = flag.Schema + } + + fs.Flags = append(fs.Flags, f) } // Ensure consistency of order of flag generation. @@ -116,27 +142,31 @@ func (fs *Flagset) UnmarshalJSON(data []byte) error { func (fs *Flagset) MarshalJSON() ([]byte, error) { manifest := struct { Flags map[string]struct { - FlagType string `json:"flagType"` - Description string `json:"description"` - DefaultValue any `json:"defaultValue"` + FlagType string `json:"flagType"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` + Schema *ObjectSchema `json:"schema,omitempty"` } `json:"flags"` }{ Flags: make(map[string]struct { - FlagType string `json:"flagType"` - Description string `json:"description"` - DefaultValue any `json:"defaultValue"` + FlagType string `json:"flagType"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` + Schema *ObjectSchema `json:"schema,omitempty"` }), } for _, flag := range fs.Flags { manifest.Flags[flag.Key] = struct { - FlagType string `json:"flagType"` - Description string `json:"description"` - DefaultValue any `json:"defaultValue"` + FlagType string `json:"flagType"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` + Schema *ObjectSchema `json:"schema,omitempty"` }{ FlagType: flag.Type.String(), Description: flag.Description, DefaultValue: flag.DefaultValue, + Schema: flag.Schema, } } diff --git a/internal/generators/angular/angular.go b/internal/generators/angular/angular.go index 1b3003b1..13922b36 100644 --- a/internal/generators/angular/angular.go +++ b/internal/generators/angular/angular.go @@ -7,6 +7,7 @@ import ( "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/generators/typescriptgen" ) // AngularGenerator generates typesafe Angular services and directives. @@ -68,15 +69,20 @@ func toJSONString(value any) string { // Generate creates the Angular typesafe client file. func (g *AngularGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "SdkServiceMethod": sdkServiceMethod, - "ToJSONString": toJSONString, + "OpenFeatureType": openFeatureType, + "SdkServiceMethod": sdkServiceMethod, + "ToJSONString": toJSONString, + "HasSchema": generators.HasSchema, + "TSInterfaceDef": typescriptgen.GenerateInterfaceDef, + "TSFlagReturnType": typescriptgen.FlagReturnType, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: Params{}, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: Params{}, } return g.GenerateFile(funcs, angularTmpl, newParams, "openfeature.generated.ts") diff --git a/internal/generators/angular/angular.tmpl b/internal/generators/angular/angular.tmpl index 6db1c753..b426b9ee 100644 --- a/internal/generators/angular/angular.tmpl +++ b/internal/generators/angular/angular.tmpl @@ -28,6 +28,18 @@ import { JsonValue, } from '@openfeature/angular-sdk'; import { Observable, map } from 'rxjs'; +{{- if HasObjectFlagsWithSchema .Flagset.Flags }} + +// ============================================================================ +// TYPED OBJECT FLAG INTERFACES +// ============================================================================ + +{{ range .Flagset.Flags }} +{{- if HasSchema . }} +{{ TSInterfaceDef . }} +{{- end }} +{{- end }} +{{- end }} // ============================================================================ // FLAG KEYS @@ -87,7 +99,7 @@ export class GeneratedFeatureFlagService { * * **Details:** * - Flag key: `{{ .Key }}` - * - Type: `{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` + * - Type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` * - Default value: `{{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` * * @param domain - Optional domain for flag evaluation (scopes the flag to a specific provider). @@ -97,7 +109,7 @@ export class GeneratedFeatureFlagService { get{{ .Key | ToPascal }}Details( domain?: string, options?: AngularFlagEvaluationOptions - ): Observable> { + ): Observable> { return this.flagService.{{ .Type | SdkServiceMethod }}( {{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue | QuoteString }}{{ end }}, @@ -106,7 +118,7 @@ export class GeneratedFeatureFlagService { updateOnConfigurationChanged: options?.updateOnConfigurationChanged ?? true, updateOnContextChanged: options?.updateOnContextChanged ?? true, } - ); + ){{ if HasSchema . }} as unknown as Observable>{{ end }}; } /** @@ -121,7 +133,7 @@ export class GeneratedFeatureFlagService { get{{ .Key | ToPascal }}( domain?: string, options?: AngularFlagEvaluationOptions - ): Observable<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> { + ): Observable<{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> { return this.get{{ .Key | ToPascal }}Details(domain, options).pipe( map((details) => details.value) ); @@ -145,7 +157,7 @@ export class GeneratedFeatureFlagService { * * **Details:** * - Flag key: `{{ .Key }}` - * - Type: `{{ if eq $type "object" }}JsonValue{{ else }}{{ $type }}{{ end }}` + * - Type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq $type "object" }}JsonValue{{ else }}{{ $type }}{{ end }}` * - Default value: `{{ if eq $type "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` * * @@ -195,17 +207,18 @@ export class GeneratedFeatureFlagService { selector: '[{{ .Key | ToCamel }}FeatureFlag]', standalone: true, }) -export class {{ .Key | ToPascal }}FeatureFlagDirective extends FeatureFlagDirective<{{ if eq $type "object" }}JsonValue{{ else }}{{ $type }}{{ end }}> implements OnChanges { +{{ $tsType := "" }}{{ if HasSchema . }}{{ $tsType = (TSFlagReturnType .) }}{{ else if eq $type "object" }}{{ $tsType = "JsonValue" }}{{ else }}{{ $tsType = $type }}{{ end -}} +export class {{ .Key | ToPascal }}FeatureFlagDirective extends FeatureFlagDirective<{{ $tsType }}> implements OnChanges { override _changeDetectorRef = inject(ChangeDetectorRef); override _viewContainerRef = inject(ViewContainerRef); - override _thenTemplateRef = inject>>(TemplateRef); + override _thenTemplateRef = inject>>(TemplateRef); {{- if ne $type "boolean" }} /** * The expected value of this {{ $type }} feature flag, for which the `then` template should be rendered. */ - @Input({ required: false }) {{ .Key | ToCamel }}FeatureFlagValue?: {{ if eq $type "object" }}JsonValue{{ else }}{{ $type }}{{ end }}; + @Input({ required: false }) {{ .Key | ToCamel }}FeatureFlagValue?: {{ $tsType }}; {{- end }} constructor() { @@ -261,7 +274,7 @@ export class {{ .Key | ToPascal }}FeatureFlagDirective extends FeatureFlagDirect * Template to be displayed when the feature flag {{ if eq $type "boolean" }}is false{{ else }}does not match value{{ end }}. */ @Input() - set {{ .Key | ToCamel }}FeatureFlagElse(tpl: TemplateRef>) { + set {{ .Key | ToCamel }}FeatureFlagElse(tpl: TemplateRef>) { this._elseTemplateRef = tpl; } @@ -269,7 +282,7 @@ export class {{ .Key | ToPascal }}FeatureFlagDirective extends FeatureFlagDirect * Template to be displayed when the provider is not ready. */ @Input() - set {{ .Key | ToCamel }}FeatureFlagInitializing(tpl: TemplateRef>) { + set {{ .Key | ToCamel }}FeatureFlagInitializing(tpl: TemplateRef>) { this._initializingTemplateRef = tpl; } @@ -277,7 +290,7 @@ export class {{ .Key | ToPascal }}FeatureFlagDirective extends FeatureFlagDirect * Template to be displayed when the provider is reconciling. */ @Input() - set {{ .Key | ToCamel }}FeatureFlagReconciling(tpl: TemplateRef>) { + set {{ .Key | ToCamel }}FeatureFlagReconciling(tpl: TemplateRef>) { this._reconcilingTemplateRef = tpl; } } diff --git a/internal/generators/csharp/csharp.go b/internal/generators/csharp/csharp.go index 5ee9b226..2924dd26 100644 --- a/internal/generators/csharp/csharp.go +++ b/internal/generators/csharp/csharp.go @@ -121,17 +121,306 @@ func formatNestedValue(value any) string { } } +// csharpSchemaType converts an ObjectSchema type string to a C# type string. +// parentName is used to reference named record types for nested objects. +func csharpSchemaType(schema *flagset.ObjectSchema, required bool, parentName string) string { + if schema == nil { + return "object" + } + + baseType := "" + switch schema.Type { + case "string": + baseType = "string" + case "number": + baseType = "double" + case "integer": + baseType = "int" + case "boolean": + baseType = "bool" + case "array": + if schema.Items != nil { + baseType = "List<" + csharpSchemaType(schema.Items, true, parentName+"Item") + ">" + } else { + baseType = "List" + } + case "object": + if schema.Properties != nil { + baseType = parentName + } else { + baseType = "Dictionary" + } + default: + baseType = "object" + } + + if !required { + return baseType + "?" + } + return baseType +} + +// collectCSharpRecordDefs recursively collects all record definitions needed for a schema, +// emitting nested records before their parents so dependencies are satisfied. +func collectCSharpRecordDefs(typeName string, schema *flagset.ObjectSchema, flagKey string) string { + var b strings.Builder + + // First, recurse into nested objects to emit their records + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + if propSchema != nil && propSchema.Type == "object" && propSchema.Properties != nil { + nestedName := typeName + generators.ObjectTypeName(propName) + b.WriteString(collectCSharpRecordDefs(nestedName, propSchema, flagKey)) + b.WriteString("\n") + } + if propSchema != nil && propSchema.Type == "array" && propSchema.Items != nil && + propSchema.Items.Type == "object" && propSchema.Items.Properties != nil { + nestedName := typeName + generators.ObjectTypeName(propName) + "Item" + b.WriteString(collectCSharpRecordDefs(nestedName, propSchema.Items, flagKey)) + b.WriteString("\n") + } + } + + // Emit this record + b.WriteString(fmt.Sprintf(" /// \n /// Typed object for the %q flag.\n /// \n", flagKey)) + b.WriteString(fmt.Sprintf(" public record %s(\n", typeName)) + propNames := generators.SortedPropertyNames(schema.Properties) + for i, propName := range propNames { + propSchema := schema.Properties[propName] + isReq := generators.IsRequired(propName, schema.Required) + nestedName := typeName + generators.ObjectTypeName(propName) + csType := csharpSchemaType(propSchema, isReq, nestedName) + fieldName := generators.ObjectTypeName(propName) + + b.WriteString(fmt.Sprintf(" %s %s", csType, fieldName)) + if i < len(propNames)-1 { + b.WriteString(",\n") + } else { + b.WriteString("\n") + } + } + b.WriteString(" );\n") + + return b.String() +} + +// generateCSharpRecordDef generates C# record definitions for a flag's object schema, +// including any nested object types. +func generateCSharpRecordDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + return collectCSharpRecordDefs(typeName, flag.Schema, flag.Key) +} + +// csSafeVarName converts a path to a safe C# variable name. +func csSafeVarName(path string) string { + v := strings.ReplaceAll(path, ".", "_") + v = strings.ReplaceAll(v, "[", "_") + v = strings.ReplaceAll(v, "]", "") + return v +} + +// generateCSharpHookDef generates a C# Hook class for runtime validation of a typed object flag. +func generateCSharpHookDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + hookName := csharpHookName(flag) + + var b strings.Builder + b.WriteString(fmt.Sprintf(" /// \n /// Validation hook for the %q flag.\n /// \n", flag.Key)) + b.WriteString(fmt.Sprintf(" public class %s : Hook\n", hookName)) + b.WriteString(" {\n") + b.WriteString(fmt.Sprintf(" public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null)\n")) + b.WriteString(" {\n") + b.WriteString(" return new ValueTask(EvaluationContext.Empty);\n") + b.WriteString(" }\n\n") + b.WriteString(fmt.Sprintf(" public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null)\n")) + b.WriteString(" {\n") + b.WriteString(" if (details.Value is not Value value)\n") + b.WriteString(" {\n") + b.WriteString(fmt.Sprintf(" throw new InvalidOperationException(\"%s: expected Value type\");\n", flag.Key)) + b.WriteString(" }\n") + b.WriteString(generateCSharpValidation(flag.Schema, "value", flag.Key, " ")) + + _ = typeName // typeName used in the doc comment above + b.WriteString(" return new ValueTask();\n") + b.WriteString(" }\n") + b.WriteString(" }\n") + return b.String() +} + +// generateCSharpValidation generates C# validation code for a schema at a given path. +// For objects, the accessor is a Value and we use .AsStructure. For primitives, we use typed accessors. +func generateCSharpValidation(schema *flagset.ObjectSchema, accessor string, path string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "object": + varName := csSafeVarName(path) + "Struct" + b.WriteString(fmt.Sprintf("%svar %s = %s.AsStructure;\n", indent, varName, accessor)) + b.WriteString(fmt.Sprintf("%sif (%s == null)\n", indent, varName)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: expected object structure\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + for _, req := range schema.Required { + b.WriteString(fmt.Sprintf("%sif (!%s.ContainsKey(%q))\n", indent, varName, req)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: missing required property '%s'\");\n", indent, path, req)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + // Validate each property's type and recurse into nested schemas + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + propPath := fmt.Sprintf("%s.%s", path, propName) + propVar := csSafeVarName(propPath) + "Val" + + b.WriteString(fmt.Sprintf("%sif (%s.ContainsKey(%q))\n", indent, varName, propName)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s var %s = %s[%q];\n", indent, propVar, varName, propName)) + b.WriteString(generateCSharpValidation(propSchema, propVar, propPath, indent+" ")) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + // Check additionalProperties: false + if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { + allowedVar := csSafeVarName(path) + "Allowed" + b.WriteString(fmt.Sprintf("%svar %s = new HashSet { ", indent, allowedVar)) + for i, propName := range generators.SortedPropertyNames(schema.Properties) { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf("%q", propName)) + } + b.WriteString(" };\n") + b.WriteString(fmt.Sprintf("%sforeach (var key in %s.Keys)\n", indent, varName)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s if (!%s.Contains(key))\n", indent, allowedVar)) + b.WriteString(fmt.Sprintf("%s {\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException($\"%s: unexpected property '{key}'\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s }\n", indent)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + case "array": + varName := csSafeVarName(path) + "List" + b.WriteString(fmt.Sprintf("%svar %s = %s.AsList;\n", indent, varName, accessor)) + b.WriteString(fmt.Sprintf("%sif (%s == null)\n", indent, varName)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: expected array\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + if schema.Items != nil { + idxVar := csSafeVarName(path) + "Idx" + b.WriteString(fmt.Sprintf("%sfor (var %s = 0; %s < %s.Count; %s++)\n", indent, idxVar, idxVar, varName, idxVar)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + itemAccessor := fmt.Sprintf("%s[%s]", varName, idxVar) + b.WriteString(generateCSharpArrayItemValidation(schema.Items, itemAccessor, path, idxVar, indent+" ")) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + case "string": + b.WriteString(fmt.Sprintf("%sif (%s.AsString == null)\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: expected string\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "number": + b.WriteString(fmt.Sprintf("%sif (%s.AsDouble == null)\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: expected number\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "integer": + b.WriteString(fmt.Sprintf("%sif (%s.AsInteger == null)\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: expected integer\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "boolean": + b.WriteString(fmt.Sprintf("%sif (%s.AsBoolean == null)\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException(\"%s: expected boolean\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + return b.String() +} + +// generateCSharpArrayItemValidation generates validation for array items with runtime index in error paths. +func generateCSharpArrayItemValidation(schema *flagset.ObjectSchema, itemAccessor string, arrayPath string, idxVar string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "string": + b.WriteString(fmt.Sprintf("%sif (%s.AsString == null)\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException($\"%s[{%s}]: expected string\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "number": + b.WriteString(fmt.Sprintf("%sif (%s.AsDouble == null)\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException($\"%s[{%s}]: expected number\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "integer": + b.WriteString(fmt.Sprintf("%sif (%s.AsInteger == null)\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException($\"%s[{%s}]: expected integer\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "boolean": + b.WriteString(fmt.Sprintf("%sif (%s.AsBoolean == null)\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s{\n", indent)) + b.WriteString(fmt.Sprintf("%s throw new InvalidOperationException($\"%s[{%s}]: expected boolean\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "object", "array": + b.WriteString(generateCSharpValidation(schema, itemAccessor, fmt.Sprintf("%s[item]", arrayPath), indent)) + } + + return b.String() +} + +// csharpFlagReturnType returns the C# return type for a flag. +// For schema-typed object flags it returns the record type name; otherwise the standard type. +func csharpFlagReturnType(flag flagset.Flag) string { + if generators.HasSchema(flag) { + return generators.ObjectTypeName(flag.Key) + } + if flag.Type == flagset.ObjectType { + return "Value" + } + return openFeatureType(flag.Type) +} + +// csharpHookName returns the hook class name for a typed object flag. +func csharpHookName(flag flagset.Flag) string { + return generators.ObjectTypeName(flag.Key) + "Hook" +} + func (g *CsharpGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "FormatDefaultValue": formatDefaultValue, - "ToCSharpDict": toCSharpDict, + "OpenFeatureType": openFeatureType, + "FormatDefaultValue": formatDefaultValue, + "ToCSharpDict": toCSharpDict, + "HasSchema": generators.HasSchema, + "CSharpRecordDef": generateCSharpRecordDef, + "CSharpHookDef": generateCSharpHookDef, + "CSharpFlagReturnType": csharpFlagReturnType, + "CSharpHookName": csharpHookName, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: params.Custom, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: params.Custom, } return g.GenerateFile(funcs, csharpTmpl, newParams, "OpenFeature.g.cs") diff --git a/internal/generators/csharp/csharp.tmpl b/internal/generators/csharp/csharp.tmpl index f09bf644..44d0cd2f 100644 --- a/internal/generators/csharp/csharp.tmpl +++ b/internal/generators/csharp/csharp.tmpl @@ -2,14 +2,30 @@ #nullable enable using System; using System.Collections.Generic; +{{- if HasObjectFlagsWithSchema .Flagset.Flags }} +using System.Text.Json; +{{- end }} using System.Threading.Tasks; using System.Threading; using Microsoft.Extensions.DependencyInjection; using OpenFeature; using OpenFeature.Model; +{{- if HasObjectFlagsWithSchema .Flagset.Flags }} +using OpenFeature.Extension; +{{- end }} namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else }}OpenFeatureGenerated{{ end }} { +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ CSharpRecordDef . }} + {{- if $.Params.RuntimeValidation }} + +{{ CSharpHookDef . }} + {{- end }} + {{- end }} + {{- end }} /// /// Flag key constants for programmatic access /// @@ -82,6 +98,24 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// Optional context for the flag evaluation /// Options for flag evaluation /// The flag value + {{- if HasSchema . }} + public async Task<{{ CSharpFlagReturnType . }}> {{ .Key | ToPascal }}Async(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + {{- if $.Params.RuntimeValidation }} + var hookOptions = options ?? new FlagEvaluationOptions(new {{ CSharpHookName . }}()); + if (options != null) + { + var hooks = new List(options.HookList) { new {{ CSharpHookName . }}() }; + hookOptions = new FlagEvaluationOptions(hooks.ToArray()); + } + var value = await _client.GetObjectValueAsync("{{ .Key }}", {{ .DefaultValue | ToCSharpDict }}, evaluationContext, hookOptions); + {{- else }} + var value = await _client.GetObjectValueAsync("{{ .Key }}", {{ .DefaultValue | ToCSharpDict }}, evaluationContext, options); + {{- end }} + var json = JsonSerializer.Serialize(value.AsStructure); + return JsonSerializer.Deserialize<{{ CSharpFlagReturnType . }}>(json)!; + } + {{- else }} public async Task<{{ if eq (.Type | OpenFeatureType) "object" }}Value{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> {{ .Key | ToPascal }}Async(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { {{- if eq .Type 1 }} @@ -93,11 +127,12 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else {{- else if eq .Type 4 }} return await _client.GetStringValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); {{- else if eq .Type 5 }} - return await _client.GetObjectValueAsync("{{ .Key }}", {{ if eq (.Type | OpenFeatureType) "object" }}{{ .DefaultValue | ToCSharpDict }}{{ else }}{{ . | FormatDefaultValue }}{{ end }}, evaluationContext, options); + return await _client.GetObjectValueAsync("{{ .Key }}", {{ .DefaultValue | ToCSharpDict }}, evaluationContext, options); {{- else }} throw new NotSupportedException("Unsupported flag type"); {{- end }} } + {{- end }} /// /// {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} @@ -110,6 +145,22 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// Optional context for the flag evaluation /// Options for flag evaluation /// The evaluation details containing the flag value and metadata + {{- if HasSchema . }} + public async Task> {{ .Key | ToPascal }}DetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + {{- if $.Params.RuntimeValidation }} + var hookOptions = options ?? new FlagEvaluationOptions(new {{ CSharpHookName . }}()); + if (options != null) + { + var hooks = new List(options.HookList) { new {{ CSharpHookName . }}() }; + hookOptions = new FlagEvaluationOptions(hooks.ToArray()); + } + return await _client.GetObjectDetailsAsync("{{ .Key }}", {{ .DefaultValue | ToCSharpDict }}, evaluationContext, hookOptions); + {{- else }} + return await _client.GetObjectDetailsAsync("{{ .Key }}", {{ .DefaultValue | ToCSharpDict }}, evaluationContext, options); + {{- end }} + } + {{- else }} public async Task> {{ .Key | ToPascal }}DetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { {{- if eq .Type 1 }} @@ -121,11 +172,12 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else {{- else if eq .Type 4 }} return await _client.GetStringDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); {{- else if eq .Type 5 }} - return await _client.GetObjectDetailsAsync("{{ .Key }}", {{ if eq (.Type | OpenFeatureType) "object" }}{{ .DefaultValue | ToCSharpDict }}{{ else }}{{ . | FormatDefaultValue }}{{ end }}, evaluationContext, options); + return await _client.GetObjectDetailsAsync("{{ .Key }}", {{ .DefaultValue | ToCSharpDict }}, evaluationContext, options); {{- else }} throw new NotSupportedException("Unsupported flag type"); {{- end }} } + {{- end }} {{ end }} /// diff --git a/internal/generators/generators.go b/internal/generators/generators.go index 516db1b2..69ce8be2 100644 --- a/internal/generators/generators.go +++ b/internal/generators/generators.go @@ -27,9 +27,10 @@ type CommonGenerator struct { } type Params[T any] struct { - OutputPath string - TemplatePath string - Custom T + OutputPath string + TemplatePath string + RuntimeValidation bool + Custom T } type TemplateData struct { diff --git a/internal/generators/golang/golang.go b/internal/generators/golang/golang.go index c45d0a81..ae1b1a91 100644 --- a/internal/generators/golang/golang.go +++ b/internal/generators/golang/golang.go @@ -128,17 +128,250 @@ func formatNestedValue(value any) string { } } +// goSchemaType converts an ObjectSchema type string to a Go type string. +func goSchemaType(schema *flagset.ObjectSchema, required bool) string { + if schema == nil { + return "any" + } + + switch schema.Type { + case "string": + return "string" + case "number": + return "float64" + case "integer": + return "int64" + case "boolean": + return "bool" + case "array": + if schema.Items != nil { + return "[]" + goSchemaType(schema.Items, true) + } + return "[]any" + case "object": + if schema.Properties != nil { + // Inline nested struct + return generateGoStructBody(schema) + } + return "map[string]any" + default: + return "any" + } +} + +// generateGoStructBody generates a Go struct type literal (with braces) from a schema. +func generateGoStructBody(schema *flagset.ObjectSchema) string { + var b strings.Builder + b.WriteString("struct {\n") + + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + isReq := generators.IsRequired(propName, schema.Required) + goType := goSchemaType(propSchema, isReq) + fieldName := generators.ObjectTypeName(propName) + + jsonTag := propName + if !isReq { + jsonTag += ",omitempty" + } + + b.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", fieldName, goType, jsonTag)) + } + + b.WriteString("}") + return b.String() +} + +// generateGoTypeDef generates a top-level Go type definition for a flag's object schema. +func generateGoTypeDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := goObjectTypeName(flag.Key) + body := generateGoStructBody(flag.Schema) + + return fmt.Sprintf("// %s is the typed object for the %q flag.\ntype %s %s\n", typeName, flag.Key, typeName, body) +} + +// generateGoHookDef generates the validation hook struct and After method for a typed object flag. +func generateGoHookDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + hookName := fmt.Sprintf("%sHook", strings.ToLower(typeName[:1])+typeName[1:]) + + var b strings.Builder + b.WriteString(fmt.Sprintf("type %s struct {\n\topenfeature.UnimplementedHook\n}\n\n", hookName)) + b.WriteString(fmt.Sprintf("func (h %s) After(ctx context.Context, hookCtx openfeature.HookContext, details openfeature.InterfaceEvaluationDetails, hints openfeature.HookHints) error {\n", hookName)) + b.WriteString(generateGoValidation(flag.Schema, "details.Value", flag.Key)) + b.WriteString("\treturn nil\n}\n") + + return b.String() +} + +// goSafeVarName converts a path like "themeCustomization.header" to a safe Go variable name. +func goSafeVarName(path string) string { + v := strings.ReplaceAll(path, ".", "_") + v = strings.ReplaceAll(v, "[", "_") + v = strings.ReplaceAll(v, "]", "") + return v +} + +// generateGoValidation generates Go validation code for a schema at a given path. +func generateGoValidation(schema *flagset.ObjectSchema, accessor string, path string) string { + var b strings.Builder + + switch schema.Type { + case "object": + varName := goSafeVarName(path) + b.WriteString(fmt.Sprintf("\t%sMap, ok := %s.(map[string]any)\n", varName, accessor)) + b.WriteString(fmt.Sprintf("\tif !ok {\n\t\treturn fmt.Errorf(\"%s: expected object, got %%T\", %s)\n\t}\n", path, accessor)) + + for _, req := range schema.Required { + b.WriteString(fmt.Sprintf("\tif _, exists := %sMap[%q]; !exists {\n", varName, req)) + b.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"%s: missing required property %%q\", %q)\n\t}\n", path, req)) + } + + // Validate each property's type and recurse into nested schemas + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + propPath := fmt.Sprintf("%s.%s", path, propName) + propVar := goSafeVarName(propPath) + + b.WriteString(fmt.Sprintf("\tif %s, exists := %sMap[%q]; exists {\n", propVar, varName, propName)) + b.WriteString(generateGoValidation(propSchema, propVar, propPath)) + b.WriteString("\t}\n") + } + + // Check additionalProperties: false + if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { + allowedVar := goSafeVarName(path) + "Allowed" + b.WriteString(fmt.Sprintf("\t%s := map[string]bool{", allowedVar)) + for i, propName := range generators.SortedPropertyNames(schema.Properties) { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf("%q: true", propName)) + } + b.WriteString("}\n") + b.WriteString(fmt.Sprintf("\tfor k := range %sMap {\n", varName)) + b.WriteString(fmt.Sprintf("\t\tif !%s[k] {\n", allowedVar)) + b.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s: unexpected property %%q\", k)\n", path)) + b.WriteString("\t\t}\n") + b.WriteString("\t}\n") + } + + case "array": + varName := goSafeVarName(path) + b.WriteString(fmt.Sprintf("\t%sArr, ok := %s.([]any)\n", varName, accessor)) + b.WriteString(fmt.Sprintf("\tif !ok {\n\t\treturn fmt.Errorf(\"%s: expected array, got %%T\", %s)\n\t}\n", path, accessor)) + + if schema.Items != nil { + idxVar := varName + "Idx" + itemVar := varName + "Item" + b.WriteString(fmt.Sprintf("\tfor %s, %s := range %sArr {\n", idxVar, itemVar, varName)) + b.WriteString(generateGoArrayItemValidation(schema.Items, itemVar, path, idxVar)) + b.WriteString("\t}\n") + } + + case "string": + b.WriteString(fmt.Sprintf("\tif _, ok := %s.(string); !ok {\n", accessor)) + b.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"%s: expected string, got %%T\", %s)\n\t}\n", path, accessor)) + + case "number": + b.WriteString(fmt.Sprintf("\tif _, ok := %s.(float64); !ok {\n", accessor)) + b.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"%s: expected number, got %%T\", %s)\n\t}\n", path, accessor)) + + case "integer": + intVar := goSafeVarName(path) + "Float" + b.WriteString(fmt.Sprintf("\t%s, ok := %s.(float64)\n", intVar, accessor)) + b.WriteString(fmt.Sprintf("\tif !ok {\n\t\treturn fmt.Errorf(\"%s: expected integer, got %%T\", %s)\n\t}\n", path, accessor)) + b.WriteString(fmt.Sprintf("\tif %s != float64(int64(%s)) {\n", intVar, intVar)) + b.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"%s: expected integer, got float\")\n\t}\n", path)) + + case "boolean": + b.WriteString(fmt.Sprintf("\tif _, ok := %s.(bool); !ok {\n", accessor)) + b.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"%s: expected boolean, got %%T\", %s)\n\t}\n", path, accessor)) + } + + return b.String() +} + +// generateGoArrayItemValidation generates validation for array items where the error +// path includes a runtime index variable (e.g., themeCustomization.tags[%d]). +func generateGoArrayItemValidation(schema *flagset.ObjectSchema, itemVar string, arrayPath string, idxVar string) string { + var b strings.Builder + + switch schema.Type { + case "string": + b.WriteString(fmt.Sprintf("\t\tif _, ok := %s.(string); !ok {\n", itemVar)) + b.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s[%%d]: expected string, got %%T\", %s, %s)\n\t\t}\n", arrayPath, idxVar, itemVar)) + case "number": + b.WriteString(fmt.Sprintf("\t\tif _, ok := %s.(float64); !ok {\n", itemVar)) + b.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s[%%d]: expected number, got %%T\", %s, %s)\n\t\t}\n", arrayPath, idxVar, itemVar)) + case "integer": + floatVar := itemVar + "Float" + b.WriteString(fmt.Sprintf("\t\t%s, ok := %s.(float64)\n", floatVar, itemVar)) + b.WriteString(fmt.Sprintf("\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"%s[%%d]: expected integer, got %%T\", %s, %s)\n\t\t}\n", arrayPath, idxVar, itemVar)) + b.WriteString(fmt.Sprintf("\t\tif %s != float64(int64(%s)) {\n", floatVar, floatVar)) + b.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s[%%d]: expected integer, got float\", %s)\n\t\t}\n", arrayPath, idxVar)) + case "boolean": + b.WriteString(fmt.Sprintf("\t\tif _, ok := %s.(bool); !ok {\n", itemVar)) + b.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s[%%d]: expected boolean, got %%T\", %s, %s)\n\t\t}\n", arrayPath, idxVar, itemVar)) + case "object": + b.WriteString(generateGoValidation(schema, itemVar, fmt.Sprintf("%s[item]", arrayPath))) + case "array": + b.WriteString(generateGoValidation(schema, itemVar, fmt.Sprintf("%s[item]", arrayPath))) + } + + return b.String() +} + +// goObjectTypeName returns the Go type name with a "Value" suffix to avoid conflicts +// with the generated variable name (which is PascalCase of the flag key). +func goObjectTypeName(flagKey string) string { + return generators.ObjectTypeName(flagKey) + "Value" +} + +// goFlagReturnType returns the Go return type for a flag - typed struct name if schema exists, else the generic type. +func goFlagReturnType(flag flagset.Flag) string { + if generators.HasSchema(flag) { + return goObjectTypeName(flag.Key) + } + if flag.Type == flagset.ObjectType { + return "any" + } + return typeString(flag.Type) +} + +// goHookName returns the hook variable name for a typed object flag. +func goHookName(flag flagset.Flag) string { + typeName := generators.ObjectTypeName(flag.Key) + return strings.ToLower(typeName[:1]) + typeName[1:] + "Hook" +} + func (g *GolangGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "SupportImports": supportImports, - "OpenFeatureType": openFeatureType, - "TypeString": typeString, - "ToMapLiteral": toMapLiteral, + "SupportImports": supportImports, + "OpenFeatureType": openFeatureType, + "TypeString": typeString, + "ToMapLiteral": toMapLiteral, + "HasSchema": generators.HasSchema, + "ObjectTypeName": generators.ObjectTypeName, + "GoTypeDef": generateGoTypeDef, + "GoHookDef": generateGoHookDef, + "GoFlagReturnType": goFlagReturnType, + "GoHookName": goHookName, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, Custom: Params{ GoPackage: params.Custom.GoPackage, CLIVersion: params.Custom.CLIVersion, diff --git a/internal/generators/golang/golang.tmpl b/internal/generators/golang/golang.tmpl index c8898bc3..5abb3e27 100644 --- a/internal/generators/golang/golang.tmpl +++ b/internal/generators/golang/golang.tmpl @@ -24,7 +24,15 @@ type ( ) var client = openfeature.NewDefaultClient() +{{- range .Flagset.Flags }} +{{- if HasSchema . }} +{{ GoTypeDef . }} +{{- if $.Params.RuntimeValidation }} +{{ GoHookDef . }} +{{- end }} +{{- end }} +{{- end }} {{- range .Flagset.Flags }} // {{ .Key | ToPascal }} returns the value of the "{{ .Key }}" feature flag. // {{ if .Description }}{{ .Description }}{{ end }} @@ -33,18 +41,43 @@ var client = openfeature.NewDefaultClient() var {{ .Key | ToPascal }} = struct { fmt.Stringer // Value returns the value of the [{{ .Key | ToPascal }}] flag. - Value evaluationValue[{{- if eq (.Type | OpenFeatureType) "Object"}}any{{- else}}{{ .Type | TypeString }}{{- end}}] + Value evaluationValue[{{ GoFlagReturnType . }}] // ValueWithDetails returns the evaluation details of the [{{ .Key | ToPascal }}] flag // and the evaluation error, if any. - ValueWithDetails evaluationDetails[{{- if eq (.Type | OpenFeatureType) "Object"}}any{{- else}}{{ .Type | TypeString }}{{- end}}] + ValueWithDetails evaluationDetails[{{ GoFlagReturnType . }}] }{ Stringer: stringer({{ .Key | Quote }}), - Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) {{- if eq (.Type | OpenFeatureType) "Object"}}any{{- else}}{{ .Type | TypeString }}{{- end}} { +{{- if HasSchema . }} + Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) {{ GoFlagReturnType . }} { +{{- if $.Params.RuntimeValidation }} + raw := client.Object(ctx, {{ .Key | Quote }}, {{.DefaultValue | ToMapLiteral }}, evalCtx, openfeature.WithHooks({{ GoHookName . }}{})) +{{- else }} + raw := client.Object(ctx, {{ .Key | Quote }}, {{.DefaultValue | ToMapLiteral }}, evalCtx) +{{- end }} + var result {{ GoFlagReturnType . }} + b, _ := json.Marshal(raw) + json.Unmarshal(b, &result) + return result + }, + ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[{{ GoFlagReturnType . }}], error) { +{{- if $.Params.RuntimeValidation }} + details, err := client.ObjectValueDetails(ctx, {{ .Key | Quote }}, {{.DefaultValue | ToMapLiteral }}, evalCtx, openfeature.WithHooks({{ GoHookName . }}{})) +{{- else }} + details, err := client.ObjectValueDetails(ctx, {{ .Key | Quote }}, {{.DefaultValue | ToMapLiteral }}, evalCtx) +{{- end }} + var result {{ GoFlagReturnType . }} + b, _ := json.Marshal(details.Value) + json.Unmarshal(b, &result) + return openfeature.GenericEvaluationDetails[{{ GoFlagReturnType . }}]{Value: result, EvaluationDetails: details.EvaluationDetails}, err + }, +{{- else }} + Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) {{- if eq (.Type | OpenFeatureType) "Object"}} any{{- else}} {{ .Type | TypeString }}{{- end}} { return client.{{ .Type | OpenFeatureType }}(ctx, {{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "Object" }}{{.DefaultValue | ToMapLiteral }}{{- else }}{{ .DefaultValue | QuoteString }}{{- end}}, evalCtx) }, ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[{{- if eq (.Type | OpenFeatureType) "Object"}}any{{- else}}{{ .Type | TypeString }}{{- end}}], error){ return client.{{ .Type | OpenFeatureType }}ValueDetails(ctx, {{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "Object" }}{{.DefaultValue | ToMapLiteral }}{{- else }}{{ .DefaultValue | QuoteString }}{{- end}}, evalCtx) }, +{{- end }} } {{- end}} diff --git a/internal/generators/java/java.go b/internal/generators/java/java.go index 4ce8f734..f3fb18f2 100644 --- a/internal/generators/java/java.go +++ b/internal/generators/java/java.go @@ -129,17 +129,272 @@ func formatNestedValue(value any) string { } } +// javaSchemaType converts an ObjectSchema type string to a Java type string. +// parentName is used to reference named record types for nested objects. +func javaSchemaType(schema *flagset.ObjectSchema, required bool, parentName string) string { + if schema == nil { + return "Object" + } + + prefix := "" + if !required { + prefix = "@Nullable " + } + + switch schema.Type { + case "string": + return prefix + "String" + case "number": + return prefix + "Double" + case "integer": + return prefix + "Integer" + case "boolean": + return prefix + "Boolean" + case "array": + if schema.Items != nil { + itemType := javaSchemaType(schema.Items, true, parentName+"Item") + return prefix + "List<" + itemType + ">" + } + return prefix + "List" + case "object": + if schema.Properties != nil { + return prefix + parentName + } + return prefix + "Map" + default: + return prefix + "Object" + } +} + +// collectJavaRecordDefs recursively collects all record definitions needed for a schema, +// emitting nested records before their parents so dependencies are satisfied. +func collectJavaRecordDefs(typeName string, schema *flagset.ObjectSchema) string { + var b strings.Builder + + // First, recurse into nested objects to emit their records + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + if propSchema != nil && propSchema.Type == "object" && propSchema.Properties != nil { + nestedName := typeName + generators.ObjectTypeName(propName) + b.WriteString(collectJavaRecordDefs(nestedName, propSchema)) + b.WriteString("\n\n") + } + if propSchema != nil && propSchema.Type == "array" && propSchema.Items != nil && + propSchema.Items.Type == "object" && propSchema.Items.Properties != nil { + nestedName := typeName + generators.ObjectTypeName(propName) + "Item" + b.WriteString(collectJavaRecordDefs(nestedName, propSchema.Items)) + b.WriteString("\n\n") + } + } + + // Emit this record + b.WriteString(fmt.Sprintf(" public record %s(\n", typeName)) + propNames := generators.SortedPropertyNames(schema.Properties) + for i, propName := range propNames { + propSchema := schema.Properties[propName] + isReq := generators.IsRequired(propName, schema.Required) + nestedName := typeName + generators.ObjectTypeName(propName) + javaType := javaSchemaType(propSchema, isReq, nestedName) + fieldName := generators.ObjectTypeName(propName) + fieldName = strings.ToLower(fieldName[:1]) + fieldName[1:] + + b.WriteString(fmt.Sprintf(" %s %s", javaType, fieldName)) + if i < len(propNames)-1 { + b.WriteString(",") + } + b.WriteString("\n") + } + b.WriteString(" ) {}") + + return b.String() +} + +// generateJavaRecordDef generates Java record definitions for a flag's object schema, +// including any nested object types. +func generateJavaRecordDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + return collectJavaRecordDefs(typeName, flag.Schema) +} + +// javaSafeVarName converts a path to a safe Java variable name. +func javaSafeVarName(path string) string { + v := strings.ReplaceAll(path, ".", "_") + v = strings.ReplaceAll(v, "[", "_") + v = strings.ReplaceAll(v, "]", "") + return v +} + +// generateJavaHookDef generates a Hook inner class that validates the object shape for a typed flag. +func generateJavaHookDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + hookName := typeName + "Hook" + + var b strings.Builder + b.WriteString(fmt.Sprintf(" static class %s implements Hook {\n", hookName)) + b.WriteString(" @Override\n") + b.WriteString(" public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) {\n") + b.WriteString(" Object value = details.getValue();\n") + b.WriteString(generateJavaValidation(flag.Schema, "value", flag.Key, " ")) + b.WriteString(" }\n") + b.WriteString(" }") + return b.String() +} + +// generateJavaValidation generates Java validation code for a schema at a given path. +func generateJavaValidation(schema *flagset.ObjectSchema, accessor string, path string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "object": + varName := javaSafeVarName(path) + "Map" + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Map)) {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: expected object, got \" + (%s == null ? \"null\" : %s.getClass().getName()));\n", indent, path, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + b.WriteString(fmt.Sprintf("%s@SuppressWarnings(\"unchecked\")\n", indent)) + b.WriteString(fmt.Sprintf("%sMap %s = (Map) %s;\n", indent, varName, accessor)) + + for _, req := range schema.Required { + b.WriteString(fmt.Sprintf("%sif (!%s.containsKey(\"%s\")) {\n", indent, varName, req)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: missing required property '%s'\");\n", indent, path, req)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + // Validate each property's type and recurse into nested schemas + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + propPath := fmt.Sprintf("%s.%s", path, propName) + propVar := javaSafeVarName(propPath) + + b.WriteString(fmt.Sprintf("%sif (%s.containsKey(\"%s\")) {\n", indent, varName, propName)) + b.WriteString(fmt.Sprintf("%s Object %s = %s.get(\"%s\");\n", indent, propVar, varName, propName)) + b.WriteString(generateJavaValidation(propSchema, propVar, propPath, indent+" ")) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + // Check additionalProperties: false + if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { + allowedVar := javaSafeVarName(path) + "Allowed" + b.WriteString(fmt.Sprintf("%sjava.util.Set %s = java.util.Set.of(", indent, allowedVar)) + for i, propName := range generators.SortedPropertyNames(schema.Properties) { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf("\"%s\"", propName)) + } + b.WriteString(");\n") + b.WriteString(fmt.Sprintf("%sfor (String key : %s.keySet()) {\n", indent, varName)) + b.WriteString(fmt.Sprintf("%s if (!%s.contains(key)) {\n", indent, allowedVar)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: unexpected property '\" + key + \"'\");\n", indent, path)) + b.WriteString(fmt.Sprintf("%s }\n", indent)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + case "array": + varName := javaSafeVarName(path) + "List" + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof java.util.List)) {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: expected array, got \" + (%s == null ? \"null\" : %s.getClass().getName()));\n", indent, path, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + b.WriteString(fmt.Sprintf("%sjava.util.List %s = (java.util.List) %s;\n", indent, varName, accessor)) + + if schema.Items != nil { + idxVar := javaSafeVarName(path) + "Idx" + b.WriteString(fmt.Sprintf("%sfor (int %s = 0; %s < %s.size(); %s++) {\n", indent, idxVar, idxVar, varName, idxVar)) + itemAccessor := fmt.Sprintf("%s.get(%s)", varName, idxVar) + b.WriteString(generateJavaArrayItemValidation(schema.Items, itemAccessor, path, idxVar, indent+" ")) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + case "string": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof String)) {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: expected String, got \" + (%s == null ? \"null\" : %s.getClass().getName()));\n", indent, path, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "number": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Number)) {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: expected Number, got \" + (%s == null ? \"null\" : %s.getClass().getName()));\n", indent, path, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "integer": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Integer) && !(%s instanceof Long)) {\n", indent, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: expected Integer, got \" + (%s == null ? \"null\" : %s.getClass().getName()));\n", indent, path, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "boolean": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Boolean)) {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s: expected Boolean, got \" + (%s == null ? \"null\" : %s.getClass().getName()));\n", indent, path, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + return b.String() +} + +// generateJavaArrayItemValidation generates validation for array items with runtime index in error paths. +func generateJavaArrayItemValidation(schema *flagset.ObjectSchema, itemAccessor string, arrayPath string, idxVar string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "string": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof String)) {\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s[\" + %s + \"]: expected String\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "number": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Number)) {\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s[\" + %s + \"]: expected Number\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "integer": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Integer) && !(%s instanceof Long)) {\n", indent, itemAccessor, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s[\" + %s + \"]: expected Integer\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "boolean": + b.WriteString(fmt.Sprintf("%sif (!(%s instanceof Boolean)) {\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new IllegalArgumentException(\"%s[\" + %s + \"]: expected Boolean\");\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "object", "array": + b.WriteString(generateJavaValidation(schema, itemAccessor, fmt.Sprintf("%s[item]", arrayPath), indent)) + } + + return b.String() +} + +// javaFlagReturnType returns the return type for a flag - the record name if schema exists, "Object" otherwise. +func javaFlagReturnType(flag flagset.Flag) string { + if generators.HasSchema(flag) { + return generators.ObjectTypeName(flag.Key) + } + return "Object" +} + +// javaHookName returns the hook class name for a typed object flag. +func javaHookName(flag flagset.Flag) string { + return generators.ObjectTypeName(flag.Key) + "Hook" +} + func (g *JavaGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "FormatDefaultValue": formatDefaultValueForJava, - "ToMapLiteral": toMapLiteral, + "OpenFeatureType": openFeatureType, + "FormatDefaultValue": formatDefaultValueForJava, + "ToMapLiteral": toMapLiteral, + "HasSchema": generators.HasSchema, + "JavaRecordDef": generateJavaRecordDef, + "JavaHookDef": generateJavaHookDef, + "JavaFlagReturnType": javaFlagReturnType, + "JavaHookName": javaHookName, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: params.Custom, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: params.Custom, } return g.GenerateFile(funcs, javaTmpl, newParams, "OpenFeature.java") diff --git a/internal/generators/java/java.tmpl b/internal/generators/java/java.tmpl index 3e3002cc..ec07cb30 100644 --- a/internal/generators/java/java.tmpl +++ b/internal/generators/java/java.tmpl @@ -5,10 +5,23 @@ import dev.openfeature.sdk.Client; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.OpenFeatureAPI; +{{- if and .Params.RuntimeValidation (HasObjectFlagsWithSchema .Flagset.Flags) }} +import dev.openfeature.sdk.FlagEvaluationOptions; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +{{- end }} +{{- if HasObjectFlagsWithSchema .Flagset.Flags }} +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nullable; +{{- end }} public final class OpenFeature { private OpenFeature() {} // prevent instantiation +{{- if HasObjectFlagsWithSchema .Flagset.Flags }} + + private static final ObjectMapper MAPPER = new ObjectMapper(); +{{- end }} /** * Flag key constants for programmatic access @@ -20,6 +33,20 @@ public final class OpenFeature { public static final String {{ .Key | ToScreamingSnake }} = {{ .Key | Quote }}; {{- end }} } +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ JavaRecordDef . }} +{{- end }} +{{- end }} +{{- if .Params.RuntimeValidation }} +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ JavaHookDef . }} +{{- end }} +{{- end }} +{{- end }} public interface GeneratedClient { {{ range .Flagset.Flags }} @@ -31,7 +58,11 @@ public final class OpenFeature { * - Default value: {{ if eq (.Type | OpenFeatureType) "Object" }}{{ .DefaultValue | ToMapLiteral }}{{ else }}{{ .DefaultValue }}{{ end }} * Returns the flag value */ +{{- if HasSchema . }} + {{ JavaFlagReturnType . }} {{ .Key | ToCamel }}(EvaluationContext ctx); +{{- else }} {{ .Type | OpenFeatureType }} {{ .Key | ToCamel }}(EvaluationContext ctx); +{{- end }} /** * {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} @@ -41,7 +72,11 @@ public final class OpenFeature { * - Default value: {{ if eq (.Type | OpenFeatureType) "Object" }}{{ .DefaultValue | ToMapLiteral }}{{ else }}{{ .DefaultValue }}{{ end }} * Returns the evaluation details containing the flag value and metadata */ +{{- if HasSchema . }} + FlagEvaluationDetails<{{ JavaFlagReturnType . }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx); +{{- else }} FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx); +{{- end }} {{- end }} } @@ -54,6 +89,36 @@ public final class OpenFeature { } {{ range .Flagset.Flags }} +{{- if HasSchema . }} + @Override + public {{ JavaFlagReturnType . }} {{ .Key | ToCamel }}(EvaluationContext ctx) { +{{- if $.Params.RuntimeValidation }} + Object raw = client.getObjectValue("{{ .Key }}", {{ .DefaultValue | ToMapLiteral }}, ctx, FlagEvaluationOptions.builder().hook(new {{ JavaHookName . }}()).build()); +{{- else }} + Object raw = client.getObjectValue("{{ .Key }}", {{ .DefaultValue | ToMapLiteral }}, ctx); +{{- end }} + return MAPPER.convertValue(raw, {{ JavaFlagReturnType . }}.class); + } + + @Override + public FlagEvaluationDetails<{{ JavaFlagReturnType . }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx) { +{{- if $.Params.RuntimeValidation }} + FlagEvaluationDetails details = client.getObjectDetails("{{ .Key }}", {{ .DefaultValue | ToMapLiteral }}, ctx, FlagEvaluationOptions.builder().hook(new {{ JavaHookName . }}()).build()); +{{- else }} + FlagEvaluationDetails details = client.getObjectDetails("{{ .Key }}", {{ .DefaultValue | ToMapLiteral }}, ctx); +{{- end }} + {{ JavaFlagReturnType . }} typed = MAPPER.convertValue(details.getValue(), {{ JavaFlagReturnType . }}.class); + FlagEvaluationDetails<{{ JavaFlagReturnType . }}> typedDetails = new FlagEvaluationDetails<>(); + typedDetails.setValue(typed); + typedDetails.setFlagKey(details.getFlagKey()); + typedDetails.setVariant(details.getVariant()); + typedDetails.setReason(details.getReason()); + typedDetails.setErrorCode(details.getErrorCode()); + typedDetails.setErrorMessage(details.getErrorMessage()); + typedDetails.setFlagMetadata(details.getFlagMetadata()); + return typedDetails; + } +{{- else }} @Override public {{ .Type | OpenFeatureType }} {{ .Key | ToCamel }}(EvaluationContext ctx) { return client.get{{ .Type | OpenFeatureType | ToPascal }}Value("{{ .Key }}", {{ if eq (.Type | OpenFeatureType) "Object" }}{{ .DefaultValue | ToMapLiteral }}{{ else }}{{ . | FormatDefaultValue }}{{ end }}, ctx); @@ -63,6 +128,7 @@ public final class OpenFeature { public FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx) { return client.get{{ .Type | OpenFeatureType | ToPascal }}Details("{{ .Key }}", {{ if eq (.Type | OpenFeatureType) "Object" }}{{ .DefaultValue | ToMapLiteral }}{{ else }}{{ . | FormatDefaultValue }}{{ end }}, ctx); } +{{- end }} {{- end }} } diff --git a/internal/generators/nestjs/nestjs.go b/internal/generators/nestjs/nestjs.go index 8528bb80..401d684f 100644 --- a/internal/generators/nestjs/nestjs.go +++ b/internal/generators/nestjs/nestjs.go @@ -7,6 +7,7 @@ import ( "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/generators/typescriptgen" ) type NestJsGenerator struct { @@ -45,14 +46,18 @@ func toJSONString(value any) string { func (g *NestJsGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "ToJSONString": toJSONString, + "OpenFeatureType": openFeatureType, + "ToJSONString": toJSONString, + "HasSchema": generators.HasSchema, + "TSFlagReturnType": typescriptgen.FlagReturnType, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: Params{}, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: Params{}, } return g.GenerateFile(funcs, nestJsTmpl, newParams, "openfeature-decorators.ts") diff --git a/internal/generators/nestjs/nestjs.tmpl b/internal/generators/nestjs/nestjs.tmpl index c0870fd8..7f9e90ff 100644 --- a/internal/generators/nestjs/nestjs.tmpl +++ b/internal/generators/nestjs/nestjs.tmpl @@ -14,6 +14,15 @@ import { OpenFeatureModule, BooleanFeatureFlag, StringFeatureFlag, NumberFeature import type { GeneratedClient } from "./openfeature"; import { getGeneratedClient, FlagKeys } from "./openfeature"; +{{- if HasObjectFlagsWithSchema .Flagset.Flags }} +import type { +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + {{ TSFlagReturnType . }}, +{{- end }} +{{- end }} +} from "./openfeature"; +{{- end }} // Re-export flag keys for convenience export { FlagKeys }; @@ -105,14 +114,14 @@ interface TypedFeatureProps { * - flag key: `{{ .Key }}` * - description: `{{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }}` * - default value: `{{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` - * - type: `{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` + * - type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` * * Usage: * ```typescript * @Get("/") * public async handleRequest( * @{{ .Key | ToPascal }}() - * {{ .Key | ToCamel }}: Observable>, + * {{ .Key | ToCamel }}: Observable>, * ) * ``` * @param {TypedFeatureProps} props The options for injecting the feature flag. diff --git a/internal/generators/nodejs/nodejs.go b/internal/generators/nodejs/nodejs.go index dc6469af..80a1b6ee 100644 --- a/internal/generators/nodejs/nodejs.go +++ b/internal/generators/nodejs/nodejs.go @@ -7,6 +7,7 @@ import ( "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/generators/typescriptgen" ) type NodejsGenerator struct { @@ -45,14 +46,21 @@ func toJSONString(value any) string { func (g *NodejsGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "ToJSONString": toJSONString, + "OpenFeatureType": openFeatureType, + "ToJSONString": toJSONString, + "HasSchema": generators.HasSchema, + "TSInterfaceDef": typescriptgen.GenerateInterfaceDef, + "TSValidationHookDef": typescriptgen.GenerateValidationHookDef, + "TSFlagReturnType": typescriptgen.FlagReturnType, + "TSValidationHookName": typescriptgen.ValidationHookName, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: Params{}, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: Params{}, } return g.GenerateFile(funcs, nodejsTmpl, newParams, "openfeature.ts") diff --git a/internal/generators/nodejs/nodejs.tmpl b/internal/generators/nodejs/nodejs.tmpl index dd4d3c79..89510faa 100644 --- a/internal/generators/nodejs/nodejs.tmpl +++ b/internal/generators/nodejs/nodejs.tmpl @@ -9,8 +9,26 @@ import type { EvaluationContext, EvaluationDetails, FlagEvaluationOptions, +{{- if and (HasObjectFlagsWithSchema .Flagset.Flags) $.Params.RuntimeValidation }} + Hook, + HookContext, +{{- end }} } from "@openfeature/server-sdk"; +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ TSInterfaceDef . }} +{{- end }} +{{- end }} +{{- if and (HasObjectFlagsWithSchema .Flagset.Flags) $.Params.RuntimeValidation }} +{{ range .Flagset.Flags }} +{{- if HasSchema . }} +{{ TSValidationHookDef . }} +{{- end }} +{{- end }} +{{- end }} + // Flag key constants for programmatic access export const FlagKeys = { {{- range .Flagset.Flags }} @@ -27,14 +45,14 @@ export interface GeneratedClient { * **Details:** * - flag key: `{{ .Key }}` * - default value: `{{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` - * - type: `{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` + * - type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` * * Performs a flag evaluation that returns a {{ .Type | OpenFeatureType }}. * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation * @param {FlagEvaluationOptions} options Additional flag evaluation options - * @returns {Promise<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}>} Flag evaluation response + * @returns {Promise<{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}>} Flag evaluation response */ - {{ .Key | ToCamel }}(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}>; + {{ .Key | ToCamel }}(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}>; /** * {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} @@ -42,14 +60,14 @@ export interface GeneratedClient { * **Details:** * - flag key: `{{ .Key }}` * - default value: `{{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` - * - type: `{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` + * - type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` * * Performs a flag evaluation that a returns an evaluation details object. * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation * @param {FlagEvaluationOptions} options Additional flag evaluation options - * @returns {Promise>} Flag evaluation details response + * @returns {Promise>} Flag evaluation details response */ - {{ .Key | ToCamel }}Details(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + {{ .Key | ToCamel }}Details(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; {{ end -}} } @@ -83,6 +101,23 @@ export function getGeneratedClient(domainOrContext?: string | EvaluationContext, return { {{- range .Flagset.Flags }} +{{- if HasSchema . }} + {{ .Key | ToCamel }}: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ TSFlagReturnType . }}> => { +{{- if $.Params.RuntimeValidation }} + return client.getObjectValue({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, context, { ...options, hooks: [...(options?.hooks ?? []), {{ TSValidationHookName . }}()] }) as Promise<{{ TSFlagReturnType . }}>; +{{- else }} + return client.getObjectValue({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, context, options) as Promise<{{ TSFlagReturnType . }}>; +{{- end }} + }, + + {{ .Key | ToCamel }}Details: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { +{{- if $.Params.RuntimeValidation }} + return client.getObjectDetails({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, context, { ...options, hooks: [...(options?.hooks ?? []), {{ TSValidationHookName . }}()] }) as Promise>; +{{- else }} + return client.getObjectDetails({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, context, options) as Promise>; +{{- end }} + }, +{{- else }} {{ .Key | ToCamel }}: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> => { return client.get{{ .Type | OpenFeatureType | ToPascal }}Value({{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue | QuoteString }}{{ end }}, context, options); }, @@ -90,6 +125,7 @@ export function getGeneratedClient(domainOrContext?: string | EvaluationContext, {{ .Key | ToCamel }}Details: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { return client.get{{ .Type | OpenFeatureType | ToPascal }}Details({{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue | QuoteString }}{{ end }}, context, options); }, +{{- end }} {{ end -}} {{ printf " " }}} } \ No newline at end of file diff --git a/internal/generators/python/python.go b/internal/generators/python/python.go index 8f84c060..26a04579 100644 --- a/internal/generators/python/python.go +++ b/internal/generators/python/python.go @@ -136,21 +136,269 @@ func formatNestedValue(value any) string { } } +// pythonSchemaType converts an ObjectSchema type to a Python type annotation string. +// parentName is used to generate named TypedDict classes for nested objects. +func pythonSchemaType(schema *flagset.ObjectSchema, parentName string) string { + if schema == nil { + return "dict[str, Any]" + } + + switch schema.Type { + case "string": + return "str" + case "number": + return "float" + case "integer": + return "int" + case "boolean": + return "bool" + case "array": + if schema.Items != nil { + return "list[" + pythonSchemaType(schema.Items, parentName+"Item") + "]" + } + return "list[Any]" + case "object": + if schema.Properties != nil { + return parentName + } + return "dict[str, Any]" + default: + return "Any" + } +} + +// collectPythonTypedDicts recursively collects all TypedDict definitions needed for a schema, +// emitting nested types before their parents so dependencies are satisfied. +func collectPythonTypedDicts(typeName string, schema *flagset.ObjectSchema) string { + var b strings.Builder + + // First, recurse into nested objects to emit their TypedDicts + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + if propSchema != nil && propSchema.Type == "object" && propSchema.Properties != nil { + nestedName := typeName + generators.ObjectTypeName(propName) + b.WriteString(collectPythonTypedDicts(nestedName, propSchema)) + b.WriteString("\n") + } + // Handle arrays of objects + if propSchema != nil && propSchema.Type == "array" && propSchema.Items != nil && + propSchema.Items.Type == "object" && propSchema.Items.Properties != nil { + nestedName := typeName + generators.ObjectTypeName(propName) + "Item" + b.WriteString(collectPythonTypedDicts(nestedName, propSchema.Items)) + b.WriteString("\n") + } + } + + // Now emit this TypedDict + b.WriteString(fmt.Sprintf("class %s(TypedDict, total=False):\n", typeName)) + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + isReq := generators.IsRequired(propName, schema.Required) + + nestedName := typeName + generators.ObjectTypeName(propName) + pyType := pythonSchemaType(propSchema, nestedName) + + if isReq { + b.WriteString(fmt.Sprintf(" %s: Required[%s]\n", propName, pyType)) + } else { + b.WriteString(fmt.Sprintf(" %s: %s\n", propName, pyType)) + } + } + + return b.String() +} + +// generatePythonTypedDictDef generates Python TypedDict class definitions for a flag's object schema, +// including any nested object types. +func generatePythonTypedDictDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + return collectPythonTypedDicts(typeName, flag.Schema) +} + +// generatePythonHookDef generates a Python Hook class for runtime validation of a flag's object schema. +func generatePythonHookDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + hookName := pythonHookName(flag) + var b strings.Builder + b.WriteString(fmt.Sprintf("class %s(Hook):\n", hookName)) + b.WriteString(" def after(\n") + b.WriteString(" self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict\n") + b.WriteString(" ):\n") + b.WriteString(" value = details.value\n") + b.WriteString(generatePythonValidation(flag.Schema, "value", flag.Key, " ")) + b.WriteString("\n") + + return b.String() +} + +// pySafeVarName converts a path to a safe Python variable name. +func pySafeVarName(path string) string { + v := strings.ReplaceAll(path, ".", "_") + v = strings.ReplaceAll(v, "[", "_") + v = strings.ReplaceAll(v, "]", "") + return v +} + +// generatePythonValidation generates Python validation code for a schema. +func generatePythonValidation(schema *flagset.ObjectSchema, accessor string, path string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "object": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, dict):\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: expected dict\")\n", indent, path)) + + for _, req := range schema.Required { + b.WriteString(fmt.Sprintf("%sif %q not in %s:\n", indent, req, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: missing required property '%s'\")\n", indent, path, req)) + } + + // Validate each property's type and recurse into nested schemas + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + propAccessor := fmt.Sprintf("%s[%q]", accessor, propName) + propPath := fmt.Sprintf("%s.%s", path, propName) + + b.WriteString(fmt.Sprintf("%sif %q in %s:\n", indent, propName, accessor)) + b.WriteString(generatePythonValidation(propSchema, propAccessor, propPath, indent+" ")) + } + + // Check additionalProperties: false + if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { + allowedVar := pySafeVarName(path) + "_allowed" + b.WriteString(fmt.Sprintf("%s%s = {", indent, allowedVar)) + for i, propName := range generators.SortedPropertyNames(schema.Properties) { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf("%q", propName)) + } + b.WriteString("}\n") + b.WriteString(fmt.Sprintf("%sfor _key in %s:\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s if _key not in %s:\n", indent, allowedVar)) + b.WriteString(fmt.Sprintf("%s raise ValueError(f\"%s: unexpected property '{_key}'\")\n", indent, path)) + } + + case "array": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, list):\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: expected list\")\n", indent, path)) + + if schema.Items != nil { + idxVar := pySafeVarName(path) + "_idx" + itemVar := pySafeVarName(path) + "_item" + b.WriteString(fmt.Sprintf("%sfor %s, %s in enumerate(%s):\n", indent, idxVar, itemVar, accessor)) + b.WriteString(generatePythonArrayItemValidation(schema.Items, itemVar, path, idxVar, indent+" ")) + } + + case "string": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, str):\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: expected str\")\n", indent, path)) + + case "number": + // In Python, bool is a subclass of int, so exclude it + b.WriteString(fmt.Sprintf("%sif isinstance(%s, bool) or not isinstance(%s, (int, float)):\n", indent, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: expected number\")\n", indent, path)) + + case "integer": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, int) or isinstance(%s, bool):\n", indent, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: expected int\")\n", indent, path)) + + case "boolean": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, bool):\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s raise ValueError(\"%s: expected bool\")\n", indent, path)) + } + + return b.String() +} + +// generatePythonArrayItemValidation generates validation for array items with runtime index in error paths. +func generatePythonArrayItemValidation(schema *flagset.ObjectSchema, itemVar string, arrayPath string, idxVar string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "string": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, str):\n", indent, itemVar)) + b.WriteString(fmt.Sprintf("%s raise ValueError(f\"%s[{%s}]: expected str\")\n", indent, arrayPath, idxVar)) + case "number": + b.WriteString(fmt.Sprintf("%sif isinstance(%s, bool) or not isinstance(%s, (int, float)):\n", indent, itemVar, itemVar)) + b.WriteString(fmt.Sprintf("%s raise ValueError(f\"%s[{%s}]: expected number\")\n", indent, arrayPath, idxVar)) + case "integer": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, int) or isinstance(%s, bool):\n", indent, itemVar, itemVar)) + b.WriteString(fmt.Sprintf("%s raise ValueError(f\"%s[{%s}]: expected int\")\n", indent, arrayPath, idxVar)) + case "boolean": + b.WriteString(fmt.Sprintf("%sif not isinstance(%s, bool):\n", indent, itemVar)) + b.WriteString(fmt.Sprintf("%s raise ValueError(f\"%s[{%s}]: expected bool\")\n", indent, arrayPath, idxVar)) + case "object", "array": + b.WriteString(generatePythonValidation(schema, itemVar, fmt.Sprintf("%s[item]", arrayPath), indent)) + } + + return b.String() +} + +// pythonFlagReturnType returns the Python return type for a flag. +// For schema-typed object flags, it returns the TypedDict class name. +// For non-schema object flags, it returns "object". +func pythonFlagReturnType(flag flagset.Flag) string { + if generators.HasSchema(flag) { + return generators.ObjectTypeName(flag.Key) + } + if flag.Type == flagset.ObjectType { + return "object" + } + return openFeatureType(flag.Type) +} + +// pythonHookName returns the hook class name for a typed object flag. +func pythonHookName(flag flagset.Flag) string { + return generators.ObjectTypeName(flag.Key) + "Hook" +} + +// pythonHookInjection generates the Python code block that prepends the validation hook +// to flag_evaluation_options. This is used in the template to avoid repeating the same +// 5-line block across all 4 method variants (sync/async × value/details). +func pythonHookInjection(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + hookName := pythonHookName(flag) + return fmt.Sprintf(` if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions(hooks=[%s()]) + else: + flag_evaluation_options = FlagEvaluationOptions( + hooks=[*(flag_evaluation_options.hooks or []), %s()], + )`, hookName, hookName) +} + func (g *PythonGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "TypedGetMethodSync": typedGetMethodSync, - "TypedGetMethodAsync": typedGetMethodAsync, - "TypedDetailsMethodSync": typedDetailsMethodSync, - "TypedDetailsMethodAsync": typedDetailsMethodAsync, - "PythonBoolLiteral": pythonBoolLiteral, - "ToPythonDict": toPythonDict, + "OpenFeatureType": openFeatureType, + "TypedGetMethodSync": typedGetMethodSync, + "TypedGetMethodAsync": typedGetMethodAsync, + "TypedDetailsMethodSync": typedDetailsMethodSync, + "TypedDetailsMethodAsync": typedDetailsMethodAsync, + "PythonBoolLiteral": pythonBoolLiteral, + "ToPythonDict": toPythonDict, + "HasSchema": generators.HasSchema, + "PythonTypedDictDef": generatePythonTypedDictDef, + "PythonHookDef": generatePythonHookDef, + "PythonFlagReturnType": pythonFlagReturnType, + "PythonHookName": pythonHookName, + "PythonHookInjection": pythonHookInjection, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: Params{}, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: Params{}, } return g.GenerateFile(funcs, pythonTmpl, newParams, "openfeature.py") diff --git a/internal/generators/python/python.tmpl b/internal/generators/python/python.tmpl index ecc8d00a..51bfde18 100644 --- a/internal/generators/python/python.tmpl +++ b/internal/generators/python/python.tmpl @@ -1,10 +1,25 @@ # AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. from typing import Optional +{{- if HasObjectFlagsWithSchema .Flagset.Flags }}, Any, Required, TypedDict{{ end }} from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import FlagEvaluationDetails, FlagEvaluationOptions from openfeature.hook import Hook +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ PythonTypedDictDef . }} +{{- end }} +{{- end }} +{{- if and (HasObjectFlagsWithSchema .Flagset.Flags) $.Params.RuntimeValidation }} +{{ range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ PythonHookDef . }} +{{- end }} +{{- end }} +{{- end }} class FlagKeys: @@ -26,23 +41,35 @@ class GeneratedClient: self, evaluation_context: Optional[EvaluationContext] = None, flag_evaluation_options: Optional[FlagEvaluationOptions] = None, - ) -> {{ .Type | OpenFeatureType }}: + ) -> {{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}: """ {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} **Details:** - flag key: `{{ .Key }}` - default value: `{{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | PythonBoolLiteral }}{{- end }}` - - type: `{{ .Type | OpenFeatureType }}` + - type: `{{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` - Performs a flag evaluation that returns a `{{ .Type | OpenFeatureType }}`. + Performs a flag evaluation that returns a `{{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}`. """ +{{- if HasSchema . }} +{{- if $.Params.RuntimeValidation }} +{{ PythonHookInjection . }} +{{- end }} + return self.client.{{ .Type | TypedGetMethodSync }}( + flag_key={{ .Key | Quote }}, + default_value={{.DefaultValue | ToPythonDict }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) # type: ignore[return-value] +{{- else }} return self.client.{{ .Type | TypedGetMethodSync }}( flag_key={{ .Key | Quote }}, default_value={{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | QuoteString | PythonBoolLiteral }}{{- end }}, evaluation_context=evaluation_context, flag_evaluation_options=flag_evaluation_options, ) +{{- end }} def {{ .Key | ToSnake }}_details( self, @@ -55,38 +82,62 @@ class GeneratedClient: **Details:** - flag key: `{{ .Key }}` - default value: `{{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | PythonBoolLiteral }}{{- end }}` - - type: `{{ .Type | OpenFeatureType }}` + - type: `{{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. """ +{{- if HasSchema . }} +{{- if $.Params.RuntimeValidation }} +{{ PythonHookInjection . }} +{{- end }} + return self.client.{{ .Type | TypedDetailsMethodSync }}( + flag_key={{ .Key | Quote }}, + default_value={{.DefaultValue | ToPythonDict }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) +{{- else }} return self.client.{{ .Type | TypedDetailsMethodSync }}( flag_key={{ .Key | Quote }}, default_value={{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | QuoteString | PythonBoolLiteral }}{{- end }}, evaluation_context=evaluation_context, flag_evaluation_options=flag_evaluation_options, ) +{{- end }} async def {{ .Key | ToSnake }}_async( self, evaluation_context: Optional[EvaluationContext] = None, flag_evaluation_options: Optional[FlagEvaluationOptions] = None, - ) -> {{ .Type | OpenFeatureType }}: + ) -> {{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}: """ {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} **Details:** - flag key: `{{ .Key }}` - default value: `{{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | PythonBoolLiteral }}{{- end }}` - - type: `{{ .Type | OpenFeatureType }}` + - type: `{{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` - Performs a flag evaluation asynchronously and returns a `{{ .Type | OpenFeatureType }}`. + Performs a flag evaluation asynchronously and returns a `{{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}`. """ +{{- if HasSchema . }} +{{- if $.Params.RuntimeValidation }} +{{ PythonHookInjection . }} +{{- end }} + return await self.client.{{ .Type | TypedGetMethodAsync }}( + flag_key={{ .Key | Quote }}, + default_value={{.DefaultValue | ToPythonDict }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) # type: ignore[return-value] +{{- else }} return await self.client.{{ .Type | TypedGetMethodAsync }}( flag_key={{ .Key | Quote }}, default_value={{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | QuoteString | PythonBoolLiteral }}{{- end }}, evaluation_context=evaluation_context, flag_evaluation_options=flag_evaluation_options, ) +{{- end }} async def {{ .Key | ToSnake }}_details_async( self, @@ -99,16 +150,28 @@ class GeneratedClient: **Details:** - flag key: `{{ .Key }}` - default value: `{{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | PythonBoolLiteral }}{{- end }}` - - type: `{{ .Type | OpenFeatureType }}` + - type: `{{ if HasSchema . }}{{ PythonFlagReturnType . }}{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. """ +{{- if HasSchema . }} +{{- if $.Params.RuntimeValidation }} +{{ PythonHookInjection . }} +{{- end }} + return await self.client.{{ .Type | TypedDetailsMethodAsync }}( + flag_key={{ .Key | Quote }}, + default_value={{.DefaultValue | ToPythonDict }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) +{{- else }} return await self.client.{{ .Type | TypedDetailsMethodAsync }}( flag_key={{ .Key | Quote }}, default_value={{- if eq (.Type | OpenFeatureType) "object"}}{{.DefaultValue | ToPythonDict }}{{- else }}{{ .DefaultValue | QuoteString | PythonBoolLiteral }}{{- end }}, evaluation_context=evaluation_context, flag_evaluation_options=flag_evaluation_options, ) +{{- end }} {{ end -}} {{ printf "\n" }} def get_generated_client( diff --git a/internal/generators/react/react.go b/internal/generators/react/react.go index f3dcb9cd..c7685e4d 100644 --- a/internal/generators/react/react.go +++ b/internal/generators/react/react.go @@ -7,6 +7,7 @@ import ( "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/generators/typescriptgen" ) type ReactGenerator struct { @@ -45,14 +46,21 @@ func toJSONString(value any) string { func (g *ReactGenerator) Generate(params *generators.Params[Params]) error { funcs := template.FuncMap{ - "OpenFeatureType": openFeatureType, - "ToJSONString": toJSONString, + "OpenFeatureType": openFeatureType, + "ToJSONString": toJSONString, + "HasSchema": generators.HasSchema, + "TSInterfaceDef": typescriptgen.GenerateInterfaceDef, + "TSValidationHookDef": typescriptgen.GenerateValidationHookDef, + "TSFlagReturnType": typescriptgen.FlagReturnType, + "TSValidationHookName": typescriptgen.ValidationHookName, + "HasObjectFlagsWithSchema": generators.HasObjectFlagsWithSchema, } newParams := &generators.Params[any]{ - OutputPath: params.OutputPath, - TemplatePath: params.TemplatePath, - Custom: Params{}, + OutputPath: params.OutputPath, + TemplatePath: params.TemplatePath, + RuntimeValidation: params.RuntimeValidation, + Custom: Params{}, } return g.GenerateFile(funcs, reactTmpl, newParams, "openfeature.ts") diff --git a/internal/generators/react/react.tmpl b/internal/generators/react/react.tmpl index 95401a02..47b52388 100644 --- a/internal/generators/react/react.tmpl +++ b/internal/generators/react/react.tmpl @@ -8,6 +8,27 @@ import { useSuspenseFlag, JsonValue } from "@openfeature/react-sdk"; +{{- if and (HasObjectFlagsWithSchema .Flagset.Flags) $.Params.RuntimeValidation }} +import type { + EvaluationDetails, + Hook, + HookContext, +} from "@openfeature/react-sdk"; +{{- end }} + +{{- range .Flagset.Flags }} +{{- if HasSchema . }} + +{{ TSInterfaceDef . }} +{{- end }} +{{- end }} +{{- if and (HasObjectFlagsWithSchema .Flagset.Flags) $.Params.RuntimeValidation }} +{{ range .Flagset.Flags }} +{{- if HasSchema . }} +{{ TSValidationHookDef . }} +{{- end }} +{{- end }} +{{- end }} // Flag key constants for programmatic access export const FlagKeys = { @@ -24,10 +45,18 @@ export const FlagKeys = { * **Details:** * - flag key: `{{ .Key }}` * - default value: `{{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` -* - type: `{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` +* - type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` */ -export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions): FlagQuery<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> => { +export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions): FlagQuery<{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> => { +{{- if HasSchema . }} +{{- if $.Params.RuntimeValidation }} + return useFlag({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, { ...options, hooks: [...(options?.hooks ?? []), {{ TSValidationHookName . }}()] }) as FlagQuery<{{ TSFlagReturnType . }}>; +{{- else }} + return useFlag({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, options) as FlagQuery<{{ TSFlagReturnType . }}>; +{{- end }} +{{- else }} return useFlag({{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue | QuoteString }}{{ end }}, options); +{{- end }} }; /** @@ -36,12 +65,20 @@ export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions): * **Details:** * - flag key: `{{ .Key }}` * - default value: `{{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue }}{{ end }}` -* - type: `{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` +* - type: `{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}` * * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> => { +export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery<{{ if HasSchema . }}{{ TSFlagReturnType . }}{{ else if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> => { +{{- if HasSchema . }} +{{- if $.Params.RuntimeValidation }} + return useSuspenseFlag({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, { ...options, hooks: [...(options?.hooks ?? []), {{ TSValidationHookName . }}()] }) as FlagQuery<{{ TSFlagReturnType . }}>; +{{- else }} + return useSuspenseFlag({{ .Key | Quote }}, {{ .DefaultValue | ToJSONString }}, options) as FlagQuery<{{ TSFlagReturnType . }}>; +{{- end }} +{{- else }} return useSuspenseFlag({{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue | QuoteString }}{{ end }}, options); +{{- end }} }; {{ end}} \ No newline at end of file diff --git a/internal/generators/schema.go b/internal/generators/schema.go new file mode 100644 index 00000000..7ab63cff --- /dev/null +++ b/internal/generators/schema.go @@ -0,0 +1,44 @@ +package generators + +import ( + "slices" + "sort" + + "github.com/iancoleman/strcase" + "github.com/open-feature/cli/internal/flagset" +) + +// HasSchema returns true if the flag has an object schema defined. +func HasSchema(flag flagset.Flag) bool { + return flag.Schema != nil && flag.Type == flagset.ObjectType +} + +// ObjectTypeName returns the PascalCase type name for a flag's object schema. +func ObjectTypeName(flagKey string) string { + return strcase.ToCamel(flagKey) +} + +// HasObjectFlagsWithSchema returns true if the flagset contains any object flags with schemas. +func HasObjectFlagsWithSchema(flags []flagset.Flag) bool { + for _, f := range flags { + if HasSchema(f) { + return true + } + } + return false +} + +// SortedPropertyNames returns property names sorted alphabetically for deterministic output. +func SortedPropertyNames(props map[string]*flagset.ObjectSchema) []string { + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// IsRequired checks if a property name is in the required list. +func IsRequired(name string, required []string) bool { + return slices.Contains(required, name) +} diff --git a/internal/generators/schema_test.go b/internal/generators/schema_test.go new file mode 100644 index 00000000..25dcdf70 --- /dev/null +++ b/internal/generators/schema_test.go @@ -0,0 +1,106 @@ +package generators + +import ( + "testing" + + "github.com/open-feature/cli/internal/flagset" +) + +func TestHasSchema(t *testing.T) { + tests := []struct { + name string + flag flagset.Flag + want bool + }{ + { + name: "object flag with schema", + flag: flagset.Flag{ + Type: flagset.ObjectType, + Schema: &flagset.ObjectSchema{Type: "object"}, + }, + want: true, + }, + { + name: "object flag without schema", + flag: flagset.Flag{ + Type: flagset.ObjectType, + Schema: nil, + }, + want: false, + }, + { + name: "string flag with schema is false", + flag: flagset.Flag{ + Type: flagset.StringType, + Schema: &flagset.ObjectSchema{Type: "string"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasSchema(tt.flag); got != tt.want { + t.Errorf("HasSchema() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestObjectTypeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"themeCustomization", "ThemeCustomization"}, + {"primary_color", "PrimaryColor"}, + {"simple", "Simple"}, + {"my-flag-key", "MyFlagKey"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := ObjectTypeName(tt.input); got != tt.want { + t.Errorf("ObjectTypeName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestHasObjectFlagsWithSchema(t *testing.T) { + tests := []struct { + name string + flags []flagset.Flag + want bool + }{ + { + name: "empty", + flags: nil, + want: false, + }, + { + name: "no schemas", + flags: []flagset.Flag{ + {Type: flagset.ObjectType, Schema: nil}, + {Type: flagset.StringType}, + }, + want: false, + }, + { + name: "has schema", + flags: []flagset.Flag{ + {Type: flagset.StringType}, + {Type: flagset.ObjectType, Schema: &flagset.ObjectSchema{Type: "object"}}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasObjectFlagsWithSchema(tt.flags); got != tt.want { + t.Errorf("HasObjectFlagsWithSchema() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/generators/typescriptgen/typescriptgen.go b/internal/generators/typescriptgen/typescriptgen.go new file mode 100644 index 00000000..364acb05 --- /dev/null +++ b/internal/generators/typescriptgen/typescriptgen.go @@ -0,0 +1,233 @@ +// Package typescriptgen provides shared TypeScript type generation utilities +// for all TypeScript-based generators (Node.js, React, Angular, NestJS). +package typescriptgen + +import ( + "fmt" + "strings" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +// tsSchemaType converts an ObjectSchema type to a TypeScript type string. +func tsSchemaType(schema *flagset.ObjectSchema, required bool) string { + if schema == nil { + return "JsonValue" + } + + switch schema.Type { + case "string": + return "string" + case "number", "integer": + return "number" + case "boolean": + return "boolean" + case "array": + if schema.Items != nil { + return tsSchemaType(schema.Items, true) + "[]" + } + return "unknown[]" + case "object": + if schema.Properties != nil { + return generateInlineInterface(schema) + } + return "Record" + default: + return "unknown" + } +} + +// generateInlineInterface generates an inline TypeScript interface body. +func generateInlineInterface(schema *flagset.ObjectSchema) string { + var b strings.Builder + b.WriteString("{\n") + + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + isReq := generators.IsRequired(propName, schema.Required) + tsType := tsSchemaType(propSchema, isReq) + + if isReq { + b.WriteString(fmt.Sprintf(" %s: %s;\n", propName, tsType)) + } else { + b.WriteString(fmt.Sprintf(" %s?: %s;\n", propName, tsType)) + } + } + + b.WriteString("}") + return b.String() +} + +// GenerateInterfaceDef generates a top-level TypeScript interface definition for a flag's object schema. +func GenerateInterfaceDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + typeName := generators.ObjectTypeName(flag.Key) + var b strings.Builder + b.WriteString(fmt.Sprintf("export interface %s ", typeName)) + b.WriteString(generateInlineInterface(flag.Schema)) + b.WriteString("\n") + + return b.String() +} + +// GenerateValidationHookDef generates a TypeScript validation hook function for a flag's object schema. +func GenerateValidationHookDef(flag flagset.Flag) string { + if !generators.HasSchema(flag) { + return "" + } + + hookName := ValidationHookName(flag) + var b strings.Builder + b.WriteString(fmt.Sprintf("function %s(): Hook {\n", hookName)) + b.WriteString(" return {\n") + b.WriteString(" after: (_hookContext: Readonly, evaluationDetails: EvaluationDetails) => {\n") + b.WriteString(" const value = evaluationDetails.value;\n") + b.WriteString(generateTSValidation(flag.Schema, "value", flag.Key, " ")) + b.WriteString(" },\n") + b.WriteString(" };\n") + b.WriteString("}\n") + + return b.String() +} + +// tsSafeVarName converts a path to a safe TypeScript variable name. +func tsSafeVarName(path string) string { + v := strings.ReplaceAll(path, ".", "_") + v = strings.ReplaceAll(v, "[", "_") + v = strings.ReplaceAll(v, "]", "") + return v +} + +// generateTSValidation generates TypeScript validation code for a schema. +func generateTSValidation(schema *flagset.ObjectSchema, accessor string, path string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "object": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'object' || %s === null || Array.isArray(%s)) {\n", indent, accessor, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: expected object');\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + castVar := tsSafeVarName(path) + b.WriteString(fmt.Sprintf("%sconst %sObj = %s as Record;\n", indent, castVar, accessor)) + + for _, req := range schema.Required { + b.WriteString(fmt.Sprintf("%sif (%sObj[%q] === undefined) {\n", indent, castVar, req)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: missing required property %q');\n", indent, path, req)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + // Validate each property's type and recurse into nested schemas + for _, propName := range generators.SortedPropertyNames(schema.Properties) { + propSchema := schema.Properties[propName] + propAccessor := fmt.Sprintf("%sObj[%q]", castVar, propName) + propPath := fmt.Sprintf("%s.%s", path, propName) + + b.WriteString(fmt.Sprintf("%sif (%s !== undefined) {\n", indent, propAccessor)) + b.WriteString(generateTSValidation(propSchema, propAccessor, propPath, indent+" ")) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + // Check additionalProperties: false + if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { + allowedVar := tsSafeVarName(path) + "Allowed" + b.WriteString(fmt.Sprintf("%sconst %s = new Set([", indent, allowedVar)) + for i, propName := range generators.SortedPropertyNames(schema.Properties) { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf("%q", propName)) + } + b.WriteString("]);\n") + b.WriteString(fmt.Sprintf("%sfor (const key of Object.keys(%sObj)) {\n", indent, castVar)) + b.WriteString(fmt.Sprintf("%s if (!%s.has(key)) {\n", indent, allowedVar)) + b.WriteString(fmt.Sprintf("%s throw new Error(`%s: unexpected property \"${key}\"`);\n", indent, path)) + b.WriteString(fmt.Sprintf("%s }\n", indent)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + case "array": + b.WriteString(fmt.Sprintf("%sif (!Array.isArray(%s)) {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: expected array');\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + if schema.Items != nil { + idxVar := tsSafeVarName(path) + "Idx" + b.WriteString(fmt.Sprintf("%sfor (let %s = 0; %s < %s.length; %s++) {\n", indent, idxVar, idxVar, accessor, idxVar)) + itemAccessor := fmt.Sprintf("%s[%s]", accessor, idxVar) + b.WriteString(generateTSArrayItemValidation(schema.Items, itemAccessor, path, idxVar, indent+" ")) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + case "string": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'string') {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: expected string');\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "number": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'number') {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: expected number');\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "integer": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'number' || !Number.isInteger(%s)) {\n", indent, accessor, accessor)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: expected integer');\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + + case "boolean": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'boolean') {\n", indent, accessor)) + b.WriteString(fmt.Sprintf("%s throw new Error('%s: expected boolean');\n", indent, path)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + } + + return b.String() +} + +// generateTSArrayItemValidation generates validation for array items with runtime index in error paths. +func generateTSArrayItemValidation(schema *flagset.ObjectSchema, itemAccessor string, arrayPath string, idxVar string, indent string) string { + var b strings.Builder + + switch schema.Type { + case "string": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'string') {\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new Error(`%s[${%s}]: expected string`);\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "number": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'number') {\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new Error(`%s[${%s}]: expected number`);\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "integer": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'number' || !Number.isInteger(%s)) {\n", indent, itemAccessor, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new Error(`%s[${%s}]: expected integer`);\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "boolean": + b.WriteString(fmt.Sprintf("%sif (typeof %s !== 'boolean') {\n", indent, itemAccessor)) + b.WriteString(fmt.Sprintf("%s throw new Error(`%s[${%s}]: expected boolean`);\n", indent, arrayPath, idxVar)) + b.WriteString(fmt.Sprintf("%s}\n", indent)) + case "object", "array": + // For nested objects/arrays in arrays, use static path since nesting gets complex + b.WriteString(generateTSValidation(schema, itemAccessor, fmt.Sprintf("%s[item]", arrayPath), indent)) + } + + return b.String() +} + +// FlagReturnType returns the TypeScript return type for a flag. +func FlagReturnType(flag flagset.Flag) string { + if generators.HasSchema(flag) { + return generators.ObjectTypeName(flag.Key) + } + if flag.Type == flagset.ObjectType { + return "JsonValue" + } + return "" // handled by existing template logic for non-object types +} + +// ValidationHookName returns the hook function name for a typed object flag. +func ValidationHookName(flag flagset.Flag) string { + return "create" + generators.ObjectTypeName(flag.Key) + "Hook" +} diff --git a/internal/manifest/json-schema.go b/internal/manifest/json-schema.go index 45dc746c..a7022bf9 100644 --- a/internal/manifest/json-schema.go +++ b/internal/manifest/json-schema.go @@ -5,6 +5,7 @@ import ( "github.com/invopop/jsonschema" "github.com/pterm/pterm" + orderedmap "github.com/wk8/go-ordered-map/v2" ) type BooleanFlag struct { @@ -45,6 +46,10 @@ type ObjectFlag struct { Type string `json:"flagType,omitempty" jsonschema:"enum=object"` // The value returned from an unsuccessful flag evaluation DefaultValue any `json:"defaultValue,omitempty"` + // An optional JSON Schema subset describing the structure of the object value. + // The reflected type here doesn't matter because buildObjectFlagProperties replaces + // this property with a $ref to the manually-constructed propertySchema definition. + Schema any `json:"schema,omitempty"` } type BaseFlag struct { @@ -116,9 +121,62 @@ func ToJSONSchema() *jsonschema.Schema { }, "objectFlag": &jsonschema.Schema{ Type: "object", - Properties: reflector.Reflect(ObjectFlag{}).Properties, + Properties: buildObjectFlagProperties(reflector), }, + "propertySchema": buildPropertySchemaDefinition(), } return schema } + +// buildObjectFlagProperties builds the properties for objectFlag, replacing the +// auto-reflected schema property with a $ref to the recursive propertySchema definition. +func buildObjectFlagProperties(reflector *jsonschema.Reflector) *orderedmap.OrderedMap[string, *jsonschema.Schema] { + props := reflector.Reflect(ObjectFlag{}).Properties + // Replace the auto-generated schema property with a $ref to the recursive definition + props.Set("schema", &jsonschema.Schema{ + Ref: "#/$defs/propertySchema", + Description: "An optional JSON Schema subset describing the structure of the object value", + }) + return props +} + +// buildPropertySchemaDefinition constructs the recursive JSON Schema definition +// for the propertySchema type used in object flag schemas. +func buildPropertySchemaDefinition() *jsonschema.Schema { + props := orderedmap.New[string, *jsonschema.Schema]() + props.Set("type", &jsonschema.Schema{ + Type: "string", + Enum: []any{"object", "array", "string", "number", "integer", "boolean"}, + Description: "The JSON Schema type", + }) + props.Set("properties", &jsonschema.Schema{ + Type: "object", + Description: "Property schemas for object types", + AdditionalProperties: &jsonschema.Schema{ + Ref: "#/$defs/propertySchema", + }, + }) + props.Set("required", &jsonschema.Schema{ + Type: "array", + Description: "Required property names for object types", + Items: &jsonschema.Schema{ + Type: "string", + }, + }) + props.Set("items", &jsonschema.Schema{ + Ref: "#/$defs/propertySchema", + Description: "Schema for array element types", + }) + props.Set("additionalProperties", &jsonschema.Schema{ + Type: "boolean", + Description: "Whether additional properties are allowed for object types", + }) + + return &jsonschema.Schema{ + Type: "object", + Description: "A JSON Schema subset for describing object flag value structure", + Properties: props, + Required: []string{"type"}, + } +} diff --git a/internal/manifest/manage.go b/internal/manifest/manage.go index e8933c62..2c64c4c6 100644 --- a/internal/manifest/manage.go +++ b/internal/manifest/manage.go @@ -51,6 +51,12 @@ func LoadFlagSet(manifestPath string) (*flagset.Flagset, error) { return nil, fmt.Errorf("error unmarshaling JSON: %v", err) } + // Validate that defaultValues conform to their declared schemas + schemaErrors := ValidateDefaultValues(flagset.Flags) + if len(schemaErrors) > 0 { + return nil, errors.New(FormatValidationError(schemaErrors)) + } + return &flagset, nil } diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index c09bd34e..4b4b3267 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -7,6 +7,7 @@ import ( "sort" "strings" + "github.com/open-feature/cli/internal/flagset" schema "github.com/open-feature/cli/schema/v0" "github.com/xeipuuv/gojsonschema" ) @@ -168,6 +169,66 @@ func skipValue(decoder *json.Decoder) { // Primitives (string, number, bool, null) are already consumed by the Token() call } +// ValidateDefaultValues checks that each object flag's defaultValue conforms to its schema. +// This is called after the flagset is loaded and provides early error detection for +// manifest authoring mistakes where the defaultValue shape doesn't match the declared schema. +func ValidateDefaultValues(flags []flagset.Flag) []ValidationError { + var issues []ValidationError + for _, flag := range flags { + if flag.Schema == nil { + continue + } + + schemaBytes, err := json.Marshal(flag.Schema) + if err != nil { + issues = append(issues, ValidationError{ + Type: "schema_marshal_error", + Path: fmt.Sprintf("flags.%s.schema", flag.Key), + Message: fmt.Sprintf("failed to marshal schema: %v", err), + }) + continue + } + + valueBytes, err := json.Marshal(flag.DefaultValue) + if err != nil { + issues = append(issues, ValidationError{ + Type: "value_marshal_error", + Path: fmt.Sprintf("flags.%s.defaultValue", flag.Key), + Message: fmt.Sprintf("failed to marshal defaultValue: %v", err), + }) + continue + } + + schemaLoader := gojsonschema.NewBytesLoader(schemaBytes) + valueLoader := gojsonschema.NewBytesLoader(valueBytes) + + result, err := gojsonschema.Validate(schemaLoader, valueLoader) + if err != nil { + issues = append(issues, ValidationError{ + Type: "schema_validation_error", + Path: fmt.Sprintf("flags.%s.defaultValue", flag.Key), + Message: fmt.Sprintf("schema validation failed: %v", err), + }) + continue + } + + basePath := fmt.Sprintf("flags.%s.defaultValue", flag.Key) + for _, verr := range result.Errors() { + field := verr.Field() + path := basePath + if field != "(root)" { + path = fmt.Sprintf("%s.%s", basePath, field) + } + issues = append(issues, ValidationError{ + Type: verr.Type(), + Path: path, + Message: verr.Description(), + }) + } + } + return issues +} + func FormatValidationError(issues []ValidationError) string { var sb strings.Builder sb.WriteString("flag manifest validation failed:\n\n") diff --git a/internal/manifest/validate_test.go b/internal/manifest/validate_test.go index 5413be88..3aa31d97 100644 --- a/internal/manifest/validate_test.go +++ b/internal/manifest/validate_test.go @@ -3,6 +3,8 @@ package manifest import ( "strings" "testing" + + "github.com/open-feature/cli/internal/flagset" ) func TestValidate_DuplicateFlagKeys(t *testing.T) { @@ -188,6 +190,290 @@ func TestFindDuplicateFlagKeys_EdgeCases(t *testing.T) { } } +func TestValidateDefaultValues(t *testing.T) { + boolTrue := true + boolFalse := false + + tests := []struct { + name string + flags []flagset.Flag + wantErrors int + }{ + { + name: "no schema - no validation", + flags: []flagset.Flag{ + {Key: "obj", Type: flagset.ObjectType, DefaultValue: map[string]any{"a": 1}, Schema: nil}, + }, + wantErrors: 0, + }, + { + name: "valid object matches schema", + flags: []flagset.Flag{ + { + Key: "theme", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"color": "#fff"}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "color": {Type: "string"}, + }, + Required: []string{"color"}, + }, + }, + }, + wantErrors: 0, + }, + { + name: "missing required property", + flags: []flagset.Flag{ + { + Key: "theme", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"other": "val"}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "color": {Type: "string"}, + }, + Required: []string{"color"}, + }, + }, + }, + wantErrors: 1, + }, + { + name: "wrong property type", + flags: []flagset.Flag{ + { + Key: "theme", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"color": 123}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "color": {Type: "string"}, + }, + }, + }, + }, + wantErrors: 1, + }, + { + name: "additional properties disallowed", + flags: []flagset.Flag{ + { + Key: "theme", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"color": "#fff", "extra": "bad"}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "color": {Type: "string"}, + }, + AdditionalProperties: &boolFalse, + }, + }, + }, + wantErrors: 1, + }, + { + name: "additional properties allowed explicitly", + flags: []flagset.Flag{ + { + Key: "theme", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"color": "#fff", "extra": "ok"}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "color": {Type: "string"}, + }, + AdditionalProperties: &boolTrue, + }, + }, + }, + wantErrors: 0, + }, + { + name: "nested object validation", + flags: []flagset.Flag{ + { + Key: "config", + Type: flagset.ObjectType, + DefaultValue: map[string]any{ + "layout": map[string]any{"fontSize": 12.0}, + }, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "layout": { + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "fontSize": {Type: "integer"}, + }, + }, + }, + }, + }, + }, + wantErrors: 0, + }, + { + name: "nested object wrong type", + flags: []flagset.Flag{ + { + Key: "config", + Type: flagset.ObjectType, + DefaultValue: map[string]any{ + "layout": map[string]any{"fontSize": "not-a-number"}, + }, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "layout": { + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "fontSize": {Type: "integer"}, + }, + }, + }, + }, + }, + }, + wantErrors: 1, + }, + { + name: "array validation passes", + flags: []flagset.Flag{ + { + Key: "tags", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"items": []any{"a", "b"}}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "items": {Type: "array", Items: &flagset.ObjectSchema{Type: "string"}}, + }, + }, + }, + }, + wantErrors: 0, + }, + { + name: "array element wrong type", + flags: []flagset.Flag{ + { + Key: "tags", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"items": []any{"a", 123}}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "items": {Type: "array", Items: &flagset.ObjectSchema{Type: "string"}}, + }, + }, + }, + }, + wantErrors: 1, + }, + { + name: "defaultValue is not an object when schema expects object", + flags: []flagset.Flag{ + { + Key: "bad", + Type: flagset.ObjectType, + DefaultValue: "not-an-object", + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "a": {Type: "string"}, + }, + }, + }, + }, + wantErrors: 1, + }, + { + name: "float passes as number", + flags: []flagset.Flag{ + { + Key: "config", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"ratio": 0.5}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "ratio": {Type: "number"}, + }, + }, + }, + }, + wantErrors: 0, + }, + { + name: "integer as float64 with no fraction passes integer check", + flags: []flagset.Flag{ + { + Key: "config", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"count": float64(10)}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "count": {Type: "integer"}, + }, + }, + }, + }, + wantErrors: 0, + }, + { + name: "float64 with fraction fails integer check", + flags: []flagset.Flag{ + { + Key: "config", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"count": 10.5}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "count": {Type: "integer"}, + }, + }, + }, + }, + wantErrors: 1, + }, + { + name: "boolean validation", + flags: []flagset.Flag{ + { + Key: "config", + Type: flagset.ObjectType, + DefaultValue: map[string]any{"enabled": true}, + Schema: &flagset.ObjectSchema{ + Type: "object", + Properties: map[string]*flagset.ObjectSchema{ + "enabled": {Type: "boolean"}, + }, + }, + }, + }, + wantErrors: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issues := ValidateDefaultValues(tt.flags) + if len(issues) != tt.wantErrors { + t.Errorf("got %d validation errors, want %d: %v", len(issues), tt.wantErrors, issues) + } + }) + } +} + // Sample test for FormatValidationError func TestFormatValidationError_SortsByPath(t *testing.T) { issues := []ValidationError{ diff --git a/sample/sample_manifest.json b/sample/sample_manifest.json index 81a86dc0..54f758fb 100644 --- a/sample/sample_manifest.json +++ b/sample/sample_manifest.json @@ -27,7 +27,15 @@ "primaryColor": "#007bff", "secondaryColor": "#6c757d" }, - "description": "Allows customization of theme colors." + "description": "Allows customization of theme colors.", + "schema": { + "type": "object", + "properties": { + "primaryColor": { "type": "string" }, + "secondaryColor": { "type": "string" } + }, + "required": ["primaryColor"] + } } } } \ No newline at end of file diff --git a/schema/v0/flag-manifest.json b/schema/v0/flag-manifest.json index 261d84e0..de93346f 100644 --- a/schema/v0/flag-manifest.json +++ b/schema/v0/flag-manifest.json @@ -100,10 +100,57 @@ }, "defaultValue": { "description": "The value returned from an unsuccessful flag evaluation" + }, + "schema": { + "$ref": "#/$defs/propertySchema", + "description": "An optional JSON Schema subset describing the structure of the object value" } }, "type": "object" }, + "propertySchema": { + "properties": { + "type": { + "type": "string", + "enum": [ + "object", + "array", + "string", + "number", + "integer", + "boolean" + ], + "description": "The JSON Schema type" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/propertySchema" + }, + "type": "object", + "description": "Property schemas for object types" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Required property names for object types" + }, + "items": { + "$ref": "#/$defs/propertySchema", + "description": "Schema for array element types" + }, + "additionalProperties": { + "type": "boolean", + "description": "Whether additional properties are allowed for object types" + } + }, + "type": "object", + "required": [ + "type" + ], + "description": "A JSON Schema subset for describing object flag value structure" + }, "stringFlag": { "properties": { "flagType": { diff --git a/schema/v0/testdata/negative/schema-invalid-type-value.json b/schema/v0/testdata/negative/schema-invalid-type-value.json new file mode 100644 index 00000000..ca2173f8 --- /dev/null +++ b/schema/v0/testdata/negative/schema-invalid-type-value.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "badSchema": { + "flagType": "object", + "defaultValue": { "a": 1 }, + "schema": { + "type": "map" + } + } + } +} diff --git a/schema/v0/testdata/negative/schema-missing-type.json b/schema/v0/testdata/negative/schema-missing-type.json new file mode 100644 index 00000000..2b8e6d38 --- /dev/null +++ b/schema/v0/testdata/negative/schema-missing-type.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "badSchema": { + "flagType": "object", + "defaultValue": { "a": 1 }, + "schema": { + "properties": { + "a": { "type": "integer" } + } + } + } + } +} diff --git a/schema/v0/testdata/negative/schema-nested-missing-type.json b/schema/v0/testdata/negative/schema-nested-missing-type.json new file mode 100644 index 00000000..4dd1de2b --- /dev/null +++ b/schema/v0/testdata/negative/schema-nested-missing-type.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "nestedBad": { + "flagType": "object", + "defaultValue": { + "inner": { "x": 1 } + }, + "schema": { + "type": "object", + "properties": { + "inner": { + "properties": { + "x": { "type": "integer" } + } + } + } + } + } + } +} diff --git a/schema/v0/testdata/positive/mixed-flags-with-and-without-schema.json b/schema/v0/testdata/positive/mixed-flags-with-and-without-schema.json new file mode 100644 index 00000000..ba50bf81 --- /dev/null +++ b/schema/v0/testdata/positive/mixed-flags-with-and-without-schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "enableBeta": { + "flagType": "boolean", + "defaultValue": false, + "description": "A boolean flag coexisting with schema-typed object flags." + }, + "greeting": { + "flagType": "string", + "defaultValue": "Hello!", + "description": "A string flag." + }, + "untypedObject": { + "flagType": "object", + "defaultValue": { "key": "value" }, + "description": "An object flag without a schema." + }, + "typedObject": { + "flagType": "object", + "defaultValue": { "color": "#000" }, + "description": "An object flag with a schema.", + "schema": { + "type": "object", + "properties": { + "color": { "type": "string" } + }, + "required": ["color"] + } + } + } +} diff --git a/schema/v0/testdata/positive/object-flag-with-full-schema.json b/schema/v0/testdata/positive/object-flag-with-full-schema.json new file mode 100644 index 00000000..20ea81d5 --- /dev/null +++ b/schema/v0/testdata/positive/object-flag-with-full-schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "dashboardConfig": { + "flagType": "object", + "defaultValue": { + "title": "My Dashboard", + "refreshInterval": 30, + "showHeader": true, + "layout": { + "columns": 3 + }, + "widgets": ["chart", "table"] + }, + "description": "Dashboard configuration with all schema features.", + "schema": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "refreshInterval": { "type": "integer" }, + "showHeader": { "type": "boolean" }, + "layout": { + "type": "object", + "properties": { + "columns": { "type": "integer" } + }, + "required": ["columns"], + "additionalProperties": false + }, + "widgets": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["title", "refreshInterval"], + "additionalProperties": true + } + } + } +} diff --git a/schema/v0/testdata/positive/object-flag-without-schema.json b/schema/v0/testdata/positive/object-flag-without-schema.json new file mode 100644 index 00000000..75a79386 --- /dev/null +++ b/schema/v0/testdata/positive/object-flag-without-schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "legacyConfig": { + "flagType": "object", + "defaultValue": { + "anything": "goes", + "nested": { "is": "fine" } + }, + "description": "An object flag without a schema remains valid for backward compatibility." + } + } +}