diff --git a/README.md b/README.md index c909f78..bbf5ef5 100644 --- a/README.md +++ b/README.md @@ -13,30 +13,46 @@ Release Date

-## Purpose +## 📚 Documentation -Featury is designed to group and manage multiple features within a project. -It provides the flexibility to utilize any pre-existing solution or create your own. -It's easily adjustable to align with the unique needs and objectives of your project. +See [featury.servactory.com](https://featury.servactory.com) for comprehensive guides and API documentation. -[//]: # (## Documentation) +Complete documentation is also available in the [docs](./docs) directory: -[//]: # (See [featury.servactory.com](https://featury.servactory.com) for documentation.) +- [Getting Started](./docs/getting-started.md) +- [Features](./docs/features.md) +- [Groups](./docs/groups.md) +- [Actions](./docs/actions.md) +- [Resources](./docs/resources.md) +- [Conditions](./docs/conditions.md) +- [Working with Features](./docs/working-with-features.md) +- [Info and Introspection](./docs/info-and-introspection.md) +- [Integration](./docs/integration.md) +- [Examples](./docs/examples.md) +- [Best Practices](./docs/best-practices.md) -## Quick Start +## 💡 Why Featury? + +- 🎯 **Unified Feature Management** — Group and manage multiple feature flags through a single interface with automatic prefix handling +- 🔌 **Flexible Integration** — Works with any backend: Flipper, Redis, databases, HTTP APIs, or custom solutions +- 🗂️ **Powerful Organization** — Organize features with prefixes, groups, and nested hierarchies for scalable feature management +- 🔍 **Rich Introspection** — Full visibility into features, actions, and resources through the comprehensive info API +- 🪝 **Lifecycle Hooks** — Before/after callbacks for actions with customizable scope and full context access +- 🛡️ **Type-Safe Resources** — Built on Servactory for robust resource validation, type checking, and automatic coercion + +## 🚀 Quick Start ### Installation +Add Featury to your Gemfile: + ```ruby gem "featury" ``` -### Usage - -#### Basic class for your features +### ApplicationFeature -For instance, assume that you are utilizing Flipper for managing features. -In such a scenario, the base class could potentially be structured as follows: +Create a base class that defines how features interact with your feature flag system: ```ruby class ApplicationFeature < Featury::Base @@ -70,17 +86,9 @@ class ApplicationFeature < Featury::Base end ``` -#### About the `web:` key - -The `web:` key in the action definition allows you to specify which method will be used for web interactions. This is useful for mapping internal action names to external endpoints or UI actions. For example: +### Feature Definitions -- `enabled?` — the method that will be used in the web context to check the state of a feature flag; -- `enable` — the method that will be used in the web context to enable a feature flag; -- `disable` — the method that will be used in the web context to disable a feature flag. - -This mapping helps you clearly separate internal logic from the interface exposed to web clients. - -#### Features of your project +Define features with prefixes, resources, conditions, and groups: ```ruby class User::OnboardingFeature < ApplicationFeature @@ -115,108 +123,29 @@ class PaymentSystemFeature < ApplicationFeature end ``` -The `resource` method provides an indication of how the transmitted information ought to be processed. -Besides the options provided by [Servactory](https://github.com/servactory/servactory), additional ones are available for stipulating the processing mode of the transmitted data. - -If a resource needs to be conveyed as a feature flag option, utilize the `option` parameter: - -```ruby -resource :user, type: User, option: true -``` - -To call a feature without passing a resource, use the `required: false` option (e.g., for managing the global state of the feature). - -```ruby -resource :user, type: User, option: true, required: false -``` - -To transfer a resource to a nested group, utilize the `nested` option: - -```ruby -resource :user, type: User, nested: true -``` - -#### Working with the features of your project - -Each of these actions will be applied to every feature flag. -Subsequently, the outcome of these actions will be contingent upon the combined results of all feature flags. +### Usage ```ruby +# Direct method calls User::OnboardingFeature.enabled?(user:) # => true -User::OnboardingFeature.disabled?(user:) # => false -User::OnboardingFeature.enable(user:) # => true -User::OnboardingFeature.disable(user:) # => true -``` +User::OnboardingFeature.enable(user:) # => true +User::OnboardingFeature.disable(user:) # => true -You can also utilize the `with` method to pass necessary arguments. - -```ruby +# Using .with() method feature = User::OnboardingFeature.with(user:) - feature.enabled? # => true -feature.disabled? # => false -feature.enable # => true -feature.disable # => true -``` - -If a feature flag is deactivated, possibly via automation processes, -the primary feature class subsequently responds with `false` when -queried about its enablement status. - -In the preceding example, there might be a scenario where the payment system is -undergoing technical maintenance and therefore is temporarily shut down. -Consequently, the onboarding process for new users will be halted until further notice. - -#### Feature and Group descriptions - -When defining features and groups, you can provide descriptions to add more context about what they do: - -```ruby -# Adding a feature with description -feature :api, description: "External API integration" - -# Adding a group with description -group PaymentSystemFeature, description: "Payment processing functionality" +feature.enable # => true +feature.disable # => true ``` -These descriptions are preserved in the feature tree and can be accessed via the info method. +## 🤝 Contributing -#### Information about features - -```ruby -info = User::OnboardingFeature.info -``` - -```ruby -# Feature actions information (all actions and web actions) -info.actions.all # All actions: [:enabled?, :disabled?, :enable, :disable, :add] -info.actions.web.all # Web actions: [:enabled?, :disabled?, :enable, :disable, :add] - -# Access specific web actions by their web: option name -info.actions.web.enabled # Returns the method name (:enabled?) that was defined with `web: :enabled?` -info.actions.web.enable # Returns the method name (:enable) that was defined with `web: :enable` -info.actions.web.disable # Returns the method name (:disable) that was defined with `web: :disable` - -# Feature resources information -info.resources # Feature resources of the current class. - -# Feature flags information (with descriptions) -info.features # Feature flags of the current class with their names and descriptions. - -# Feature groups information (with descriptions) -info.groups # Feature groups of the current class with their class references and descriptions. - -# Complete feature tree (features and nested groups) -info.tree.features # Direct features of the current class. -info.tree.groups # Features from nested groups. -``` +This project is intended to be a safe, welcoming space for collaboration. Contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. We recommend reading the [contributing guide](./CONTRIBUTING.md) as well. -## Contributing +## 🙏 Acknowledgments -This project is intended to be a safe, welcoming space for collaboration. -Contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. -We recommend reading the [contributing guide](./CONTRIBUTING.md) as well. +Featury is built and maintained by amazing [contributors](https://github.com/servactory/featury/graphs/contributors). -## License +## 📄 License Featury is available as open source under the terms of the [MIT License](./LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bc66303 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,84 @@ +# Featury Documentation + +Welcome to the comprehensive documentation for Featury, a flexible Ruby gem for managing feature flags with powerful organization capabilities. + +## Table of Contents + +### Getting Started + +- [Getting Started](./getting-started.md) - Installation, setup, and your first feature + +### Core Concepts + +- [Features](./features.md) - Defining features with prefixes and descriptions +- [Groups](./groups.md) - Organizing features into hierarchical groups +- [Actions](./actions.md) - Defining custom actions for feature management +- [Resources](./resources.md) - Type-safe resource definitions with Servactory +- [Conditions](./conditions.md) - Adding conditional logic to features + +### Usage + +- [Working with Features](./working-with-features.md) - Practical usage patterns and examples +- [Info and Introspection](./info-and-introspection.md) - Inspecting features, actions, and resources + +### Integration + +- [Integration](./integration.md) - Backend storage implementations (Flipper, Redis, Database, HTTP APIs) + +### Reference + +- [Examples](./examples.md) - Real-world examples and use cases +- [Best Practices](./best-practices.md) - Recommended patterns and conventions + +## What is Featury? + +Featury is a feature flag management library that provides: + +- **Unified Interface**: Manage multiple feature flags through a single class +- **Flexible Backend**: Works with Flipper, Redis, databases, HTTP APIs, or any custom solution +- **Hierarchical Organization**: Group related features with automatic prefix management +- **Type Safety**: Built on Servactory for robust resource validation +- **Lifecycle Hooks**: Before/after callbacks with customizable scope +- **Rich Introspection**: Complete visibility into your feature configuration + +Visit [featury.servactory.com](https://featury.servactory.com) for comprehensive guides, API documentation, and tutorials. + +## Quick Links + +- [Official Documentation](https://featury.servactory.com) +- [GitHub Repository](https://github.com/servactory/featury) +- [RubyGems](https://rubygems.org/gems/featury) +- [Changelog](https://github.com/servactory/featury/blob/main/CHANGELOG.md) +- [Contributing Guide](https://github.com/servactory/featury/blob/main/CONTRIBUTING.md) + +## Architecture Overview + +Featury is built around several key concepts: + +1. **Base Class**: `ApplicationFeature` defines how features interact with your storage backend +2. **Feature Classes**: Inherit from `ApplicationFeature` and define specific features +3. **Actions**: Custom methods that operate on feature collections (e.g., `enabled?`, `enable`, `disable`) +4. **Resources**: Type-safe inputs validated through Servactory +5. **Groups**: Nested feature hierarchies for organization +6. **Conditions**: Lambda-based validation logic + +## Philosophy + +Featury follows these design principles: + +- **Backend Agnostic**: Actions receive feature names and options - you decide how to store them +- **Convention over Configuration**: Automatic prefix generation from class names +- **Composability**: Combine features, groups, resources, and conditions +- **Transparency**: Full introspection through the `info` API +- **Type Safety**: Leverage Servactory's resource validation + +## Getting Help + +- Read through the documentation in order for a complete understanding +- Check [Examples](./examples.md) for real-world use cases +- Review [Best Practices](./best-practices.md) for recommended patterns +- Open an issue on [GitHub](https://github.com/servactory/featury/issues) for bugs or questions + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](https://github.com/servactory/featury/blob/main/CONTRIBUTING.md) for details. diff --git a/docs/actions.md b/docs/actions.md new file mode 100644 index 0000000..fbbf9be --- /dev/null +++ b/docs/actions.md @@ -0,0 +1,313 @@ +# Actions + +Actions are custom methods that define how your features interact with your feature flag system. They receive feature names and options, and return results based on your system's API. + +## Defining Actions + +Use the `action` method in your base class: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end +``` + +## Action Block Parameters + +Each action block receives two parameters: + +### features: + +An array of feature flag names (symbols) that the action should operate on: + +```ruby +action :enabled? do |features:, **options| + puts features.inspect + # => [:user_onboarding_passage, :billing_api, :billing_webhooks] +end +``` + +When a feature class has: +- Direct features defined with `feature` +- Nested groups defined with `group` + +The `features:` array contains **all feature names from the current class and all nested groups**. + +### **options + +A hash of resources passed as options when the action is called: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + resource :account, type: Account, option: true, required: false + + feature :passage +end + +action :enabled? do |features:, **options| + puts options.inspect + # => { user: #, account: # } + + puts options.values.inspect + # => [#, #] +end + +User::OnboardingFeature.enabled?(user: user, account: account) +``` + +Only resources marked with `option: true` are included in `**options`. See [Resources](./resources.md) for details. + +## Web Mappings + +The `web:` parameter specifies how the action should be represented in web contexts: + +```ruby +action :enabled?, web: :enabled? do |features:, **options| + # Implementation +end +``` + +### Available Web Mappings + +**web: :enabled?** — For checking feature status (read-only) + +**web: :enable** — For enabling features (write) + +**web: :disable** — For disabling features (write) + +**web: :regular** — For actions that don't fit the above categories + +### Accessing Web Mappings + +Web mappings are accessible via the `.info.actions.web` API: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +# Access web mappings +info = ApplicationFeature.info + +info.actions.web.enabled # => :enabled? +info.actions.web.enable # => :enable +info.actions.web.disable # => :disable +info.actions.web.all # => [:enabled?, :disabled?, :enable, :disable, :add] +``` + +Web mappings allow you to: +- Build admin UIs that know which actions are read vs. write +- Generate API endpoints based on action types +- Create permission systems based on action categories + +See [Info and Introspection](./info-and-introspection.md) for complete details. + +## Before Callbacks + +Execute code before **all** actions: + +```ruby +class ApplicationFeature < Featury::Base + before do |action:, features:| + Slack::API::Notify.call!(action: action, features: features) + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end +end +``` + +The `before` callback receives: +- `action:` — Symbol name of the action being called (e.g., `:enable`) +- `features:` — Array of feature flag names + +## After Callbacks + +Execute code after specific actions: + +```ruby +class ApplicationFeature < Featury::Base + after :enabled?, :disabled? do |action:, features:| + Slack::API::Notify.call!(action: action, features: features) + end + + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end +end +``` + +The `after` callback receives the same parameters as `before`. + +### Callback Scope + +**before** — Runs before all actions (no scope specification) + +**after :action1, :action2** — Runs after specific actions only + +```ruby +class ApplicationFeature < Featury::Base + # Runs before ALL actions + before do |action:, features:| + Logger.info("Starting action #{action} for #{features}") + end + + # Runs after ONLY :enabled? and :disabled? + after :enabled?, :disabled? do |action:, features:| + Logger.info("Completed check action #{action}") + end + + # Runs after ONLY :enable, :disable, :add + after :enable, :disable, :add do |action:, features:| + Logger.info("Completed mutating action #{action}") + Cache.clear_for(features) + end +end +``` + +## Common Action Patterns + +### All-Must-Match (AND Logic) + +```ruby +action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } +end +# Returns true only if ALL features are enabled +``` + +### Any-Must-Match (OR Logic) + +```ruby +action :any_enabled?, web: :enabled? do |features:, **options| + features.any? { |feature| Flipper.enabled?(feature, *options.values) } +end +# Returns true if ANY feature is enabled +``` + +### Negation Logic + +```ruby +action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } +end +# Returns true if ANY feature is disabled +``` + +### Aggregate Results + +```ruby +action :status, web: :regular do |features:, **options| + features.map do |feature| + { feature: feature, enabled: Flipper.enabled?(feature, *options.values) } + end +end +# Returns array of hashes with status for each feature +``` + +### Custom Logic + +```ruby +action :percentage_enabled, web: :regular do |features:, **options| + enabled_count = features.count { |feature| Flipper.enabled?(feature, *options.values) } + (enabled_count.to_f / features.size * 100).round(2) +end +# Returns percentage of features enabled +``` + +## Action Inheritance + +Actions defined in the base class are available to all feature classes: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end +end + +class BillingFeature < ApplicationFeature + feature :api +end + +class PaymentFeature < ApplicationFeature + feature :stripe +end + +# Both classes have the :enabled? action +BillingFeature.enabled?(user: user) +PaymentFeature.enabled?(user: user) +``` + +## Calling Actions + +Actions can be called in two ways: + +### Direct Method Calls + +```ruby +User::OnboardingFeature.enabled?(user: user) +User::OnboardingFeature.enable(user: user) +User::OnboardingFeature.disable(user: user) +``` + +### Using .with() + +```ruby +feature = User::OnboardingFeature.with(user: user) + +feature.enabled? +feature.enable +feature.disable +``` + +See [Working with Features](./working-with-features.md) for detailed usage patterns. + +## Next Steps + +- Learn about [Resources](./resources.md) and the `option: true` parameter +- Add [Conditions](./conditions.md) to control when actions are evaluated +- Review [Examples](./examples.md) for real-world action patterns +- Explore [Info and Introspection](./info-and-introspection.md) for runtime action discovery diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..af33f18 --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,612 @@ +# Best Practices + +Recommended patterns and conventions for building maintainable feature flag systems with Featury. + +## Naming Conventions + +### Prefixes + +Use descriptive, lowercase prefixes with underscores: + +```ruby +# Good +prefix :user_onboarding +prefix :billing_system +prefix :payment_gateway + +# Avoid +prefix :uo +prefix :BillingSystem +prefix :payment_gateway_integration_system +``` + +**Guidelines:** +- 2-3 words maximum +- Describes the feature domain +- Uses underscores, not hyphens or camelCase + +### Features + +Use short, descriptive feature names: + +```ruby +# Good +feature :api +feature :webhooks +feature :passage +feature :email_notifications + +# Avoid +feature :api_integration +feature :webhook_endpoint_receiver +feature :p +``` + +**Guidelines:** +- 1-2 words preferred +- Describes the specific capability +- Combined with prefix should be clear: `:billing_api`, `:user_onboarding_passage` + +### Class Names + +Follow Rails/Ruby conventions: + +```ruby +# Good +class User::OnboardingFeature < ApplicationFeature +class BillingFeature < ApplicationFeature +class PaymentSystemFeature < ApplicationFeature + +# Avoid +class UserOnboarding < ApplicationFeature +class Billing_Feature < ApplicationFeature +class PaymentSystem < ApplicationFeature +``` + +**Guidelines:** +- Use modules for namespacing (`User::`, `Admin::`) +- Always suffix with `Feature` +- Use descriptive names that reflect the domain + +## Resource Organization + +### Required vs. Optional + +Be explicit about resource requirements: + +```ruby +# Good - Clear about requirements +class User::ProfileFeature < ApplicationFeature + resource :user, type: User, option: true # Required + resource :avatar, type: String, option: true, required: false # Optional +end + +# Avoid - Ambiguous +class User::ProfileFeature < ApplicationFeature + resource :user, type: User, option: true + resource :avatar, type: String # Is this required? +end +``` + +### option: true Usage + +Only use `option: true` when the resource should be passed to the feature flag system: + +```ruby +# Good - User is passed to Flipper +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end +end + +# Good - Metadata is validated but not passed to Flipper +class AnalyticsFeature < ApplicationFeature + resource :user, type: User, option: true + resource :event_metadata, type: Hash # Only for validation +end + +# Avoid - Everything as option when not needed +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + resource :invoice_id, type: String, option: true # Not needed in Flipper + resource :amount, type: Integer, option: true # Not needed in Flipper +end +``` + +### nested: true Pattern + +Use `nested: true` when resources should flow to nested groups: + +```ruby +# Good - Explicit nesting +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true +end + +# Avoid - Missing nested when required +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true # BillingFeature won't receive :user + + group BillingFeature +end +``` + +## Action Design + +### Aggregation Logic + +Choose aggregation logic that makes sense for your use case: + +```ruby +# All-must-match for critical features +action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } +end + +# Any-can-match for progressive rollout +action :any_enabled?, web: :enabled? do |features:, **options| + features.any? { |feature| Flipper.enabled?(feature, *options.values) } +end + +# Count-based for partial availability +action :percentage_enabled, web: :regular do |features:, **options| + enabled_count = features.count { |feature| Flipper.enabled?(feature, *options.values) } + (enabled_count.to_f / features.size * 100).round(2) +end +``` + +### Web Mappings + +Use consistent web mappings across your application: + +```ruby +# Good - Consistent pattern +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? # Read operation + action :disabled?, web: :regular # Read operation (not primary) + action :enable, web: :enable # Write operation + action :disable, web: :disable # Write operation + action :add, web: :regular # Other operation +end + +# Avoid - Inconsistent mappings +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? + action :enable, web: :regular # Should be :enable + action :disable, web: :enabled? # Should be :disable +end +``` + +### Callbacks + +Use callbacks judiciously: + +```ruby +# Good - Specific after callbacks +class ApplicationFeature < Featury::Base + before do |action:, features:| + Rails.logger.info("Feature action: #{action} on #{features}") + end + + after :enable, :disable do |action:, features:| + FeatureAuditLog.create!(action: action, features: features) + end + + after :enabled?, :disabled? do |action:, features:| + Metrics.track("feature.check", features: features) + end +end + +# Avoid - Heavy operations in callbacks +class ApplicationFeature < Featury::Base + before do |action:, features:| + # Expensive external API call + SlackNotifier.notify_all_channels(action, features) + end +end +``` + +## Condition Patterns + +### Guard Conditions + +Use conditions for business logic, not configuration: + +```ruby +# Good - Business logic +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage +end + +# Avoid - Configuration logic (use feature flags instead) +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { ENV["ONBOARDING_ENABLED"] == "true" } + + feature :passage +end +``` + +### Optional Resource Checks + +Always check for presence when using `required: false`: + +```ruby +# Good +class AnalyticsFeature < ApplicationFeature + resource :user, type: User, option: true, required: false + + condition(lambda do |resources:| + resources.user.nil? || resources.user.analytics_enabled? + end) +end + +# Avoid - Will raise error if user is nil +class AnalyticsFeature < ApplicationFeature + resource :user, type: User, option: true, required: false + + condition ->(resources:) { resources.user.analytics_enabled? } +end +``` + +## Group Organization + +### Logical Grouping + +Group related features together: + +```ruby +# Good - Logical grouping +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + feature :passage + feature :tutorial + feature :welcome_email + + group BillingFeature # Related to onboarding + group PaymentSystemFeature # Related to billing +end + +# Avoid - Unrelated grouping +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + feature :passage + + group AdminPanelFeature # Not related to onboarding + group AnalyticsFeature # Not related to onboarding +end +``` + +### Shared Base Class + +Keep all feature classes using the same base: + +```ruby +# Good +class ApplicationFeature < Featury::Base + # Shared action definitions +end + +class BillingFeature < ApplicationFeature +end + +class PaymentFeature < ApplicationFeature +end + +# Avoid - Different bases unless necessary +class BillingFeatureBase < Featury::Base + # Different actions +end + +class BillingFeature < BillingFeatureBase +end +``` + +## Feature Descriptions + +### Write Clear Descriptions + +Always add descriptions to features and groups: + +```ruby +# Good +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api, description: "External billing API integration" + feature :webhooks, description: "Webhook endpoints for billing events" + feature :invoicing, description: "Automated invoice generation" + + group PaymentSystemFeature, description: "Payment processing features" +end + +# Avoid +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api + feature :webhooks + feature :invoicing + + group PaymentSystemFeature +end +``` + +**Guidelines:** +- Describe what the feature does, not how +- Be concise (one sentence) +- Use consistent terminology + +## Testing Strategies + +### Test Conditions + +```ruby +RSpec.describe User::OnboardingFeature do + let(:user) { create(:user) } + + describe "condition" do + context "when user is awaiting onboarding" do + before do + allow(user).to receive(:onboarding_awaiting?).and_return(true) + allow(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user).and_return(true) + end + + it "proceeds to check feature flags" do + expect(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user) + User::OnboardingFeature.enabled?(user: user) + end + end + + context "when user is not awaiting onboarding" do + before { allow(user).to receive(:onboarding_awaiting?).and_return(false) } + + it "returns false without checking feature flags" do + expect(Flipper).not_to receive(:enabled?) + expect(User::OnboardingFeature.enabled?(user: user)).to be(false) + end + end + end +end +``` + +### Test Actions + +```ruby +RSpec.describe ApplicationFeature do + describe ".enabled?" do + let(:user) { create(:user) } + + before do + class TestFeature < ApplicationFeature + prefix :test + resource :user, type: User, option: true + feature :alpha + feature :beta + end + end + + it "returns true when all features are enabled" do + allow(Flipper).to receive(:enabled?).and_return(true) + expect(TestFeature.enabled?(user: user)).to be(true) + end + + it "returns false when any feature is disabled" do + allow(Flipper).to receive(:enabled?).with(:test_alpha, user).and_return(true) + allow(Flipper).to receive(:enabled?).with(:test_beta, user).and_return(false) + expect(TestFeature.enabled?(user: user)).to be(false) + end + end +end +``` + +### Test Resource Validation + +```ruby +RSpec.describe User::OnboardingFeature do + describe "resource validation" do + it "raises error when user is not provided" do + expect { User::OnboardingFeature.enabled? }.to raise_error(Servactory::Errors::InputError) + end + + it "raises error when user is wrong type" do + expect { User::OnboardingFeature.enabled?(user: "not a user") }.to raise_error(Servactory::Errors::InputError) + end + + it "accepts valid user" do + user = create(:user) + expect { User::OnboardingFeature.enabled?(user: user) }.not_to raise_error + end + end +end +``` + +## Performance Considerations + +### Minimize Feature Count + +Keep feature classes focused to reduce the number of feature flag checks: + +```ruby +# Good - Focused feature set +class BillingAPIFeature < ApplicationFeature + prefix :billing + + feature :api + feature :webhooks +end + +# Avoid - Too many features (consider splitting) +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api + feature :webhooks + feature :invoicing + feature :payments + feature :subscriptions + feature :refunds + feature :analytics + feature :reporting + # ... 20 more features +end +``` + +### Cache Feature Instances + +When using `.with()`, cache the instance if used multiple times: + +```ruby +# Good +feature = User::OnboardingFeature.with(user: user) + +if feature.enabled? + feature.disable + log_feature_change(feature) +end + +# Avoid - Creating multiple instances +if User::OnboardingFeature.with(user: user).enabled? + User::OnboardingFeature.with(user: user).disable + log_feature_change(User::OnboardingFeature.with(user: user)) +end +``` + +## Documentation + +### Use .info for Runtime Discovery + +Use the `.info` API to dynamically discover features and actions: + +```ruby +feature_classes = [BillingFeature, PaymentFeature] + +features = feature_classes.map do |klass| + info = klass.info + + { + name: klass.name, + features: info.features.all, + actions: info.actions.web.all + } +end + +# Use this data to build admin UIs, generate documentation, etc. +``` + +### Generate Documentation + +Create automated documentation from feature definitions: + +```ruby +class FeatureDocGenerator + def self.generate + feature_classes = [User::OnboardingFeature, BillingFeature, PaymentFeature] + + feature_classes.each do |klass| + info = klass.info + + puts "## #{klass.name}" + puts "" + + info.features.all.each do |feature| + puts "- `#{feature.name}`: #{feature.description}" + end + + puts "" + end + end +end +``` + +## Common Anti-Patterns + +### Avoid Feature Flag Sprawl + +```ruby +# Avoid - Too granular +class User::ProfileFeature < ApplicationFeature + feature :edit_name + feature :edit_email + feature :edit_phone + feature :edit_address + # ... 20 more edit features +end + +# Good - Appropriate granularity +class User::ProfileFeature < ApplicationFeature + feature :editing + feature :avatar_upload + feature :privacy_settings +end +``` + +### Avoid Complex Conditions + +```ruby +# Avoid - Complex business logic in conditions +class PremiumFeature < ApplicationFeature + condition(lambda do |resources:| + user = resources.user + subscription = user.subscription + + return false unless subscription + + # 50 lines of complex logic + # ... + end) +end + +# Good - Delegate to model methods +class PremiumFeature < ApplicationFeature + condition ->(resources:) { resources.user.eligible_for_premium? } +end + +class User < ApplicationRecord + def eligible_for_premium? + # Complex logic here + end +end +``` + +### Avoid Circular Dependencies + +```ruby +# Avoid - Circular group dependencies +class FeatureA < ApplicationFeature + group FeatureB +end + +class FeatureB < ApplicationFeature + group FeatureA # Circular! +end + +# Good - One-way hierarchy +class MainFeature < ApplicationFeature + group FeatureA + group FeatureB +end +``` + +## Next Steps + +- Review [Examples](./examples.md) for real-world patterns +- Explore [Integration](./integration.md) for framework-specific practices +- See [Actions](./actions.md) for action design patterns +- Learn about [Resources](./resources.md) for resource organization diff --git a/docs/conditions.md b/docs/conditions.md new file mode 100644 index 0000000..63acb9b --- /dev/null +++ b/docs/conditions.md @@ -0,0 +1,339 @@ +# Conditions + +Conditions are lambda-based rules that determine when features should be evaluated. They provide a way to add business logic that controls whether feature actions should execute. + +## Defining Conditions + +Use the `condition` method with a lambda: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage +end +``` + +The condition lambda receives a `resources:` parameter that provides access to all resources defined in the feature class. + +## Condition Parameters + +### resources: + +An object that provides access to all resources via method calls: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + resource :account, type: Account, option: true + + condition(lambda do |resources:| + resources.user.active? && resources.account.premium? + end) +end +``` + +Access resources by name: +- `resources.user` — Returns the User instance +- `resources.account` — Returns the Account instance + +## How Conditions Work + +When a condition is defined, it acts as a guard for feature actions: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage + + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end +end + +# If user.onboarding_awaiting? returns false: +User::OnboardingFeature.enabled?(user: user) +# The action is not executed, returns false immediately + +# If user.onboarding_awaiting? returns true: +User::OnboardingFeature.enabled?(user: user) +# The action executes normally +``` + +## Resource-Based Conditions + +### Single Resource + +```ruby +class User::ProfileEditingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.email_verified? } + + feature :profile_editing +end +``` + +### Multiple Resources + +```ruby +class OrganizationFeature < ApplicationFeature + resource :organization, type: Organization, option: true + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.organization.active? && resources.user.admin? + end) + + feature :admin_panel +end +``` + +### Optional Resources + +When using `required: false`, check for resource presence: + +```ruby +class AnalyticsFeature < ApplicationFeature + resource :user, type: User, option: true, required: false + + condition(lambda do |resources:| + # Check if user is provided before accessing it + resources.user.nil? || resources.user.analytics_enabled? + end) + + feature :tracking +end +``` + +## Complex Conditions + +### Method Chains + +```ruby +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.user.subscription&.active? && + resources.user.subscription&.plan&.premium? + end) + + feature :api +end +``` + +### Time-Based Conditions + +```ruby +class SeasonalFeature < ApplicationFeature + resource :user, type: User, option: true + + condition(lambda do |resources:| + Date.current.month.in?([11, 12]) && resources.user.active? + end) + + feature :holiday_theme +end +``` + +### External Service Checks + +```ruby +class ExperimentalFeature < ApplicationFeature + resource :user, type: User, option: true + + condition(lambda do |resources:| + ExperimentService.user_enrolled?(resources.user.id) + end) + + feature :beta_ui +end +``` + +## Conditions vs. Feature Flags + +Conditions and feature flags serve different purposes: + +**Conditions** — Business logic that determines if a feature should be evaluated + +```ruby +condition ->(resources:) { resources.user.onboarding_awaiting? } +# "Should we even check the feature flag?" +``` + +**Feature Flags** — Configuration that enables/disables features + +```ruby +action :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } +end +# "Is the feature flag turned on?" +``` + +### Example: Combined Usage + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + # Condition: Only proceed if user is in onboarding state + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage + + # Feature flag: Check if feature is enabled for this user + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end +end + +# Flow: +# 1. Check condition: Is user.onboarding_awaiting? true? +# - If false: Return false immediately +# - If true: Continue to step 2 +# 2. Check feature flag: Is Flipper.enabled?(:user_onboarding_passage, user) true? +# - Return the result +``` + +## Conditions with Groups + +Conditions apply to the current class and do not cascade to nested groups: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + + # This class has NO condition + # It will always execute its actions + + feature :api +end + +# The condition only applies to :user_onboarding_passage +# BillingFeature actions always execute +User::OnboardingFeature.enabled?(user: user) +``` + +To add conditions to nested groups, define them in each class: + +```ruby +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.billing_enabled? } + + feature :api +end +``` + +## Condition Patterns + +### Permission Check + +```ruby +class AdminFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.admin? } + + feature :admin_panel +end +``` + +### Subscription Tier + +```ruby +class PremiumFeature < ApplicationFeature + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.user.subscription&.tier&.in?(["premium", "enterprise"]) + end) + + feature :advanced_analytics +end +``` + +### State Machine + +```ruby +class OnboardingStepFeature < ApplicationFeature + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.user.onboarding_state == "step_2" + end) + + feature :step_2_completion +end +``` + +### Regional Availability + +```ruby +class RegionalFeature < ApplicationFeature + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.user.country_code.in?(["US", "CA", "GB"]) + end) + + feature :regional_payment_method +end +``` + +## Testing Conditions + +When testing, verify that conditions control action execution: + +```ruby +RSpec.describe User::OnboardingFeature do + let(:user) { User.new } + + context "when user is awaiting onboarding" do + before do + allow(user).to receive(:onboarding_awaiting?).and_return(true) + allow(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user).and_return(true) + end + + it "checks the feature flag" do + expect(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user) + User::OnboardingFeature.enabled?(user: user) + end + end + + context "when user is not awaiting onboarding" do + before { allow(user).to receive(:onboarding_awaiting?).and_return(false) } + + it "returns false without checking feature flag" do + expect(Flipper).not_to receive(:enabled?) + expect(User::OnboardingFeature.enabled?(user: user)).to be(false) + end + end +end +``` + +## Next Steps + +- Learn about [Actions](./actions.md) and how conditions affect action execution +- Review [Resources](./resources.md) for accessing resource data in conditions +- See [Examples](./examples.md) for real-world condition patterns +- Explore [Best Practices](./best-practices.md) for condition design diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..815d10e --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,441 @@ +# Examples + +Real-world examples demonstrating common Featury patterns and use cases. + +## User Onboarding System + +A complete user onboarding feature with multiple stages and nested billing features: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end + + before do |action:, features:| + Rails.logger.info("Executing #{action} on features: #{features}") + end +end + +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User, option: true, nested: true + + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage, description: "User onboarding passage feature" + feature :tutorial, description: "Interactive tutorial" + feature :welcome_email, description: "Welcome email notification" + + group BillingFeature, description: "Billing functionality group" + group PaymentSystemFeature, description: "Payment system functionality group" +end + +class BillingFeature < ApplicationFeature + prefix :billing + + resource :user, type: User, option: true + + feature :api, description: "Billing API feature" + feature :webhooks, description: "Billing webhooks feature" + feature :invoicing, description: "Invoice generation" +end + +class PaymentSystemFeature < ApplicationFeature + prefix :payment_system + + resource :user, type: User, option: true + + feature :api, description: "Payment system API feature" + feature :webhooks, description: "Payment system webhooks feature" + feature :stripe, description: "Stripe integration" + feature :paypal, description: "PayPal integration" +end + +# Usage +user = User.find(1) + +# Check if all onboarding features are enabled +User::OnboardingFeature.enabled?(user: user) +# Checks 9 features total: +# - :user_onboarding_passage +# - :user_onboarding_tutorial +# - :user_onboarding_welcome_email +# - :billing_api +# - :billing_webhooks +# - :billing_invoicing +# - :payment_system_api +# - :payment_system_webhooks +# - :payment_system_stripe +# - :payment_system_paypal + +# Enable all onboarding features +User::OnboardingFeature.enable(user: user) + +# Disable all onboarding features +User::OnboardingFeature.disable(user: user) + +# Using .with() +feature = User::OnboardingFeature.with(user: user) +feature.enabled? # => true +feature.disable # => true +``` + +## Multi-Tenant SaaS Application + +Features organized by organization and user context: + +```ruby +class Organization::FeatureBase < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +class Organization::PremiumFeature < Organization::FeatureBase + prefix :org_premium + + resource :organization, type: Organization, option: true + resource :user, type: User, option: true, required: false + + condition(lambda do |resources:| + resources.organization.subscription&.plan&.in?(["premium", "enterprise"]) + end) + + feature :advanced_analytics, description: "Advanced analytics dashboard" + feature :custom_branding, description: "Custom branding options" + feature :priority_support, description: "24/7 priority support" + feature :api_access, description: "API access" + + group Organization::IntegrationsFeature, description: "Third-party integrations" +end + +class Organization::IntegrationsFeature < Organization::FeatureBase + prefix :org_integrations + + resource :organization, type: Organization, option: true + + feature :slack, description: "Slack integration" + feature :github, description: "GitHub integration" + feature :jira, description: "Jira integration" + feature :salesforce, description: "Salesforce integration" +end + +# Usage +org = Organization.find(1) + +# Check if premium features are available +Organization::PremiumFeature.enabled?(organization: org) + +# Enable premium features for organization +Organization::PremiumFeature.enable(organization: org) + +# Check with user context +Organization::PremiumFeature.enabled?(organization: org, user: current_user) +``` + +## Progressive Feature Rollout + +Staged rollout with environment-based and user-based controls: + +```ruby +class ExperimentalFeature < ApplicationFeature + prefix :experimental + + resource :user, type: User, option: true, required: false + + feature :new_dashboard, description: "Redesigned dashboard UI" + feature :ai_assistant, description: "AI-powered assistant" + feature :beta_api, description: "Beta API endpoints" + + group AlphaFeature, description: "Alpha-stage features" +end + +class AlphaFeature < ApplicationFeature + prefix :alpha + + resource :user, type: User, option: true, required: false + + condition(lambda do |resources:| + # Only for staff or opted-in beta testers + resources.user&.staff? || resources.user&.beta_tester? + end) + + feature :experimental_ui, description: "Highly experimental UI changes" + feature :performance_mode, description: "Performance optimization mode" +end + +# Usage + +# Global rollout (no user) +ExperimentalFeature.enable + +# User-specific rollout +ExperimentalFeature.enable(user: beta_user) + +# Check for specific user +feature = ExperimentalFeature.with(user: current_user) +if feature.enabled? + render "new_dashboard" +else + render "classic_dashboard" +end + +# Alpha features only work for staff/beta testers +AlphaFeature.enable(user: staff_user) +AlphaFeature.enabled?(user: regular_user) # => false (condition fails) +``` + +## Regional Feature Availability + +Features controlled by geographic regions: + +```ruby +class RegionalFeature < ApplicationFeature + prefix :regional + + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.user.country_code.in?(["US", "CA", "GB", "AU"]) + end) + + feature :crypto_payments, description: "Cryptocurrency payment support" + feature :instant_transfer, description: "Instant bank transfers" + feature :local_currency, description: "Local currency support" +end + +class EUFeature < ApplicationFeature + prefix :eu + + resource :user, type: User, option: true + + condition(lambda do |resources:| + resources.user.country_code.in?(EU_COUNTRY_CODES) + end) + + feature :gdpr_tools, description: "GDPR compliance tools" + feature :sepa_payments, description: "SEPA payment support" + feature :vat_calculation, description: "VAT calculation" +end + +# Usage +us_user = User.find_by(country_code: "US") +eu_user = User.find_by(country_code: "DE") + +RegionalFeature.enabled?(user: us_user) # => true (condition passes) +RegionalFeature.enabled?(user: eu_user) # => false (condition fails) + +EUFeature.enabled?(user: eu_user) # => true (condition passes) +EUFeature.enabled?(user: us_user) # => false (condition fails) +``` + +## A/B Testing Framework + +Features for A/B testing with variant assignments: + +```ruby +class ABTestFeature < ApplicationFeature + prefix :ab_test + + resource :user, type: User, option: true + + feature :new_checkout_v1, description: "Checkout variant 1" + feature :new_checkout_v2, description: "Checkout variant 2" + feature :new_pricing_page, description: "New pricing page design" +end + +class ABTestService + def self.assign_variant(user, test_name) + # Consistent hash-based assignment + variant = (user.id + test_name.hash) % 2 + + case test_name + when :checkout + if variant == 0 + Flipper.enable(:ab_test_new_checkout_v1, user) + Flipper.disable(:ab_test_new_checkout_v2, user) + else + Flipper.disable(:ab_test_new_checkout_v1, user) + Flipper.enable(:ab_test_new_checkout_v2, user) + end + end + end +end + +# Setup +ABTestService.assign_variant(user, :checkout) + +# Check which variant is active +if Flipper.enabled?(:ab_test_new_checkout_v1, user) + render "checkout/variant_1" +elsif Flipper.enabled?(:ab_test_new_checkout_v2, user) + render "checkout/variant_2" +else + render "checkout/control" +end +``` + +## Maintenance Mode + +System-wide and service-specific maintenance controls: + +```ruby +class MaintenanceFeature < ApplicationFeature + prefix :maintenance + + resource :user, type: User, option: true, required: false + + feature :global, description: "Global maintenance mode" + feature :api, description: "API maintenance mode" + feature :webhooks, description: "Webhooks maintenance mode" + feature :scheduled_jobs, description: "Background jobs maintenance mode" +end + + +# Usage + +# Enable global maintenance +Flipper.enable(:maintenance_global) + +# Enable API maintenance only +Flipper.enable(:maintenance_api) + +# Disable all maintenance +MaintenanceFeature.disable +``` + +## Permission-Based Features + +Features controlled by user roles and permissions: + +```ruby +class AdminFeature < ApplicationFeature + prefix :admin + + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.admin? } + + feature :user_management, description: "User management panel" + feature :system_settings, description: "System settings access" + feature :audit_logs, description: "Audit log viewer" + + group SuperAdminFeature, description: "Super admin features" +end + +class SuperAdminFeature < ApplicationFeature + prefix :super_admin + + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.super_admin? } + + feature :feature_flags, description: "Feature flag management" + feature :database_access, description: "Direct database access" + feature :impersonation, description: "User impersonation" +end + +# Usage +admin_user = User.find_by(role: "admin") +super_admin = User.find_by(role: "super_admin") +regular_user = User.find_by(role: "user") + +# Admin features +AdminFeature.enabled?(user: admin_user) # => true +AdminFeature.enabled?(user: super_admin) # => true +AdminFeature.enabled?(user: regular_user) # => false (condition fails) + +# Super admin features +SuperAdminFeature.enabled?(user: super_admin) # => true +SuperAdminFeature.enabled?(user: admin_user) # => false (condition fails) + +``` + +## Time-Based Features + +Features that activate/deactivate based on time windows: + +```ruby +class SeasonalFeature < ApplicationFeature + prefix :seasonal + + resource :user, type: User, option: true, required: false + + condition(lambda do |resources:| + # Holiday season: November and December + Date.current.month.in?([11, 12]) + end) + + feature :holiday_theme, description: "Holiday-themed UI" + feature :gift_cards, description: "Gift card purchases" + feature :special_offers, description: "Seasonal special offers" +end + +class ScheduledFeature < ApplicationFeature + prefix :scheduled + + resource :user, type: User, option: true, required: false + + condition(lambda do |resources:| + # Active between specific dates + launch_date = Date.parse("2024-01-15") + end_date = Date.parse("2024-02-15") + + Date.current.between?(launch_date, end_date) + end) + + feature :limited_campaign, description: "Limited-time campaign" +end + +# Usage + +# Check seasonal features (automatic based on current date) +SeasonalFeature.enabled? # => true in Nov/Dec, false otherwise + +# Enable for specific user +SeasonalFeature.enable(user: user) + +# Check scheduled features +ScheduledFeature.enabled? # => true during campaign window +``` + +## Next Steps + +- Review [Best Practices](./best-practices.md) for recommended patterns +- Explore [Integration](./integration.md) for framework-specific examples +- Learn about [Actions](./actions.md) for custom action definitions +- See [Conditions](./conditions.md) for advanced conditional logic diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..3ecf4f1 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,189 @@ +# Features + +Features are the core building blocks of Featury. Each feature represents an individual feature flag that can be enabled, disabled, or checked for status. + +## Defining Features + +Use the `feature` method to define a feature flag: + +```ruby +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api + feature :webhooks + feature :invoicing +end +``` + +This creates three feature flags: +- `:billing_api` +- `:billing_webhooks` +- `:billing_invoicing` + +## Feature Naming with Prefixes + +The `prefix` method defines a namespace for all features in the class: + +```ruby +class PaymentSystemFeature < ApplicationFeature + prefix :payment_system + + feature :api # => :payment_system_api + feature :webhooks # => :payment_system_webhooks +end +``` + +### Naming Conventions + +- Prefixes should use underscores: `user_onboarding`, `payment_system` +- Feature names should be concise: `api`, `webhooks`, `passage` +- Combined names use single underscores: `:user_onboarding_passage` + +## Feature Descriptions + +Add descriptions to document what each feature does: + +```ruby +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api, description: "External billing API integration" + feature :webhooks, description: "Webhook endpoints for billing events" + feature :invoicing, description: "Automated invoice generation" +end +``` + +Descriptions are preserved and accessible via the `.info` method: + +```ruby +BillingFeature.info.features.all +# => [ +# { name: :billing_api, description: "External billing API integration" }, +# { name: :billing_webhooks, description: "Webhook endpoints for billing events" }, +# { name: :billing_invoicing, description: "Automated invoice generation" } +# ] +``` + +## Multiple Features in One Class + +You can define multiple related features in a single class: + +```ruby +class User::AccountFeature < ApplicationFeature + prefix :user_account + + resource :user, type: User, option: true + + feature :profile_editing, description: "Allow users to edit their profiles" + feature :avatar_upload, description: "Allow users to upload avatars" + feature :email_change, description: "Allow users to change their email" + feature :password_reset, description: "Enable password reset functionality" +end +``` + +When you call actions on this class, they will operate on **all four features**: + +```ruby +User::AccountFeature.enabled?(user: user) +# Checks if ALL four features are enabled for this user + +User::AccountFeature.enable(user: user) +# Enables ALL four features for this user +``` + +## Feature Aggregation Logic + +By default, Featury uses **all-must-match** logic: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + # Returns true only if ALL features are enabled + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end +``` + +You can customize this behavior in your action definitions. See [Actions](./actions.md) for details. + +## Working with Individual Features + +To work with individual features, create separate classes: + +```ruby +class BillingAPIFeature < ApplicationFeature + prefix :billing + + feature :api, description: "Billing API" +end + +class BillingWebhooksFeature < ApplicationFeature + prefix :billing + + feature :webhooks, description: "Billing webhooks" +end + +# Now you can control them independently +BillingAPIFeature.enable(user: user) +BillingWebhooksFeature.disable(user: user) +``` + +Or use your feature flag system directly for granular control: + +```ruby +Flipper.enable(:billing_api, user) +Flipper.disable(:billing_webhooks, user) +``` + +## Feature Tree + +Access all features including nested groups via `.info.tree`: + +```ruby +class MainFeature < ApplicationFeature + prefix :main + + feature :alpha + feature :beta + + group SubFeature, description: "Sub-features" +end + +class SubFeature < ApplicationFeature + prefix :sub + + feature :gamma +end + +MainFeature.info.tree.features +# Direct features: [:main_alpha, :main_beta] + +MainFeature.info.tree.groups +# Features from nested groups: [:sub_gamma] +``` + +See [Info and Introspection](./info-and-introspection.md) for complete details on the info API. + +## Next Steps + +- Learn about [Groups](./groups.md) for organizing features hierarchically +- Add [Resources](./resources.md) for type-safe parameters +- Define [Conditions](./conditions.md) for conditional feature activation +- See [Examples](./examples.md) for real-world feature definitions diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..dfd924e --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,126 @@ +# Getting Started + +This guide will walk you through installing Featury and creating your first feature flag management system. + +## Installation + +Add Featury to your Gemfile: + +```ruby +gem "featury" +``` + +Then run: + +```bash +bundle install +``` + +## Creating ApplicationFeature + +The `ApplicationFeature` base class defines how your features interact with your feature flag system. This is where you define actions and callbacks that will be inherited by all feature classes. + +### Basic Setup + +Create a base class that inherits from `Featury::Base`: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end +``` + +### Understanding Actions + +Each action receives two parameters: + +- `features:` — Array of feature flag names (symbols) +- `**options` — Hash of resources passed as options (e.g., `{ user: user_instance }`) + +The action block should return a result based on your feature flag system's API. In the example above: + +- `enabled?` checks if **all** features are enabled +- `enable` enables **all** features +- `disable` disables **all** features + +See [Actions](./actions.md) for detailed information about action parameters and behavior. + +## Your First Feature + +Create a feature class that inherits from `ApplicationFeature`: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User, option: true + + feature :passage, description: "User onboarding passage" +end +``` + +### Breaking It Down + +**prefix :user_onboarding** — All features in this class will be prefixed with `user_onboarding_` + +**resource :user** — Defines a required parameter of type `User` that will be passed to actions as an option + +**feature :passage** — Creates a feature flag named `:user_onboarding_passage` + +## Basic Usage + +Now you can use your feature: + +```ruby +user = User.find(1) + +# Check if enabled +User::OnboardingFeature.enabled?(user: user) +# => true + +# Enable the feature +User::OnboardingFeature.enable(user: user) +# => true + +# Disable the feature +User::OnboardingFeature.disable(user: user) +# => true +``` + +### Using .with() + +For cleaner syntax, use the `.with()` method: + +```ruby +feature = User::OnboardingFeature.with(user: user) + +feature.enabled? # => true +feature.enable # => true +feature.disable # => true +``` + +## Next Steps + +- Learn about [Features](./features.md) and naming conventions +- Explore [Groups](./groups.md) for organizing related features +- Add [Resources](./resources.md) for type-safe parameters +- Define [Conditions](./conditions.md) for conditional feature activation +- Review [Examples](./examples.md) for real-world scenarios diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 0000000..c28b62b --- /dev/null +++ b/docs/groups.md @@ -0,0 +1,314 @@ +# Groups + +Groups allow you to organize features into hierarchical structures, combining multiple feature classes into a unified interface. + +## Defining Groups + +Use the `group` method to include other feature classes: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User, option: true + + feature :passage, description: "User onboarding passage" + + group BillingFeature, description: "Billing functionality" + group PaymentSystemFeature, description: "Payment system functionality" +end +``` + +Now when you call actions on `User::OnboardingFeature`, they will operate on: +1. The direct feature: `:user_onboarding_passage` +2. All features from `BillingFeature` +3. All features from `PaymentSystemFeature` + +## Group Descriptions + +Add descriptions to document what each group provides: + +```ruby +class MainFeature < ApplicationFeature + prefix :main + + feature :core, description: "Core functionality" + + group BillingFeature, description: "Billing and invoicing features" + group NotificationsFeature, description: "Email and SMS notifications" + group AnalyticsFeature, description: "Usage analytics and tracking" +end +``` + +Access group descriptions via `.info`: + +```ruby +MainFeature.info.groups.all +# => [ +# { group_class: BillingFeature, description: "Billing and invoicing features" }, +# { group_class: NotificationsFeature, description: "Email and SMS notifications" }, +# { group_class: AnalyticsFeature, description: "Usage analytics and tracking" } +# ] +``` + +## Nested Group Structure + +Groups can be nested to any depth: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + feature :passage + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api + feature :webhooks + + group PaymentSystemFeature +end + +class PaymentSystemFeature < ApplicationFeature + prefix :payment_system + + feature :stripe + feature :paypal +end +``` + +This creates a hierarchy: +``` +User::OnboardingFeature +├── :user_onboarding_passage +└── BillingFeature + ├── :billing_api + ├── :billing_webhooks + └── PaymentSystemFeature + ├── :payment_system_stripe + └── :payment_system_paypal +``` + +Calling `User::OnboardingFeature.enabled?(user: user)` checks **all five features**. + +## Passing Resources to Groups + +By default, resources are not passed to nested groups. Use `nested: true` to pass resources down the hierarchy: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User, option: true, nested: true + + feature :passage + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + prefix :billing + + resource :user, type: User, option: true + + feature :api +end + +# The :user resource is passed to both classes +User::OnboardingFeature.enabled?(user: user) +# Checks: Flipper.enabled?(:user_onboarding_passage, user) +# Flipper.enabled?(:billing_api, user) +``` + +See [Resources](./resources.md) for detailed information on the `nested` option. + +## Group Inheritance Patterns + +### Shared Base Class + +All groups should inherit from the same base class to ensure consistent behavior: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +class BillingFeature < ApplicationFeature + # Inherits actions from ApplicationFeature +end + +class PaymentSystemFeature < ApplicationFeature + # Inherits actions from ApplicationFeature +end + +class MainFeature < ApplicationFeature + group BillingFeature + group PaymentSystemFeature + # All groups share the same action definitions +end +``` + +### Different Base Classes + +You can mix groups with different base classes, but they must define compatible actions: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +class CustomFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| CustomFeatureSystem.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !CustomFeatureSystem.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| CustomFeatureSystem.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| CustomFeatureSystem.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| CustomFeatureSystem.add(feature, *options.values) } + end +end + +class MainFeature < ApplicationFeature + group BillingFeature # Uses Flipper + group CustomFeature # Uses CustomFeatureSystem +end + +# Both must respond to the same action names +MainFeature.enabled?(user: user) +``` + +## Accessing Group Information + +### Direct Groups + +Get groups defined in the current class: + +```ruby +MainFeature.info.groups.all +# Returns only groups defined directly in MainFeature +``` + +### Complete Tree + +Get all features including nested groups: + +```ruby +MainFeature.info.tree.features +# Direct features only + +MainFeature.info.tree.groups +# All features from all nested groups (flattened) +``` + +See [Info and Introspection](./info-and-introspection.md) for complete details. + +## Use Cases for Groups + +### Feature Bundling + +Group related features that should be enabled/disabled together: + +```ruby +class PremiumFeature < ApplicationFeature + prefix :premium + + feature :advanced_analytics + feature :priority_support + feature :custom_branding + + group IntegrationsFeature + group ExportFeature +end + +# Enable all premium features at once +PremiumFeature.enable(user: user) +``` + +### Progressive Feature Rollout + +Create hierarchies for staged rollouts: + +```ruby +class ExperimentalFeature < ApplicationFeature + prefix :experimental + + feature :beta_ui + + group AlphaFeature # More experimental nested features +end + +# Enable outer feature without enabling inner experimental features +ExperimentalFeature.info.features.all # Just :beta_ui +``` + +### Domain Organization + +Organize features by domain: + +```ruby +class User::Feature < ApplicationFeature + group User::OnboardingFeature + group User::ProfileFeature + group User::NotificationsFeature +end + +# Manage all user-related features together +User::Feature.enabled?(user: user) +``` + +## Next Steps + +- Learn about [Resources](./resources.md) and the `nested: true` option +- Review [Actions](./actions.md) to understand how actions cascade through groups +- See [Examples](./examples.md) for real-world group patterns diff --git a/docs/info-and-introspection.md b/docs/info-and-introspection.md new file mode 100644 index 0000000..68fbf5b --- /dev/null +++ b/docs/info-and-introspection.md @@ -0,0 +1,454 @@ +# Info and Introspection + +Featury provides a comprehensive `.info` API for runtime introspection of features, actions, resources, and groups. This enables dynamic feature discovery, admin UI generation, and debugging. + +## Accessing Info + +Call `.info` on any feature class: + +```ruby +info = User::OnboardingFeature.info +``` + +The returned object provides access to: +- Actions (all actions and web-specific mappings) +- Resources (resource definitions) +- Features (direct features with descriptions) +- Groups (nested groups with descriptions) +- Tree (complete feature hierarchy) + +## Actions Information + +### All Actions + +Get all action names defined in the feature class: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + # Implementation + end + + action :disabled?, web: :regular do |features:, **options| + # Implementation + end + + action :enable, web: :enable do |features:, **options| + # Implementation + end + + action :disable, web: :disable do |features:, **options| + # Implementation + end + + action :add, web: :regular do |features:, **options| + # Implementation + end +end + +info = ApplicationFeature.info + +info.actions.all +# => [:enabled?, :disabled?, :enable, :disable, :add] +``` + +### Web Actions + +Get all actions that have web mappings: + +```ruby +info.actions.web.all +# => [:enabled?, :disabled?, :enable, :disable, :add] +``` + +### Specific Web Mappings + +Access actions by their web mapping type: + +```ruby +info.actions.web.enabled +# => :enabled? +# Returns the action name that was defined with `web: :enabled?` + +info.actions.web.enable +# => :enable +# Returns the action name that was defined with `web: :enable` + +info.actions.web.disable +# => :disable +# Returns the action name that was defined with `web: :disable` +``` + +### Use Cases for Web Mappings + +**Admin UI Generation:** + +```ruby +info = User::OnboardingFeature.info + +# Generate enable button +if info.actions.web.enable + button "Enable", action: info.actions.web.enable + # Renders: calling :enable action +end + +# Generate disable button +if info.actions.web.disable + button "Disable", action: info.actions.web.disable + # Renders: calling :disable action +end +``` + +**API Endpoint Generation:** + +```ruby +info = BillingFeature.info + +# Create endpoints for web actions +if info.actions.web.enabled + get "/api/features/billing/status" do + BillingFeature.send(info.actions.web.enabled, user: current_user) + end +end + +if info.actions.web.enable + post "/api/features/billing/enable" do + BillingFeature.send(info.actions.web.enable, user: current_user) + end +end +``` + +**Permission Checks:** + +```ruby +info = PremiumFeature.info + +# Different permissions for read vs. write actions +def can_check_feature?(user) + user.role.in?(["user", "admin"]) && info.actions.web.enabled +end + +def can_modify_feature?(user) + user.admin? && (info.actions.web.enable || info.actions.web.disable) +end +``` + +## Resources Information + +Access resource definitions: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + resource :account, type: Account, option: true, required: false +end + +info = User::OnboardingFeature.info + +info.resources.all +# Returns collection of resources with metadata: +# - Type information +# - Option flags (option: true) +# - Required flags (required: false) +# - Nested flags (nested: true) +``` + +### Use Cases for Resources + +**Dynamic Parameter Validation:** + +```ruby +info = User::OnboardingFeature.info + +# Validate that all required resources are provided +def validate_resources(params) + info.resources.all.each do |resource| + next if resource.optional? + + raise "Missing required resource: #{resource.name}" unless params.key?(resource.name) + end +end +``` + +**Form Generation:** + +```ruby +info = User::OnboardingFeature.info + +# Generate form fields based on resources +info.resources.all.each do |resource| + label = resource.required? ? "#{resource.name} *" : resource.name + render_field(resource.name, type: resource.type, label: label) +end +``` + +## Features Information + +Access direct features defined in the current class: + +```ruby +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api, description: "External billing API integration" + feature :webhooks, description: "Webhook endpoints for billing events" + feature :invoicing, description: "Automated invoice generation" +end + +info = BillingFeature.info + +info.features.all +# => [ +# { name: :billing_api, description: "External billing API integration" }, +# { name: :billing_webhooks, description: "Webhook endpoints for billing events" }, +# { name: :billing_invoicing, description: "Automated invoice generation" } +# ] +``` + +### Use Cases for Features + +**Documentation Generation:** + +```ruby +info = BillingFeature.info + +# Generate feature documentation +info.features.all.each do |feature| + puts "#{feature.name}: #{feature.description}" +end + +# Output: +# billing_api: External billing API integration +# billing_webhooks: Webhook endpoints for billing events +# billing_invoicing: Automated invoice generation +``` + +**Feature Registry:** + +```ruby +# Build a registry of all features in the system +feature_registry = {} + +[BillingFeature, PaymentFeature, AnalyticsFeature].each do |klass| + info = klass.info + info.features.all.each do |feature| + feature_registry[feature.name] = { + class: klass, + description: feature.description + } + end +end +``` + +## Groups Information + +Access nested groups defined in the current class: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + feature :passage, description: "User onboarding passage" + + group BillingFeature, description: "Billing functionality" + group PaymentSystemFeature, description: "Payment system functionality" +end + +info = User::OnboardingFeature.info + +info.groups.all +# => [ +# { group_class: BillingFeature, description: "Billing functionality" }, +# { group_class: PaymentSystemFeature, description: "Payment system functionality" } +# ] +``` + +### Use Cases for Groups + +**Hierarchical UI:** + +```ruby +info = User::OnboardingFeature.info + +# Render nested feature groups +info.groups.all.each do |group| + puts "Group: #{group.description}" + + group_info = group.group_class.info + group_info.features.all.each do |feature| + puts " - #{feature.name}: #{feature.description}" + end +end +``` + +**Dependency Visualization:** + +```ruby +def visualize_dependencies(feature_class, indent = 0) + info = feature_class.info + + info.features.all.each do |feature| + puts " " * indent + "Feature: #{feature.name}" + end + + info.groups.all.each do |group| + puts " " * indent + "Group: #{group.group_class.name}" + visualize_dependencies(group.group_class, indent + 1) + end +end + +visualize_dependencies(User::OnboardingFeature) +``` + +## Tree Information + +The tree provides a complete view of all features including nested groups: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + feature :passage, description: "User onboarding passage" + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api, description: "Billing API" + feature :webhooks, description: "Billing webhooks" +end + +info = User::OnboardingFeature.info + +# Direct features +info.tree.features +# => [:user_onboarding_passage] + +# Features from all nested groups (flattened) +info.tree.groups +# => [:billing_api, :billing_webhooks] +``` + +### Use Cases for Tree + +**Complete Feature List:** + +```ruby +info = User::OnboardingFeature.info + +all_features = info.tree.features + info.tree.groups.flat_map(&:features) +# Returns all feature names in the hierarchy +``` + +**Feature Auditing:** + +```ruby +def audit_features(feature_class) + info = feature_class.info + + direct_count = info.tree.features.count + nested_count = info.tree.groups.count + total_count = direct_count + nested_count + + { + class: feature_class.name, + direct_features: direct_count, + nested_features: nested_count, + total_features: total_count + } +end + +audit_features(User::OnboardingFeature) +# => { class: "User::OnboardingFeature", direct_features: 1, nested_features: 2, total_features: 3 } +``` + +## Practical Examples + +### API Documentation Generator + +```ruby +class FeatureDocGenerator + def generate + doc = [] + + [User::OnboardingFeature, BillingFeature].each do |klass| + info = klass.info + + doc << "## #{klass.name}" + doc << "" + + doc << "### Features" + info.features.all.each do |feature| + doc << "- `#{feature.name}`: #{feature.description}" + end + doc << "" + + doc << "### Actions" + info.actions.all.each do |action| + doc << "- `#{action}`" + end + doc << "" + + if info.groups.all.any? + doc << "### Nested Groups" + info.groups.all.each do |group| + doc << "- `#{group.group_class.name}`: #{group.description}" + end + doc << "" + end + end + + doc.join("\n") + end +end +``` + +### Feature Toggle UI + +```ruby +class FeatureToggleComponent + def initialize(feature_class, user:) + @feature_class = feature_class + @user = user + @info = feature_class.info + end + + def render + html = ["
"] + + # Feature name + html << "

#{@feature_class.name}

" + + # Current status + if @info.actions.web.enabled + status = @feature_class.send(@info.actions.web.enabled, user: @user) + html << "

Status: #{status ? 'Enabled' : 'Disabled'}

" + end + + # Actions + html << "
" + + if @info.actions.web.enable + html << "" + end + + if @info.actions.web.disable + html << "" + end + + html << "
" + html << "
" + + html.join("\n") + end +end +``` + +## Next Steps + +- Review [Actions](./actions.md) to understand action definitions and web mappings +- Learn about [Resources](./resources.md) and their metadata +- Explore [Examples](./examples.md) for real-world introspection patterns +- See [Integration](./integration.md) for building admin UIs and APIs diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..da564e7 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,869 @@ +# Integration + +This guide demonstrates how to integrate Featury with various backend storage systems. Featury is backend-agnostic - actions receive feature names and options, and you decide how to store and retrieve feature flag states. + +## Table of Contents + +- [Overview](#overview) +- [Flipper Integration](#flipper-integration) +- [Redis Integration](#redis-integration) +- [Database Integration](#database-integration) +- [External Service Integration](#external-service-integration) +- [Hybrid Approach](#hybrid-approach) + +## Overview + +Featury's actions abstract feature flag operations through a simple interface: + +- **Actions receive `features:`** - An array of feature names (symbols) +- **Actions receive `**options`** - A hash with backend-specific parameters +- **Options can contain**: `actor`, `user_id`, `team_id`, `percentage`, `api_key`, etc. +- **Each backend implements actions differently** based on its storage mechanism + +This design allows you to integrate with any backend by implementing actions that match your storage layer. + +### Action Signature + +All actions follow this signature: + +```ruby +action :action_name do |features:, **options| + # features => [:user_onboarding_passage, :user_onboarding_completion] + # options => { user: #, team_id: 123, percentage: 50, ... } + + # Return true/false or perform operation +end +``` + +The `features` array contains fully-qualified feature names (with prefixes applied). The `options` hash contains any parameters passed when calling the action. + +## Flipper Integration + +[Flipper](https://github.com/jnunemaker/flipper) is a popular feature flag library for Ruby. Featury can wrap Flipper to provide organizational capabilities. + +### Basic Flipper Actions + +```ruby +class ApplicationFeature < Featury::Base + # Check if all features are enabled for an actor + action :enabled?, web: :enabled? do |features:, **options| + actor = options[:actor] + features.all? { |feature| Flipper.enabled?(feature, actor) } + end + + # Check if any feature is disabled for an actor + action :disabled?, web: :regular do |features:, **options| + actor = options[:actor] + features.any? { |feature| !Flipper.enabled?(feature, actor) } + end + + # Enable all features globally + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature) } + end + + # Disable all features globally + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature) } + end + + # Add features to Flipper (initialize them) + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature) } + end +end +``` + +### Advanced Flipper Actions + +Flipper supports multiple activation strategies. Here's how to expose them through Featury actions: + +```ruby +class ApplicationFeature < Featury::Base + # Enable for specific actor (user, account, etc.) + action :enable_for_actor do |features:, **options| + actor = options[:actor] + features.all? { |feature| Flipper.enable_actor(feature, actor) } + end + + # Disable for specific actor + action :disable_for_actor do |features:, **options| + actor = options[:actor] + features.all? { |feature| Flipper.disable_actor(feature, actor) } + end + + # Enable for percentage of actors + action :enable_percentage do |features:, **options| + percentage = options[:percentage] + features.all? { |feature| Flipper.enable_percentage_of_actors(feature, percentage) } + end + + # Enable for a group (defined in Flipper) + action :enable_group do |features:, **options| + group = options[:group] + features.all? { |feature| Flipper.enable_group(feature, group) } + end + + # Disable for a group + action :disable_group do |features:, **options| + group = options[:group] + features.all? { |feature| Flipper.disable_group(feature, group) } + end + + # Enable for specific gate IDs + action :enable_for_gate do |features:, **options| + gate_name = options[:gate_name] + gate_value = options[:gate_value] + features.all? { |feature| Flipper.enable(feature, gate_name => gate_value) } + end +end +``` + +### Flipper Usage Examples + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User + + feature :passage, description: "User onboarding passage feature" + feature :completion, description: "User onboarding completion feature" +end + +# Check if enabled for specific user +User::OnboardingFeature.enabled?(actor: current_user) +# => Checks: Flipper.enabled?(:user_onboarding_passage, current_user) +# Flipper.enabled?(:user_onboarding_completion, current_user) + +# Enable for specific user +User::OnboardingFeature.enable_for_actor(actor: current_user) + +# Enable for 25% of users +User::OnboardingFeature.enable_percentage(percentage: 25) + +# Enable for admin group +User::OnboardingFeature.enable_group(group: :admins) + +# Using .with() for cleaner syntax +feature = User::OnboardingFeature.with(user: current_user) +feature.enabled? # Uses current_user as actor +``` + +### Flipper with Custom Adapter + +```ruby +class ApplicationFeature < Featury::Base + # Use custom Flipper instance with Redis adapter + action :enabled? do |features:, **options| + actor = options[:actor] + flipper = Flipper.new(Flipper::Adapters::Redis.new(Redis.current)) + features.all? { |feature| flipper.enabled?(feature, actor) } + end +end +``` + +## Redis Integration + +For applications using Redis directly without Flipper, you can implement custom Redis-based feature flags. + +### Basic Redis Actions + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + actor_id = options[:actor_id] + namespace = options[:namespace] || "features" + + features.all? do |feature| + key = "#{namespace}:#{feature}:#{actor_id}" + Redis.current.get(key) == "true" + end + end + + action :enable, web: :enable do |features:, **options| + actor_id = options[:actor_id] + namespace = options[:namespace] || "features" + ttl = options[:ttl] # Optional expiration in seconds + + features.all? do |feature| + key = "#{namespace}:#{feature}:#{actor_id}" + Redis.current.set(key, "true") + Redis.current.expire(key, ttl) if ttl + true + end + end + + action :disable, web: :disable do |features:, **options| + actor_id = options[:actor_id] + namespace = options[:namespace] || "features" + + features.all? do |feature| + key = "#{namespace}:#{feature}:#{actor_id}" + Redis.current.del(key) + true + end + end +end +``` + +### Redis Usage Examples + +```ruby +class PaymentFeature < ApplicationFeature + prefix :payment + + feature :processing, description: "Payment processing" + feature :refunds, description: "Payment refunds" +end + +# Check if enabled for user +PaymentFeature.enabled?(actor_id: user.id) + +# Enable with custom namespace +PaymentFeature.enable(actor_id: user.id, namespace: "app_features") + +# Enable with 1-hour expiration +PaymentFeature.enable(actor_id: user.id, ttl: 3600) + +# Disable for user +PaymentFeature.disable(actor_id: user.id) +``` + +### Redis with Hash Storage + +Store multiple features per user in a single Redis hash: + +```ruby +class ApplicationFeature < Featury::Base + action :enabled? do |features:, **options| + user_id = options[:user_id] + key = "user_features:#{user_id}" + + features.all? do |feature| + Redis.current.hget(key, feature.to_s) == "1" + end + end + + action :enable do |features:, **options| + user_id = options[:user_id] + key = "user_features:#{user_id}" + + Redis.current.pipelined do |pipeline| + features.each do |feature| + pipeline.hset(key, feature.to_s, "1") + end + end + true + end + + action :disable do |features:, **options| + user_id = options[:user_id] + key = "user_features:#{user_id}" + + Redis.current.pipelined do |pipeline| + features.each do |feature| + pipeline.hdel(key, feature.to_s) + end + end + true + end +end +``` + +### Redis with Percentage Rollout + +```ruby +class ApplicationFeature < Featury::Base + action :enabled? do |features:, **options| + user_id = options[:user_id] + + features.all? do |feature| + # Check global flag + global_key = "feature:#{feature}:enabled" + return false unless Redis.current.get(global_key) == "true" + + # Check percentage + percentage_key = "feature:#{feature}:percentage" + percentage = Redis.current.get(percentage_key).to_i + + return true if percentage >= 100 + return false if percentage <= 0 + + # Consistent hashing for percentage rollout + hash = Digest::MD5.hexdigest("#{feature}:#{user_id}").to_i(16) + (hash % 100) < percentage + end + end + + action :set_percentage do |features:, **options| + percentage = options[:percentage] + + features.all! do |feature| + Redis.current.set("feature:#{feature}:percentage", percentage) + true + end + end +end +``` + +## Database Integration + +For applications that need persistence and complex querying, store feature flags in a database. + +### ActiveRecord Integration + +```ruby +# Migration +class CreateFeatureFlags < ActiveRecord::Migration[7.0] + def change + create_table :feature_flags do |t| + t.string :name, null: false + t.bigint :user_id + t.bigint :organization_id + t.boolean :enabled, default: false, null: false + t.jsonb :metadata, default: {} + t.timestamps + end + + add_index :feature_flags, [:name, :user_id, :organization_id], unique: true + add_index :feature_flags, [:organization_id, :enabled] + end +end + +# Model +class FeatureFlag < ApplicationRecord + belongs_to :user, optional: true + belongs_to :organization, optional: true + + validates :name, presence: true + validates :name, uniqueness: { scope: [:user_id, :organization_id] } +end +``` + +### Database Actions + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + + features.all? do |feature| + FeatureFlag.exists?( + name: feature, + user_id: user_id, + organization_id: organization_id, + enabled: true + ) + end + end + + action :enable, web: :enable do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + metadata = options[:metadata] || {} + + features.all? do |feature| + FeatureFlag.find_or_create_by!( + name: feature, + user_id: user_id, + organization_id: organization_id + ).update!(enabled: true, metadata: metadata) + end + end + + action :disable, web: :disable do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + + features.all? do |feature| + FeatureFlag.where( + name: feature, + user_id: user_id, + organization_id: organization_id + ).update_all(enabled: false) + true + end + end + + action :remove do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + + features.all? do |feature| + FeatureFlag.where( + name: feature, + user_id: user_id, + organization_id: organization_id + ).destroy_all + true + end + end +end +``` + +### Database Usage Examples + +```ruby +class BillingFeature < ApplicationFeature + prefix :billing + + feature :api, description: "Billing API" + feature :webhooks, description: "Billing webhooks" +end + +# Check if enabled for user in organization +BillingFeature.enabled?(user_id: user.id, organization_id: org.id) + +# Enable with metadata +BillingFeature.enable( + user_id: user.id, + organization_id: org.id, + metadata: { enabled_by: admin.id, reason: "Upgrade to Pro plan" } +) + +# Disable for user +BillingFeature.disable(user_id: user.id, organization_id: org.id) + +# Remove feature flags +BillingFeature.remove(user_id: user.id, organization_id: org.id) +``` + +### Database with Scope-Based Checks + +```ruby +class ApplicationFeature < Featury::Base + # Check at organization level (all users) + action :enabled_for_organization? do |features:, **options| + organization_id = options[:organization_id] + + features.all? do |feature| + FeatureFlag.exists?( + name: feature, + organization_id: organization_id, + user_id: nil, + enabled: true + ) + end + end + + # Enable for entire organization + action :enable_for_organization do |features:, **options| + organization_id = options[:organization_id] + + features.all? do |feature| + FeatureFlag.find_or_create_by!( + name: feature, + organization_id: organization_id, + user_id: nil + ).update!(enabled: true) + end + end + + # Check for specific user (fallback to organization) + action :enabled? do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + + features.all? do |feature| + # Check user-level first + user_flag = FeatureFlag.find_by( + name: feature, + user_id: user_id, + organization_id: organization_id + ) + return user_flag.enabled if user_flag + + # Fallback to organization-level + org_flag = FeatureFlag.find_by( + name: feature, + organization_id: organization_id, + user_id: nil + ) + org_flag&.enabled || false + end + end +end +``` + +## External Service Integration + +For microservices or distributed systems, feature flags might be managed by an external HTTP API. + +### HTTP API Integration + +```ruby +require "http" # Using the http gem + +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + tenant_id = options[:tenant_id] + api_key = options[:api_key] + + features.all? do |feature| + response = HTTP + .auth("Bearer #{api_key}") + .get("https://features-api.example.com/features/#{feature}/status", + params: { tenant_id: tenant_id }) + + response.parse["enabled"] == true + rescue HTTP::Error => e + Rails.logger.error("Feature check failed: #{e.message}") + false # Fail closed + end + end + + action :enable, web: :enable do |features:, **options| + tenant_id = options[:tenant_id] + api_key = options[:api_key] + user_id = options[:user_id] + + features.all? do |feature| + response = HTTP + .auth("Bearer #{api_key}") + .post("https://features-api.example.com/features/#{feature}/enable", + json: { + tenant_id: tenant_id, + user_id: user_id + }) + + response.status.success? + rescue HTTP::Error => e + Rails.logger.error("Feature enable failed: #{e.message}") + false + end + end + + action :disable, web: :disable do |features:, **options| + tenant_id = options[:tenant_id] + api_key = options[:api_key] + + features.all? do |feature| + response = HTTP + .auth("Bearer #{api_key}") + .post("https://features-api.example.com/features/#{feature}/disable", + json: { tenant_id: tenant_id }) + + response.status.success? + rescue HTTP::Error => e + Rails.logger.error("Feature disable failed: #{e.message}") + false + end + end + + # Batch check for efficiency + action :batch_enabled? do |features:, **options| + tenant_id = options[:tenant_id] + api_key = options[:api_key] + + response = HTTP + .auth("Bearer #{api_key}") + .post("https://features-api.example.com/features/batch-check", + json: { + tenant_id: tenant_id, + features: features.map(&:to_s) + }) + + result = response.parse + features.all? { |feature| result[feature.to_s] == true } + rescue HTTP::Error => e + Rails.logger.error("Batch feature check failed: #{e.message}") + false + end +end +``` + +### HTTP API Usage Examples + +```ruby +class NotificationFeature < ApplicationFeature + prefix :notification + + feature :email, description: "Email notifications" + feature :sms, description: "SMS notifications" + feature :push, description: "Push notifications" +end + +# Check if enabled via API +NotificationFeature.enabled?( + tenant_id: "acme-corp", + api_key: ENV["FEATURES_API_KEY"] +) + +# Enable for specific user +NotificationFeature.enable( + tenant_id: "acme-corp", + api_key: ENV["FEATURES_API_KEY"], + user_id: user.id +) + +# Batch check (single API call) +NotificationFeature.batch_enabled?( + tenant_id: "acme-corp", + api_key: ENV["FEATURES_API_KEY"] +) +``` + +### GraphQL API Integration + +```ruby +class ApplicationFeature < Featury::Base + action :enabled? do |features:, **options| + tenant_id = options[:tenant_id] + user_id = options[:user_id] + + query = <<~GRAPHQL + query CheckFeatures($tenantId: ID!, $userId: ID!, $features: [String!]!) { + featuresEnabled(tenantId: $tenantId, userId: $userId, features: $features) { + name + enabled + } + } + GRAPHQL + + response = GraphQL::Client.execute( + query, + variables: { + tenantId: tenant_id, + userId: user_id, + features: features.map(&:to_s) + } + ) + + results = response.data.features_enabled + features.all? { |feature| results.find { |r| r.name == feature.to_s }&.enabled } + end +end +``` + +## Hybrid Approach + +Combine multiple backends for optimal performance and reliability. + +### Redis Cache + Database Fallback + +```ruby +class ApplicationFeature < Featury::Base + action :enabled? do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + cache_ttl = options[:cache_ttl] || 300 # 5 minutes default + + features.all? do |feature| + cache_key = "feature:#{feature}:#{user_id}:#{organization_id}" + + # Try cache first + cached = Redis.current.get(cache_key) + if cached + return cached == "true" + end + + # Fallback to database + result = FeatureFlag.exists?( + name: feature, + user_id: user_id, + organization_id: organization_id, + enabled: true + ) + + # Cache the result + Redis.current.setex(cache_key, cache_ttl, result.to_s) + result + end + end + + action :enable do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + + features.all? do |feature| + # Update database + FeatureFlag.find_or_create_by!( + name: feature, + user_id: user_id, + organization_id: organization_id + ).update!(enabled: true) + + # Invalidate cache + cache_key = "feature:#{feature}:#{user_id}:#{organization_id}" + Redis.current.del(cache_key) + + true + end + end + + action :disable do |features:, **options| + user_id = options[:user_id] + organization_id = options[:organization_id] + + features.all? do |feature| + # Update database + FeatureFlag.where( + name: feature, + user_id: user_id, + organization_id: organization_id + ).update_all(enabled: false) + + # Invalidate cache + cache_key = "feature:#{feature}:#{user_id}:#{organization_id}" + Redis.current.del(cache_key) + + true + end + end +end +``` + +### Local Memory + Remote API + +```ruby +class ApplicationFeature < Featury::Base + # In-memory cache with TTL + @feature_cache = Concurrent::Map.new + @cache_timestamps = Concurrent::Map.new + + class << self + attr_reader :feature_cache, :cache_timestamps + end + + action :enabled? do |features:, **options| + tenant_id = options[:tenant_id] + api_key = options[:api_key] + cache_ttl = options[:cache_ttl] || 60 # 1 minute default + + features.all? do |feature| + cache_key = "#{tenant_id}:#{feature}" + + # Check memory cache + timestamp = self.class.cache_timestamps[cache_key] + if timestamp && (Time.current - timestamp) < cache_ttl + return self.class.feature_cache[cache_key] + end + + # Fetch from API + response = HTTP + .auth("Bearer #{api_key}") + .get("https://features-api.example.com/features/#{feature}/status", + params: { tenant_id: tenant_id }) + + result = response.parse["enabled"] == true + + # Cache in memory + self.class.feature_cache[cache_key] = result + self.class.cache_timestamps[cache_key] = Time.current + + result + rescue HTTP::Error + # Return cached value if available, otherwise fail closed + self.class.feature_cache[cache_key] || false + end + end +end +``` + +### Multi-Region with Fallback + +```ruby +class ApplicationFeature < Featury::Base + action :enabled? do |features:, **options| + user_id = options[:user_id] + region = options[:region] || ENV["AWS_REGION"] + + features.all? do |feature| + # Try regional database first + regional_flag = FeatureFlag.where( + name: feature, + user_id: user_id, + region: region, + enabled: true + ).exists? + + return true if regional_flag + + # Fallback to global database + FeatureFlag.where( + name: feature, + user_id: user_id, + region: nil, + enabled: true + ).exists? + end + end +end +``` + +## Best Practices + +### Error Handling + +Always handle errors gracefully in actions: + +```ruby +action :enabled? do |features:, **options| + features.all? do |feature| + begin + check_feature(feature, options) + rescue => e + Rails.logger.error("Feature check failed: #{e.message}") + false # Fail closed by default + end + end +end +``` + +### Performance Optimization + +Batch operations when possible: + +```ruby +action :enabled? do |features:, **options| + user_id = options[:user_id] + + # Single query instead of N queries + enabled_features = FeatureFlag.where( + name: features, + user_id: user_id, + enabled: true + ).pluck(:name) + + features.all? { |feature| enabled_features.include?(feature) } +end +``` + +### Timeout Protection + +Set timeouts for external calls: + +```ruby +action :enabled? do |features:, **options| + features.all? do |feature| + Timeout.timeout(1) do # 1 second timeout + check_external_service(feature, options) + end + end +rescue Timeout::Error + false # Fail closed on timeout +end +``` + +### Monitoring + +Add monitoring to track feature flag usage: + +```ruby +action :enabled? do |features:, **options| + result = features.all? { |feature| Flipper.enabled?(feature, options[:actor]) } + + # Track metrics + StatsD.increment("feature.check", tags: ["result:#{result}"]) + + result +end +``` + +## Next Steps + +- Review [Actions](./actions.md) for more on defining actions +- See [Examples](./examples.md) for complete integration examples +- Check [Best Practices](./best-practices.md) for recommended patterns diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 0000000..dec1a03 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,303 @@ +# Resources + +Resources are type-validated parameters that are passed to feature actions. Featury integrates with Servactory to provide robust resource validation and flexible parameter handling. + +## Defining Resources + +Use the `resource` method to define a parameter: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User + + feature :passage +end +``` + +## Resource Options + +### type: (Required) + +Specifies the expected type of the resource. Featury uses Servactory for type validation: + +```ruby +resource :user, type: User +resource :account, type: Account +resource :comment, type: String +resource :count, type: Integer +``` + +### option: true + +Passes the resource as an option to the feature flag system: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + feature :passage + + action :enabled?, web: :enabled? do |features:, **options| + puts options.inspect + # => { user: # } + + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + # Calls: Flipper.enabled?(:user_onboarding_passage, user_instance) + end +end + +User::OnboardingFeature.enabled?(user: user) +``` + +**Without option: true:** + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User + # option: true is NOT specified + + feature :passage + + action :enabled?, web: :enabled? do |features:, **options| + puts options.inspect + # => {} + # The user resource is NOT included in options + end +end +``` + +Use `option: true` when your feature flag system needs the resource as a context parameter (e.g., Flipper actors). + +### required: false + +Makes a resource optional: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + resource :account, type: Account, option: true, required: false + + feature :passage +end + +# Can be called without :account +User::OnboardingFeature.enabled?(user: user) + +# Or with :account +User::OnboardingFeature.enabled?(user: user, account: account) +``` + +**Use Cases:** +- Global feature flags that don't require user context +- Optional context parameters for logging/analytics +- Features that work with or without certain resources + +### nested: true + +Passes the resource to nested groups: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + + feature :passage + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + + feature :api +end + +User::OnboardingFeature.enabled?(user: user) +# The :user resource is passed to: +# 1. User::OnboardingFeature (for :user_onboarding_passage) +# 2. BillingFeature (for :billing_api) +``` + +**Without nested: true:** + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + # nested: true is NOT specified + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + + feature :api +end + +# This will raise an error because BillingFeature expects :user +# but it's not passed from the parent +User::OnboardingFeature.enabled?(user: user) +``` + +## Combining Resource Options + +You can combine multiple options: + +```ruby +class MainFeature < ApplicationFeature + # Required resource, passed as option, passed to nested groups + resource :user, type: User, option: true, nested: true + + # Optional resource, passed as option, not nested + resource :account, type: Account, option: true, required: false + + # Required resource, not passed as option (only for validation) + resource :context, type: Hash + + feature :main + + group SubFeature +end +``` + +## Servactory Integration + +Featury uses Servactory for resource validation, which provides: + +### Type Checking + +```ruby +resource :user, type: User + +User::OnboardingFeature.enabled?(user: "not a user") +# Raises Servactory validation error +``` + +### Custom Validations + +You can use Servactory's validation features: + +```ruby +resource :user, type: User, option: true +resource :age, type: Integer, option: true, required: false +``` + +For more advanced validations, refer to the [Servactory documentation](https://github.com/servactory/servactory). + +## Resource Patterns + +### User Context + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + + feature :passage + group BillingFeature +end +``` + +Use this pattern when: +- Features are user-specific +- Nested groups need the same user context +- Using Flipper actors or similar + +### Multi-Resource Context + +```ruby +class OrganizationFeature < ApplicationFeature + resource :organization, type: Organization, option: true, nested: true + resource :user, type: User, option: true, nested: true + + feature :admin_panel + group BillingFeature +end +``` + +Use this pattern when: +- Features depend on multiple entities +- Both resources are needed for nested groups + +### Optional Global State + +```ruby +class ExperimentalFeature < ApplicationFeature + resource :user, type: User, option: true, required: false + + feature :beta_ui +end + +# Can be called globally +ExperimentalFeature.enabled? + +# Or for specific users +ExperimentalFeature.enabled?(user: user) +``` + +Use this pattern when: +- Features can be enabled globally or per-user +- Managing progressive rollouts + +### Validation-Only Resources + +```ruby +class AnalyticsFeature < ApplicationFeature + resource :event_data, type: Hash # Not option: true + resource :user, type: User, option: true + + feature :tracking + + action :track, web: :regular do |features:, **options| + # event_data is validated but not in options + # Only user is in options + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end +end +``` + +Use this pattern when: +- You need to validate a resource's type +- But don't need to pass it to the feature flag system + +## Accessing Resources + +Resources are validated but not directly accessible in action blocks. Instead, they're passed via `**options` if marked with `option: true`: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + resource :metadata, type: Hash # No option: true + + action :enabled?, web: :enabled? do |features:, **options| + # options contains only :user (marked with option: true) + # metadata is validated but not included in options + + user = options[:user] + features.all? { |feature| Flipper.enabled?(feature, user) } + end +end +``` + +## Resource Introspection + +Access resource definitions via `.info`: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + resource :account, type: Account, option: true, required: false +end + +User::OnboardingFeature.info.resources.all +# Returns resource collection with type and option information +``` + +See [Info and Introspection](./info-and-introspection.md) for details. + +## Next Steps + +- Learn about [Conditions](./conditions.md) that use resources for conditional logic +- Review [Actions](./actions.md) to understand how resources flow to action blocks +- Explore [Groups](./groups.md) and the `nested: true` option +- See [Examples](./examples.md) for real-world resource patterns diff --git a/docs/working-with-features.md b/docs/working-with-features.md new file mode 100644 index 0000000..083ce26 --- /dev/null +++ b/docs/working-with-features.md @@ -0,0 +1,471 @@ +# Working with Features + +Featury provides two primary ways to interact with features: direct method calls and the `.with()` method. Both approaches support the same actions and produce the same results. + +## Direct Method Calls + +Call actions directly on the feature class: + +```ruby +class User::OnboardingFeature < ApplicationFeature + prefix :user_onboarding + + resource :user, type: User, option: true + + feature :passage +end + +user = User.find(1) + +User::OnboardingFeature.enabled?(user: user) # => true +User::OnboardingFeature.enable(user: user) # => true +User::OnboardingFeature.disable(user: user) # => true +``` + +This approach is concise and works well for one-off checks or actions. + +## Using .with() + +The `.with()` method creates a feature instance with resources bound: + +```ruby +user = User.find(1) + +feature = User::OnboardingFeature.with(user: user) + +feature.enabled? # => true +feature.enable # => true +feature.disable # => true +``` + +### When to Use .with() + +**Multiple Operations on the Same Resources:** + +```ruby +feature = User::OnboardingFeature.with(user: user) + +if feature.disabled? + feature.enable + NotificationService.notify(user, "Onboarding enabled!") +end +``` + +**Passing Feature Instances:** + +```ruby +def enable_feature_for_user(feature) + return if feature.enabled? + + feature.enable + log_feature_change(feature) +end + +enable_feature_for_user(User::OnboardingFeature.with(user: user)) +``` + +**Cleaner Syntax:** + +```ruby +# Without .with() +if User::OnboardingFeature.enabled?(user: user) + User::OnboardingFeature.disable(user: user) +end + +# With .with() +feature = User::OnboardingFeature.with(user: user) +feature.disable if feature.enabled? +``` + +## Passing Resources + +Resources are passed as keyword arguments: + +### Single Resource + +```ruby +class User::ProfileFeature < ApplicationFeature + resource :user, type: User, option: true + + feature :editing +end + +User::ProfileFeature.enabled?(user: user) +# Or +User::ProfileFeature.with(user: user).enabled? +``` + +### Multiple Resources + +```ruby +class OrganizationFeature < ApplicationFeature + resource :organization, type: Organization, option: true + resource :user, type: User, option: true + + feature :admin_panel +end + +OrganizationFeature.enabled?(organization: org, user: user) +# Or +OrganizationFeature.with(organization: org, user: user).enabled? +``` + +### Optional Resources + +```ruby +class ExperimentalFeature < ApplicationFeature + resource :user, type: User, option: true, required: false + + feature :beta_ui +end + +# Without user (global check) +ExperimentalFeature.enabled? + +# With user (user-specific check) +ExperimentalFeature.enabled?(user: user) + +# Using .with() +ExperimentalFeature.with(user: user).enabled? +ExperimentalFeature.with.enabled? # No resources +``` + +## Result Aggregation + +Actions operate on **all features** in a class (including nested groups) and aggregate results based on your action definition. + +### All Features Must Match + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +class User::OnboardingFeature < ApplicationFeature + feature :passage + feature :completion + + group BillingFeature # Contains :billing_api +end + +# Returns true only if ALL three features are enabled: +# - :user_onboarding_passage +# - :user_onboarding_completion +# - :billing_api +User::OnboardingFeature.enabled?(user: user) +``` + +### Any Feature Can Match + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :any_enabled?, web: :enabled? do |features:, **options| + features.any? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +# Returns true if ANY feature is enabled +User::OnboardingFeature.any_enabled?(user: user) +``` + +### Negation (Disabled Check) + +```ruby +class ApplicationFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| Flipper.enabled?(feature, *options.values) } + end + + action :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(feature, *options.values) } + end + + action :enable, web: :enable do |features:, **options| + features.all? { |feature| Flipper.enable(feature, *options.values) } + end + + action :disable, web: :disable do |features:, **options| + features.all? { |feature| Flipper.disable(feature, *options.values) } + end + + action :add, web: :regular do |features:, **options| + features.all? { |feature| Flipper.add(feature, *options.values) } + end +end + +# Returns true if ANY feature is disabled +User::OnboardingFeature.disabled?(user: user) +``` + +### Custom Aggregation + +```ruby +class ApplicationFeature < Featury::Base + action :status, web: :regular do |features:, **options| + features.map do |feature| + { feature: feature, enabled: Flipper.enabled?(feature, *options.values) } + end + end +end + +User::OnboardingFeature.status(user: user) +# => [ +# { feature: :user_onboarding_passage, enabled: true }, +# { feature: :user_onboarding_completion, enabled: false }, +# { feature: :billing_api, enabled: true } +# ] +``` + +## Working with Nested Groups + +When calling actions on a parent feature, they cascade through all nested groups: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true, nested: true + + feature :passage + + group BillingFeature +end + +class BillingFeature < ApplicationFeature + resource :user, type: User, option: true + + feature :api + feature :webhooks +end + +# This checks THREE features: +# 1. :user_onboarding_passage +# 2. :billing_api +# 3. :billing_webhooks +User::OnboardingFeature.enabled?(user: user) +``` + +The `nested: true` option ensures resources are passed to `BillingFeature`. See [Resources](./resources.md) for details. + +## Feature-Specific Actions + +To work with individual features, create dedicated classes: + +```ruby +class BillingAPIFeature < ApplicationFeature + prefix :billing + + resource :user, type: User, option: true + + feature :api +end + +class BillingWebhooksFeature < ApplicationFeature + prefix :billing + + resource :user, type: User, option: true + + feature :webhooks +end + +# Control each feature independently +BillingAPIFeature.enable(user: user) +BillingWebhooksFeature.disable(user: user) +``` + +Or interact with your feature flag system directly: + +```ruby +Flipper.enable(:billing_api, user) +Flipper.disable(:billing_webhooks, user) +``` + +## Conditional Execution + +Features can have conditions that guard action execution: + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true + + condition ->(resources:) { resources.user.onboarding_awaiting? } + + feature :passage +end + +# If user.onboarding_awaiting? is false: +User::OnboardingFeature.enabled?(user: user) # => false (condition failed) + +# If user.onboarding_awaiting? is true: +User::OnboardingFeature.enabled?(user: user) # => (checks Flipper) +``` + +See [Conditions](./conditions.md) for complete details. + +## Common Patterns + +### Enable/Disable Toggle + +```ruby +feature = User::OnboardingFeature.with(user: user) + +if feature.enabled? + feature.disable +else + feature.enable +end +``` + +### Conditional Enable + +```ruby +feature = User::OnboardingFeature.with(user: user) + +feature.enable if user.premium? +``` + +### Status Check with Action + +```ruby +feature = BillingFeature.with(user: user) + +if feature.disabled? + feature.enable + NotificationService.notify(user, "Billing features enabled") +end +``` + +### Bulk Operations + +```ruby +users = User.where(premium: true) + +users.each do |user| + PremiumFeature.with(user: user).enable +end +``` + +### Feature Instance as Parameter + +```ruby +class FeatureManager + def enable_and_log(feature) + return if feature.enabled? + + feature.enable + Rails.logger.info("Enabled feature: #{feature.class.name}") + end +end + +manager = FeatureManager.new +manager.enable_and_log(User::OnboardingFeature.with(user: user)) +manager.enable_and_log(BillingFeature.with(user: user)) +``` + +## Error Handling + +### Missing Resources + +```ruby +class User::OnboardingFeature < ApplicationFeature + resource :user, type: User, option: true +end + +# Raises Servactory validation error +User::OnboardingFeature.enabled? +# => Error: :user is required + +# Correct usage +User::OnboardingFeature.enabled?(user: user) +``` + +### Invalid Resource Types + +```ruby +# Raises Servactory validation error +User::OnboardingFeature.enabled?(user: "not a user") +# => Error: :user must be of type User + +# Correct usage +User::OnboardingFeature.enabled?(user: User.new) +``` + +### Handling Exceptions + +```ruby +begin + feature = User::OnboardingFeature.with(user: user) + feature.enable +rescue Servactory::Errors::InputError => e + Rails.logger.error("Invalid feature parameters: #{e.message}") +rescue StandardError => e + Rails.logger.error("Feature error: #{e.message}") +end +``` + +## Testing + +### RSpec Examples + +```ruby +RSpec.describe User::OnboardingFeature do + let(:user) { create(:user) } + + describe ".enabled?" do + it "returns true when all features are enabled" do + allow(Flipper).to receive(:enabled?).and_return(true) + expect(User::OnboardingFeature.enabled?(user: user)).to be(true) + end + end + + describe ".with" do + it "creates a feature instance" do + feature = User::OnboardingFeature.with(user: user) + expect(feature).to respond_to(:enabled?) + end + end +end +``` + +## Next Steps + +- Learn about [Actions](./actions.md) and aggregation logic +- Review [Resources](./resources.md) for parameter handling +- Explore [Conditions](./conditions.md) for conditional execution +- See [Examples](./examples.md) for real-world usage patterns