From 1372fc40bf2a5a041d1e2d986ec05df9e5d8b59c Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Tue, 14 Apr 2026 15:55:55 -0400 Subject: [PATCH 1/3] feat(generators): expose underlying OpenFeature client on generated clients Allow users to access the underlying OpenFeature SDK client for ad-hoc flag evaluations beyond what is defined in the manifest. Signed-off-by: Kris Coleman --- internal/cmd/testdata/success_angular.golden | 8 +++++++ internal/cmd/testdata/success_csharp.golden | 5 ++++ internal/cmd/testdata/success_go.golden | 24 +++++++++++--------- internal/cmd/testdata/success_java.golden | 11 +++++++++ internal/cmd/testdata/success_nodejs.golden | 4 ++++ internal/cmd/testdata/success_react.golden | 7 ++++++ internal/generators/angular/angular.tmpl | 8 +++++++ internal/generators/csharp/csharp.tmpl | 5 ++++ internal/generators/golang/golang.tmpl | 8 ++++--- internal/generators/java/java.tmpl | 11 +++++++++ internal/generators/nodejs/nodejs.tmpl | 4 ++++ internal/generators/react/react.tmpl | 8 ++++++- test/csharp-integration/Program.cs | 3 +++ test/go-integration/test.go | 3 +++ test/nodejs-integration/test.ts | 6 +++++ 15 files changed, 100 insertions(+), 15 deletions(-) diff --git a/internal/cmd/testdata/success_angular.golden b/internal/cmd/testdata/success_angular.golden index 221a7523..cb2063d0 100644 --- a/internal/cmd/testdata/success_angular.golden +++ b/internal/cmd/testdata/success_angular.golden @@ -101,6 +101,14 @@ export type FlagKey = (typeof FlagKeys)[keyof typeof FlagKeys]; export class GeneratedFeatureFlagService { private readonly flagService = inject(FeatureFlagService); + /** + * The underlying FeatureFlagService for ad-hoc flag evaluations + * beyond what's defined in the manifest. + */ + get client(): FeatureFlagService { + return this.flagService; + } + /** * Get evaluation details for the `discountPercentage` flag. diff --git a/internal/cmd/testdata/success_csharp.golden b/internal/cmd/testdata/success_csharp.golden index a77ce093..a3214005 100644 --- a/internal/cmd/testdata/success_csharp.golden +++ b/internal/cmd/testdata/success_csharp.golden @@ -75,6 +75,11 @@ namespace TestNamespace { _client = client ?? throw new ArgumentNullException(nameof(client)); } + + /// + /// The underlying OpenFeature client for ad-hoc flag evaluations. + /// + public IFeatureClient Client => _client; /// /// Discount percentage applied to purchases. /// diff --git a/internal/cmd/testdata/success_go.golden b/internal/cmd/testdata/success_go.golden index 64861abe..92da0ba2 100644 --- a/internal/cmd/testdata/success_go.golden +++ b/internal/cmd/testdata/success_go.golden @@ -24,7 +24,9 @@ type ( evaluationDetails[T any] func(context.Context, openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[T], error) ) -var client = openfeature.NewDefaultClient() +// Client is the underlying OpenFeature client used for flag evaluations. +// It can be used directly for ad-hoc flag evaluations beyond what is defined in the manifest. +var Client = openfeature.NewDefaultClient() // DiscountPercentage returns the value of the "discountPercentage" feature flag. // Discount percentage applied to purchases. @@ -41,10 +43,10 @@ var DiscountPercentage = struct { }{ Stringer: stringer("discountPercentage"), Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) float64 { - return client.Float(ctx, "discountPercentage", 0.15, evalCtx) + return Client.Float(ctx, "discountPercentage", 0.15, evalCtx) }, ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[float64], error) { - return client.FloatValueDetails(ctx, "discountPercentage", 0.15, evalCtx) + return Client.FloatValueDetails(ctx, "discountPercentage", 0.15, evalCtx) }, } @@ -63,10 +65,10 @@ var EnableFeatureA = struct { }{ Stringer: stringer("enableFeatureA"), Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) bool { - return client.Boolean(ctx, "enableFeatureA", false, evalCtx) + 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) + return Client.BooleanValueDetails(ctx, "enableFeatureA", false, evalCtx) }, } @@ -85,10 +87,10 @@ var GreetingMessage = struct { }{ Stringer: stringer("greetingMessage"), Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) string { - return client.String(ctx, "greetingMessage", "Hello there!", evalCtx) + 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) + return Client.StringValueDetails(ctx, "greetingMessage", "Hello there!", evalCtx) }, } @@ -107,10 +109,10 @@ var ThemeCustomization = struct { }{ Stringer: stringer("themeCustomization"), Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) any { - return client.Object(ctx, "themeCustomization", map[string]any{"primaryColor": "#007bff", "secondaryColor": "#6c757d"}, evalCtx) + return Client.Object(ctx, "themeCustomization", map[string]any{"primaryColor": "#007bff", "secondaryColor": "#6c757d"}, evalCtx) }, ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[any], error) { - return client.ObjectValueDetails(ctx, "themeCustomization", map[string]any{"primaryColor": "#007bff", "secondaryColor": "#6c757d"}, evalCtx) + return Client.ObjectValueDetails(ctx, "themeCustomization", map[string]any{"primaryColor": "#007bff", "secondaryColor": "#6c757d"}, evalCtx) }, } @@ -129,9 +131,9 @@ var UsernameMaxLength = struct { }{ Stringer: stringer("usernameMaxLength"), Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) int64 { - return client.Int(ctx, "usernameMaxLength", 50, evalCtx) + return Client.Int(ctx, "usernameMaxLength", 50, evalCtx) }, ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[int64], error) { - return client.IntValueDetails(ctx, "usernameMaxLength", 50, evalCtx) + return Client.IntValueDetails(ctx, "usernameMaxLength", 50, evalCtx) }, } diff --git a/internal/cmd/testdata/success_java.golden b/internal/cmd/testdata/success_java.golden index de285270..8749455f 100644 --- a/internal/cmd/testdata/success_java.golden +++ b/internal/cmd/testdata/success_java.golden @@ -125,6 +125,12 @@ public final class OpenFeature { * Returns the evaluation details containing the flag value and metadata */ FlagEvaluationDetails usernameMaxLengthDetails(EvaluationContext ctx); + + /** + * Returns the underlying OpenFeature client for ad-hoc flag evaluations. + * @return The OpenFeature Client instance + */ + Client getOpenFeatureClient(); } private static final class OpenFeatureGeneratedClient implements GeneratedClient { @@ -180,6 +186,11 @@ public final class OpenFeature { public FlagEvaluationDetails usernameMaxLengthDetails(EvaluationContext ctx) { return client.getIntegerDetails("usernameMaxLength", 50, ctx); } + + @Override + public Client getOpenFeatureClient() { + return client; + } } public static GeneratedClient getClient() { diff --git a/internal/cmd/testdata/success_nodejs.golden b/internal/cmd/testdata/success_nodejs.golden index f1f7d4a6..6107ca4f 100644 --- a/internal/cmd/testdata/success_nodejs.golden +++ b/internal/cmd/testdata/success_nodejs.golden @@ -6,6 +6,7 @@ import { JsonValue, } from "@openfeature/server-sdk"; import type { + Client, EvaluationContext, EvaluationDetails, FlagEvaluationOptions, @@ -26,6 +27,8 @@ export const FlagKeys = { } as const; export interface GeneratedClient { + /** The underlying OpenFeature client for ad-hoc flag evaluations. */ + readonly client: Client; /** * Discount percentage applied to purchases. * @@ -206,6 +209,7 @@ export function getGeneratedClient(domainOrContext?: string | EvaluationContext, const client = domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context) return { + client, discountPercentage: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { return client.getNumberValue("discountPercentage", 0.15, context, options); }, diff --git a/internal/cmd/testdata/success_react.golden b/internal/cmd/testdata/success_react.golden index 17a219ac..444040f2 100644 --- a/internal/cmd/testdata/success_react.golden +++ b/internal/cmd/testdata/success_react.golden @@ -6,6 +6,7 @@ import { type FlagQuery, useFlag, useSuspenseFlag, + useOpenFeatureClient, JsonValue } from "@openfeature/react-sdk"; @@ -158,3 +159,9 @@ export const useUsernameMaxLength = (options?: ReactFlagEvaluationOptions): Flag export const useSuspenseUsernameMaxLength = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery => { return useSuspenseFlag("usernameMaxLength", 50, options); }; + +/** + * Re-exported hook for accessing the underlying OpenFeature client + * for ad-hoc flag evaluations beyond what's defined in the manifest. + */ +export { useOpenFeatureClient }; diff --git a/internal/generators/angular/angular.tmpl b/internal/generators/angular/angular.tmpl index 37e1a074..b2ac0eee 100644 --- a/internal/generators/angular/angular.tmpl +++ b/internal/generators/angular/angular.tmpl @@ -79,6 +79,14 @@ export type FlagKey = (typeof FlagKeys)[keyof typeof FlagKeys]; export class GeneratedFeatureFlagService { private readonly flagService = inject(FeatureFlagService); + /** + * The underlying FeatureFlagService for ad-hoc flag evaluations + * beyond what's defined in the manifest. + */ + get client(): FeatureFlagService { + return this.flagService; + } + {{ range .Flagset.Flags }} /** * Get evaluation details for the `{{ .Key }}` flag. diff --git a/internal/generators/csharp/csharp.tmpl b/internal/generators/csharp/csharp.tmpl index f09bf644..1e492c5b 100644 --- a/internal/generators/csharp/csharp.tmpl +++ b/internal/generators/csharp/csharp.tmpl @@ -70,6 +70,11 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else _client = client ?? throw new ArgumentNullException(nameof(client)); } + /// + /// The underlying OpenFeature client for ad-hoc flag evaluations. + /// + public IFeatureClient Client => _client; + {{- range .Flagset.Flags }} /// /// {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} diff --git a/internal/generators/golang/golang.tmpl b/internal/generators/golang/golang.tmpl index c8898bc3..48653ce6 100644 --- a/internal/generators/golang/golang.tmpl +++ b/internal/generators/golang/golang.tmpl @@ -23,7 +23,9 @@ type ( evaluationDetails[T any] func(context.Context, openfeature.EvaluationContext) (openfeature.GenericEvaluationDetails[T], error) ) -var client = openfeature.NewDefaultClient() +// Client is the underlying OpenFeature client used for flag evaluations. +// It can be used directly for ad-hoc flag evaluations beyond what is defined in the manifest. +var Client = openfeature.NewDefaultClient() {{- range .Flagset.Flags }} // {{ .Key | ToPascal }} returns the value of the "{{ .Key }}" feature flag. @@ -41,10 +43,10 @@ var {{ .Key | ToPascal }} = struct { }{ Stringer: stringer({{ .Key | Quote }}), 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) + 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) + return Client.{{ .Type | OpenFeatureType }}ValueDetails(ctx, {{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "Object" }}{{.DefaultValue | ToMapLiteral }}{{- else }}{{ .DefaultValue | QuoteString }}{{- end}}, evalCtx) }, } {{- end}} diff --git a/internal/generators/java/java.tmpl b/internal/generators/java/java.tmpl index 3e3002cc..4b024575 100644 --- a/internal/generators/java/java.tmpl +++ b/internal/generators/java/java.tmpl @@ -44,6 +44,12 @@ public final class OpenFeature { FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx); {{- end }} + + /** + * Returns the underlying OpenFeature client for ad-hoc flag evaluations. + * @return The OpenFeature Client instance + */ + Client getOpenFeatureClient(); } private static final class OpenFeatureGeneratedClient implements GeneratedClient { @@ -65,6 +71,11 @@ public final class OpenFeature { } {{- end }} + + @Override + public Client getOpenFeatureClient() { + return client; + } } public static GeneratedClient getClient() { diff --git a/internal/generators/nodejs/nodejs.tmpl b/internal/generators/nodejs/nodejs.tmpl index dd4d3c79..5c4ef83b 100644 --- a/internal/generators/nodejs/nodejs.tmpl +++ b/internal/generators/nodejs/nodejs.tmpl @@ -6,6 +6,7 @@ import { JsonValue, } from "@openfeature/server-sdk"; import type { + Client, EvaluationContext, EvaluationDetails, FlagEvaluationOptions, @@ -20,6 +21,8 @@ export const FlagKeys = { } as const; export interface GeneratedClient { + /** The underlying OpenFeature client for ad-hoc flag evaluations. */ + readonly client: Client; {{- range .Flagset.Flags }} /** * {{ if .Description }}{{ .Description }}{{ else }}Feature flag{{ end }} @@ -82,6 +85,7 @@ export function getGeneratedClient(domainOrContext?: string | EvaluationContext, const client = domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context) return { + client, {{- range .Flagset.Flags }} {{ .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); diff --git a/internal/generators/react/react.tmpl b/internal/generators/react/react.tmpl index 95401a02..6b6aaf50 100644 --- a/internal/generators/react/react.tmpl +++ b/internal/generators/react/react.tmpl @@ -6,6 +6,7 @@ import { type FlagQuery, useFlag, useSuspenseFlag, + useOpenFeatureClient, JsonValue } from "@openfeature/react-sdk"; @@ -44,4 +45,9 @@ export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions): export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery<{{ if eq (.Type | OpenFeatureType) "object" }}JsonValue{{ else }}{{ .Type | OpenFeatureType }}{{ end }}> => { return useSuspenseFlag({{ .Key | Quote }}, {{ if eq (.Type | OpenFeatureType) "object"}}{{ .DefaultValue | ToJSONString }}{{ else }}{{ .DefaultValue | QuoteString }}{{ end }}, options); }; -{{ end}} \ No newline at end of file +{{ end}} +/** + * Re-exported hook for accessing the underlying OpenFeature client + * for ad-hoc flag evaluations beyond what's defined in the manifest. + */ +export { useOpenFeatureClient }; diff --git a/test/csharp-integration/Program.cs b/test/csharp-integration/Program.cs index 3c3230b6..3c86f9f8 100644 --- a/test/csharp-integration/Program.cs +++ b/test/csharp-integration/Program.cs @@ -25,6 +25,9 @@ static void Main(string[] args) // Test client retrieval from DI var client = serviceProvider.GetRequiredService(); + // Verify the underlying client is accessible + var underlyingClient = client.Client; + // Also test the traditional factory method var clientFromFactory = GeneratedClient.CreateClient(); diff --git a/test/go-integration/test.go b/test/go-integration/test.go index 0a8ebeac..455361a1 100644 --- a/test/go-integration/test.go +++ b/test/go-integration/test.go @@ -105,6 +105,9 @@ func run() error { } fmt.Printf("themeCustomization: %v\n", themeCustomization) + // Verify the underlying client is accessible for ad-hoc evaluations + _ = generated.Client + // Test the String() method functionality for all flags fmt.Printf("enableFeatureA flag key: %s\n", generated.EnableFeatureA.String()) fmt.Printf("discountPercentage flag key: %s\n", generated.DiscountPercentage.String()) diff --git a/test/nodejs-integration/test.ts b/test/nodejs-integration/test.ts index 735b57a3..31c628e2 100644 --- a/test/nodejs-integration/test.ts +++ b/test/nodejs-integration/test.ts @@ -61,6 +61,12 @@ async function main() { const { getGeneratedClient } = await import(clientPath); const client = getGeneratedClient(); + // Verify the underlying client is accessible + if (!client.client) { + throw new Error('Underlying OpenFeature client not exposed'); + } + console.log('โœ… Underlying client accessible'); + console.log('๐Ÿงช Testing flags...'); // Test each flag From 879a7428ea79aee31b6bc6421877df197b54fcd4 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Tue, 12 May 2026 12:42:08 -0400 Subject: [PATCH 2/3] feat(generators): treat Client/client as reserved symbols with collision warning Flag keys that transform to a reserved generator symbol (Client in Go, client in Node.js) are excluded from the generated output and a warning is emitted at generation time. Documents the reserved symbols per generator in the generators README. Signed-off-by: Kris Coleman --- internal/generators/README.md | 21 ++++++++++++++++++++- internal/generators/golang/golang.go | 23 +++++++++++++++++++++++ internal/generators/nodejs/nodejs.go | 24 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/generators/README.md b/internal/generators/README.md index a3491976..f12e832c 100644 --- a/internal/generators/README.md +++ b/internal/generators/README.md @@ -55,4 +55,23 @@ To add a new generator, follow these steps: 6. Write tests for your generator to ensure it works as expected. 7. Update the documentation to include information about your new generator. -We appreciate your contributions and look forward to seeing your new generators! \ No newline at end of file +We appreciate your contributions and look forward to seeing your new generators! + +## Reserved Keywords + +Each generator reserves certain symbol names that it exports in the generated output. If a flag key transforms to a reserved name, that flag will be **excluded** from the generated output and a warning will be printed. + +| Generator | Reserved names | Transform applied | +|-----------|---------------|-------------------| +| Go | `Client` | `ToPascal` | +| Node.js | `client` | `ToCamel` | + +For example, a flag key `"client"` in a Go manifest would transform to `Client` (via `ToPascal`), colliding with the exported `var Client` that the Go generator places in every generated file. The flag will be skipped and the following warning emitted: + +``` +Flag "client" transforms to "Client" which is a reserved symbol in the Go generator. This flag will be excluded from the generated output. +``` + +To avoid this, rename any flags whose transformed name matches a reserved symbol. + +When adding a new generator, document its reserved names in the table above and enforce them in the generator's `Generate()` method using the same pattern. \ No newline at end of file diff --git a/internal/generators/golang/golang.go b/internal/generators/golang/golang.go index c45d0a81..898d9812 100644 --- a/internal/generators/golang/golang.go +++ b/internal/generators/golang/golang.go @@ -10,8 +10,10 @@ import ( "strings" "text/template" + "github.com/iancoleman/strcase" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/logger" "golang.org/x/tools/imports" ) @@ -128,7 +130,28 @@ func formatNestedValue(value any) string { } } +// reservedNames are symbols exported by the Go generator itself. Flag keys +// that transform (via ToPascal) to one of these names will be excluded from +// the generated output and a warning will be emitted. +var reservedNames = map[string]bool{ + "Client": true, +} + func (g *GolangGenerator) Generate(params *generators.Params[Params]) error { + filtered := &flagset.Flagset{} + for _, flag := range g.Flagset.Flags { + transformed := strcase.ToCamel(flag.Key) + if reservedNames[transformed] { + logger.Default.Warning(fmt.Sprintf( + "Flag %q transforms to %q which is a reserved symbol in the Go generator. This flag will be excluded from the generated output.", + flag.Key, transformed, + )) + continue + } + filtered.Flags = append(filtered.Flags, flag) + } + g.Flagset = filtered + funcs := template.FuncMap{ "SupportImports": supportImports, "OpenFeatureType": openFeatureType, diff --git a/internal/generators/nodejs/nodejs.go b/internal/generators/nodejs/nodejs.go index dc6469af..3ba049e5 100644 --- a/internal/generators/nodejs/nodejs.go +++ b/internal/generators/nodejs/nodejs.go @@ -3,10 +3,13 @@ package nodejs import ( _ "embed" "encoding/json" + "fmt" "text/template" + "github.com/iancoleman/strcase" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/logger" ) type NodejsGenerator struct { @@ -43,7 +46,28 @@ func toJSONString(value any) string { return string(bytes) } +// reservedNames are symbols exported by the Node.js generator itself. Flag +// keys that transform (via ToCamel) to one of these names will be excluded +// from the generated output and a warning will be emitted. +var reservedNames = map[string]bool{ + "client": true, +} + func (g *NodejsGenerator) Generate(params *generators.Params[Params]) error { + filtered := &flagset.Flagset{} + for _, flag := range g.Flagset.Flags { + transformed := strcase.ToLowerCamel(flag.Key) + if reservedNames[transformed] { + logger.Default.Warning(fmt.Sprintf( + "Flag %q transforms to %q which is a reserved symbol in the Node.js generator. This flag will be excluded from the generated output.", + flag.Key, transformed, + )) + continue + } + filtered.Flags = append(filtered.Flags, flag) + } + g.Flagset = filtered + funcs := template.FuncMap{ "OpenFeatureType": openFeatureType, "ToJSONString": toJSONString, From 7390c517a0fe3d647c02fc3846dc10fae83a9c2a Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Tue, 23 Jun 2026 09:32:15 -0400 Subject: [PATCH 3/3] fix(generators): reserve useOpenFeatureClient in the React generator The React generator re-exports the useOpenFeatureClient hook but did not guard against a flag key transforming into that same name. A flag key such as "openFeatureClient" would generate `export const useOpenFeatureClient`, duplicating the re-exported symbol and breaking the generated TypeScript. React now filters reserved symbols like the Go and Node.js generators do. The duplicated inline filter loops are consolidated into a shared generators.FilterReservedFlags helper covered by a unit test. C# and Java intentionally do not reserve a name: their exposed client symbols (a `Client` property and a no-arg `getOpenFeatureClient()`) cannot collide with the `Async`/`(ctx)` members generated from flag keys, so a guard there would falsely exclude valid flags. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Coleman --- internal/generators/README.md | 15 +++--- internal/generators/golang/golang.go | 15 +----- internal/generators/nodejs/nodejs.go | 16 +----- internal/generators/react/react.go | 12 +++++ internal/generators/reserved.go | 34 ++++++++++++ internal/generators/reserved_test.go | 80 ++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 internal/generators/reserved.go create mode 100644 internal/generators/reserved_test.go diff --git a/internal/generators/README.md b/internal/generators/README.md index f12e832c..cd2459d7 100644 --- a/internal/generators/README.md +++ b/internal/generators/README.md @@ -61,12 +61,13 @@ We appreciate your contributions and look forward to seeing your new generators! Each generator reserves certain symbol names that it exports in the generated output. If a flag key transforms to a reserved name, that flag will be **excluded** from the generated output and a warning will be printed. -| Generator | Reserved names | Transform applied | -|-----------|---------------|-------------------| -| Go | `Client` | `ToPascal` | -| Node.js | `client` | `ToCamel` | +| Generator | Reserved names | Transform applied | +|-----------|------------------------|------------------------------| +| Go | `Client` | `ToPascal` | +| Node.js | `client` | `ToCamel` | +| React | `useOpenFeatureClient` | `use` + `ToPascal` | -For example, a flag key `"client"` in a Go manifest would transform to `Client` (via `ToPascal`), colliding with the exported `var Client` that the Go generator places in every generated file. The flag will be skipped and the following warning emitted: +A generator only reserves a name when a flag key could actually transform into a symbol it already emits. For example, a flag key `"client"` in a Go manifest transforms to `Client` (via `ToPascal`), colliding with the exported `var Client` that the Go generator places in every generated file. Likewise, a flag key `"openFeatureClient"` for the React generator produces the hook `useOpenFeatureClient`, colliding with the re-exported hook of the same name. The flag is skipped and a warning is emitted: ``` Flag "client" transforms to "Client" which is a reserved symbol in the Go generator. This flag will be excluded from the generated output. @@ -74,4 +75,6 @@ Flag "client" transforms to "Client" which is a reserved symbol in the Go genera To avoid this, rename any flags whose transformed name matches a reserved symbol. -When adding a new generator, document its reserved names in the table above and enforce them in the generator's `Generate()` method using the same pattern. \ No newline at end of file +Generators whose exposed client symbol cannot collide with a flag-generated member do not reserve a name. For instance, C# exposes a `Client` property while flags generate `Async`/`DetailsAsync` methods, and Java exposes a no-arg `getOpenFeatureClient()` that a flag method would only ever overload โ€” so neither needs an entry above. + +When adding a new generator, use the shared `generators.FilterReservedFlags` helper in the generator's `Generate()` method, and add a row here only if a flag key can genuinely transform into a symbol the generator exports. \ No newline at end of file diff --git a/internal/generators/golang/golang.go b/internal/generators/golang/golang.go index 898d9812..c5306d87 100644 --- a/internal/generators/golang/golang.go +++ b/internal/generators/golang/golang.go @@ -13,7 +13,6 @@ import ( "github.com/iancoleman/strcase" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" - "github.com/open-feature/cli/internal/logger" "golang.org/x/tools/imports" ) @@ -138,19 +137,7 @@ var reservedNames = map[string]bool{ } func (g *GolangGenerator) Generate(params *generators.Params[Params]) error { - filtered := &flagset.Flagset{} - for _, flag := range g.Flagset.Flags { - transformed := strcase.ToCamel(flag.Key) - if reservedNames[transformed] { - logger.Default.Warning(fmt.Sprintf( - "Flag %q transforms to %q which is a reserved symbol in the Go generator. This flag will be excluded from the generated output.", - flag.Key, transformed, - )) - continue - } - filtered.Flags = append(filtered.Flags, flag) - } - g.Flagset = filtered + g.Flagset = generators.FilterReservedFlags(g.Flagset, "Go", reservedNames, strcase.ToCamel) funcs := template.FuncMap{ "SupportImports": supportImports, diff --git a/internal/generators/nodejs/nodejs.go b/internal/generators/nodejs/nodejs.go index 3ba049e5..a06b0d3b 100644 --- a/internal/generators/nodejs/nodejs.go +++ b/internal/generators/nodejs/nodejs.go @@ -3,13 +3,11 @@ package nodejs import ( _ "embed" "encoding/json" - "fmt" "text/template" "github.com/iancoleman/strcase" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" - "github.com/open-feature/cli/internal/logger" ) type NodejsGenerator struct { @@ -54,19 +52,7 @@ var reservedNames = map[string]bool{ } func (g *NodejsGenerator) Generate(params *generators.Params[Params]) error { - filtered := &flagset.Flagset{} - for _, flag := range g.Flagset.Flags { - transformed := strcase.ToLowerCamel(flag.Key) - if reservedNames[transformed] { - logger.Default.Warning(fmt.Sprintf( - "Flag %q transforms to %q which is a reserved symbol in the Node.js generator. This flag will be excluded from the generated output.", - flag.Key, transformed, - )) - continue - } - filtered.Flags = append(filtered.Flags, flag) - } - g.Flagset = filtered + g.Flagset = generators.FilterReservedFlags(g.Flagset, "Node.js", reservedNames, strcase.ToLowerCamel) funcs := template.FuncMap{ "OpenFeatureType": openFeatureType, diff --git a/internal/generators/react/react.go b/internal/generators/react/react.go index f3dcb9cd..6d093800 100644 --- a/internal/generators/react/react.go +++ b/internal/generators/react/react.go @@ -5,6 +5,7 @@ import ( "encoding/json" "text/template" + "github.com/iancoleman/strcase" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" ) @@ -43,7 +44,18 @@ func toJSONString(value any) string { return string(bytes) } +// reservedNames are symbols exported by the React generator itself. Flag keys +// whose generated hook name (use + ToPascal) matches one of these will be +// excluded from the generated output and a warning will be emitted. +var reservedNames = map[string]bool{ + "useOpenFeatureClient": true, +} + func (g *ReactGenerator) Generate(params *generators.Params[Params]) error { + g.Flagset = generators.FilterReservedFlags(g.Flagset, "React", reservedNames, func(key string) string { + return "use" + strcase.ToCamel(key) + }) + funcs := template.FuncMap{ "OpenFeatureType": openFeatureType, "ToJSONString": toJSONString, diff --git a/internal/generators/reserved.go b/internal/generators/reserved.go new file mode 100644 index 00000000..f6bbe5a7 --- /dev/null +++ b/internal/generators/reserved.go @@ -0,0 +1,34 @@ +package generators + +import ( + "fmt" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/logger" +) + +// FilterReservedFlags returns a copy of fs with any flag removed whose key, +// once transformed by the generator, collides with a symbol the generator +// reserves for itself (such as the exposed underlying OpenFeature client). A +// warning is emitted for each excluded flag so users understand why it is +// missing from the generated output. The reserved symbol always wins the +// collision. +// +// transform maps a flag key to the symbol name the generator would emit for it +// (e.g. ToPascal for Go). reserved holds the symbol names the generator +// reserves. generatorName is used only in the warning message. +func FilterReservedFlags(fs *flagset.Flagset, generatorName string, reserved map[string]bool, transform func(string) string) *flagset.Flagset { + filtered := &flagset.Flagset{} + for _, flag := range fs.Flags { + symbol := transform(flag.Key) + if reserved[symbol] { + logger.Default.Warning(fmt.Sprintf( + "Flag %q transforms to %q which is a reserved symbol in the %s generator. This flag will be excluded from the generated output.", + flag.Key, symbol, generatorName, + )) + continue + } + filtered.Flags = append(filtered.Flags, flag) + } + return filtered +} diff --git a/internal/generators/reserved_test.go b/internal/generators/reserved_test.go new file mode 100644 index 00000000..cc8b8b4e --- /dev/null +++ b/internal/generators/reserved_test.go @@ -0,0 +1,80 @@ +package generators + +import ( + "testing" + + "github.com/iancoleman/strcase" + "github.com/open-feature/cli/internal/flagset" +) + +func keys(fs *flagset.Flagset) []string { + out := make([]string, 0, len(fs.Flags)) + for _, f := range fs.Flags { + out = append(out, f.Key) + } + return out +} + +func TestFilterReservedFlags(t *testing.T) { + tests := []struct { + name string + generator string + reserved map[string]bool + transform func(string) string + input []string + want []string + }{ + { + name: "drops colliding flag, keeps the rest", + generator: "Go", + reserved: map[string]bool{"Client": true}, + transform: strcase.ToCamel, + input: []string{"client", "discountPercentage"}, + want: []string{"discountPercentage"}, + }, + { + name: "no collision keeps every flag", + generator: "Go", + reserved: map[string]bool{"Client": true}, + transform: strcase.ToCamel, + input: []string{"discountPercentage", "enableFeatureA"}, + want: []string{"discountPercentage", "enableFeatureA"}, + }, + { + name: "react hook name collision", + generator: "React", + reserved: map[string]bool{"useOpenFeatureClient": true}, + transform: func(key string) string { return "use" + strcase.ToCamel(key) }, + input: []string{"openFeatureClient", "greetingMessage"}, + want: []string{"greetingMessage"}, + }, + { + name: "node lower-camel collision", + generator: "Node.js", + reserved: map[string]bool{"client": true}, + transform: strcase.ToLowerCamel, + input: []string{"client", "greetingMessage"}, + want: []string{"greetingMessage"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := &flagset.Flagset{} + for _, k := range tt.input { + fs.Flags = append(fs.Flags, flagset.Flag{Key: k, Type: flagset.StringType}) + } + + got := keys(FilterReservedFlags(fs, tt.generator, tt.reserved, tt.transform)) + + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + } + }) + } +}