diff --git a/cmd/generate.go b/cmd/generate.go index d89f9b4..bca65d8 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -8,6 +8,7 @@ import ( "github.com/open-feature/cli/internal/generators" "github.com/open-feature/cli/internal/generators/csharp" "github.com/open-feature/cli/internal/generators/golang" + "github.com/open-feature/cli/internal/generators/nestjs" "github.com/open-feature/cli/internal/generators/nodejs" "github.com/open-feature/cli/internal/generators/python" "github.com/open-feature/cli/internal/generators/react" @@ -152,6 +153,60 @@ func getGenerateReactCmd() *cobra.Command { return reactCmd } +func GetGenerateNestJsCmd() *cobra.Command { + nestJsCmd := &cobra.Command{ + Use: "nestjs", + Short: "Generate typesafe NestJS decorators.", + Long: `Generate typesafe NestJS decorators compatible with the OpenFeature NestJS SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate.nestjs") + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + outputPath := config.GetOutputPath(cmd) + + logger.Default.GenerationStarted("NestJS") + + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + nestjsParams := generators.Params[nestjs.Params]{ + OutputPath: outputPath, + Custom: nestjs.Params{}, + } + nestjsGenerator := nestjs.NewGenerator(flagset) + logger.Default.Debug("Executing NestJS generator") + err = nestjsGenerator.Generate(&nestjsParams) + if err != nil { + return err + } + + nodejsParams := generators.Params[nodejs.Params]{ + OutputPath: outputPath, + Custom: nodejs.Params{}, + } + nodeGenerator := nodejs.NewGenerator(flagset) + err = nodeGenerator.Generate(&nodejsParams) + if err != nil { + return err + } + + logger.Default.GenerationComplete("NestJS") + + return nil + }, + } + + addStabilityInfo(nestJsCmd) + + return nestJsCmd +} + func getGenerateCSharpCmd() *cobra.Command { csharpCmd := &cobra.Command{ Use: "csharp", @@ -300,5 +355,6 @@ func init() { generators.DefaultManager.Register(getGenerateGoCmd) generators.DefaultManager.Register(getGenerateNodeJSCmd) generators.DefaultManager.Register(getGeneratePythonCmd) - generators.DefaultManager.Register(getGenerateCSharpCmd) + generators.DefaultManager.Register(getGenerateCSharpCmd) + generators.DefaultManager.Register(GetGenerateNestJsCmd) } diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 0bc8466..b2534d5 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -48,6 +48,13 @@ func TestGenerate(t *testing.T) { outputGolden: "testdata/success_nodejs.golden", outputFile: "openfeature.ts", }, + { + name: "NestJS generation success", + command: "nestjs", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_nestjs.golden", + outputFile: "openfeature-decorators.ts", + }, { name: "Python generation success", command: "python", diff --git a/cmd/testdata/success_nestjs.golden b/cmd/testdata/success_nestjs.golden new file mode 100644 index 0000000..ff482ec --- /dev/null +++ b/cmd/testdata/success_nestjs.golden @@ -0,0 +1,194 @@ +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, +} from "@openfeature/nestjs-sdk"; +import { OpenFeatureModule, BooleanFeatureFlag, StringFeatureFlag, NumberFeatureFlag } from "@openfeature/nestjs-sdk"; + +import type { GeneratedClient } from "./openfeature"; +import { getGeneratedClient } from "./openfeature"; + +/** + * 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 `discountPercentage` 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: `discountPercentage` + * - description: `Discount percentage applied to purchases.` + * - default value: `0.15` + * - type: `number` + * + * Usage: + * ```typescript + * @Get("/") + * public async handleRequest( + * @DiscountPercentage() + * discountPercentage: Observable>, + * ) + * ``` + * @param {TypedFeatureProps} props The options for injecting the feature flag. + * @returns {ParameterDecorator} The decorator function. + */ +export function DiscountPercentage(props?: TypedFeatureProps): ParameterDecorator { + return NumberFeatureFlag({ flagKey: "discountPercentage", defaultValue: 0.15, ...props }); +} + +/** + * 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 `usernameMaxLength` 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: `usernameMaxLength` + * - description: `Maximum allowed length for usernames.` + * - default value: `50` + * - type: `number` + * + * Usage: + * ```typescript + * @Get("/") + * public async handleRequest( + * @UsernameMaxLength() + * usernameMaxLength: Observable>, + * ) + * ``` + * @param {TypedFeatureProps} props The options for injecting the feature flag. + * @returns {ParameterDecorator} The decorator function. + */ +export function UsernameMaxLength(props?: TypedFeatureProps): ParameterDecorator { + return NumberFeatureFlag({ flagKey: "usernameMaxLength", defaultValue: 50, ...props }); +} diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index a2107dd..7402c7e 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -28,6 +28,7 @@ openfeature generate [flags] * [openfeature](openfeature.md) - CLI for OpenFeature. * [openfeature generate csharp](openfeature_generate_csharp.md) - Generate typesafe C# client. * [openfeature generate go](openfeature_generate_go.md) - Generate typesafe accessors for OpenFeature. +* [openfeature generate nestjs](openfeature_generate_nestjs.md) - Generate typesafe NestJS decorators. * [openfeature generate nodejs](openfeature_generate_nodejs.md) - Generate typesafe Node.js client. * [openfeature generate python](openfeature_generate_python.md) - Generate typesafe Python client. * [openfeature generate react](openfeature_generate_react.md) - Generate typesafe React Hooks. diff --git a/docs/commands/openfeature_generate_nestjs.md b/docs/commands/openfeature_generate_nestjs.md new file mode 100644 index 0000000..4b1af9b --- /dev/null +++ b/docs/commands/openfeature_generate_nestjs.md @@ -0,0 +1,36 @@ + + +## openfeature generate nestjs + +Generate typesafe NestJS decorators. + + +> **Stability**: alpha + +### Synopsis + +Generate typesafe NestJS decorators compatible with the OpenFeature NestJS SDK. + +``` +openfeature generate nestjs [flags] +``` + +### Options + +``` + -h, --help help for nestjs +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts + -o, --output string Path to where the generated files should be saved +``` + +### SEE ALSO + +* [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. + diff --git a/internal/generators/nestjs/nestjs.go b/internal/generators/nestjs/nestjs.go new file mode 100644 index 0000000..74b3227 --- /dev/null +++ b/internal/generators/nestjs/nestjs.go @@ -0,0 +1,56 @@ +package nestjs + +import ( + _ "embed" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type NestJsGenerator struct { + generators.CommonGenerator +} + +type Params struct { +} + +//go:embed nestjs.tmpl +var nestJsTmpl string + +func openFeatureType(t flagset.FlagType) string { + switch t { + case flagset.IntType: + fallthrough + case flagset.FloatType: + return "number" + case flagset.BoolType: + return "boolean" + case flagset.StringType: + return "string" + default: + return "" + } +} + +func (g *NestJsGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "OpenFeatureType": openFeatureType, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: Params{}, + } + + return g.GenerateFile(funcs, nestJsTmpl, newParams, "openfeature-decorators.ts") +} + +// NewGenerator creates a generator for NestJS. +func NewGenerator(fs *flagset.Flagset) *NestJsGenerator { + return &NestJsGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generators/nestjs/nestjs.tmpl b/internal/generators/nestjs/nestjs.tmpl new file mode 100644 index 0000000..971a74e --- /dev/null +++ b/internal/generators/nestjs/nestjs.tmpl @@ -0,0 +1,120 @@ +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, +} from "@openfeature/nestjs-sdk"; +import { OpenFeatureModule, BooleanFeatureFlag, StringFeatureFlag, NumberFeatureFlag } from "@openfeature/nestjs-sdk"; + +import type { GeneratedClient } from "./openfeature"; +import { getGeneratedClient } from "./openfeature"; + +/** + * 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; +} + +{{ range .Flagset.Flags }} +/** + * Gets the {@link EvaluationDetails} for `{{ .Key }}` 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: `{{ .Key }}` + * - description: `{{ .Description }}` + * - default value: `{{ .DefaultValue }}` + * - type: `{{ .Type | OpenFeatureType }}` + * + * Usage: + * ```typescript + * @Get("/") + * public async handleRequest( + * @{{ .Key | ToPascal }}() + * {{ .Key | ToCamel }}: Observable>, + * ) + * ``` + * @param {TypedFeatureProps} props The options for injecting the feature flag. + * @returns {ParameterDecorator} The decorator function. + */ +export function {{ .Key | ToPascal }}(props?: TypedFeatureProps): ParameterDecorator { + return {{ .Type | OpenFeatureType | ToPascal }}FeatureFlag({ flagKey: {{ .Key | Quote }}, defaultValue: {{ .DefaultValue | QuoteString }}, ...props }); +} +{{ end -}}