From b5e8c3b8953d99518648db9babc77cc79ad2d3ca Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Mon, 1 Dec 2025 00:09:22 +0700 Subject: [PATCH 1/4] Add comprehensive documentation for actions and best practices **Changes:** - Introduced new documentation pages: `actions.md`, `best-practices.md`, `conditions.md`, and `examples.md`, providing detailed guidelines and examples. - `actions.md`: Detailed the structure and purpose of custom actions, web mappings, and callback usage. - `best-practices.md`: Provided conventions for naming, resource organization, and testing strategies. - `conditions.md`: Explained the role of conditions in feature logic with practical patterns. - `examples.md`: Demonstrated the implementation of features and their real-world applications. This extensive documentation ensures developers have robust guidelines for implementing and managing features. --- README.md | 152 ++---- docs/README.md | 81 +++ docs/actions.md | 305 ++++++++++++ docs/best-practices.md | 611 +++++++++++++++++++++++ docs/conditions.md | 336 +++++++++++++ docs/examples.md | 466 ++++++++++++++++++ docs/features.md | 173 +++++++ docs/getting-started.md | 118 +++++ docs/groups.md | 266 ++++++++++ docs/info-and-introspection.md | 497 +++++++++++++++++++ docs/integration.md | 869 +++++++++++++++++++++++++++++++++ docs/resources.md | 303 ++++++++++++ docs/working-with-features.md | 419 ++++++++++++++++ 13 files changed, 4485 insertions(+), 111 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/actions.md create mode 100644 docs/best-practices.md create mode 100644 docs/conditions.md create mode 100644 docs/examples.md create mode 100644 docs/features.md create mode 100644 docs/getting-started.md create mode 100644 docs/groups.md create mode 100644 docs/info-and-introspection.md create mode 100644 docs/integration.md create mode 100644 docs/resources.md create mode 100644 docs/working-with-features.md diff --git a/README.md b/README.md index c909f78..712b4bf 100644 --- a/README.md +++ b/README.md @@ -13,30 +13,49 @@ 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. +Complete documentation is available in the [docs](./docs) directory: -[//]: # (## 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) -[//]: # (See [featury.servactory.com](https://featury.servactory.com) for documentation.) +## 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 +89,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,106 +126,25 @@ 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 -``` - -You can also utilize the `with` method to pass necessary arguments. +User::OnboardingFeature.enable(user:) # => true +User::OnboardingFeature.disable(user:) # => true -```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" -``` - -These descriptions are preserved in the feature tree and can be accessed via the info method. - -#### 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. +feature.enable # => true +feature.disable # => true ``` ## Contributing -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. +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. ## License diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c767fa8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,81 @@ +# 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 + +## Quick Links + +- [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..145291b --- /dev/null +++ b/docs/actions.md @@ -0,0 +1,305 @@ +# 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 :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 +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..32eeff1 --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,611 @@ +# 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 ->(resources:) do + 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 { allow(user).to receive(:onboarding_awaiting?).and_return(true) } + + 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 + +Build admin UIs using the `.info` API: + +```ruby +class FeaturesController < ApplicationController + def index + @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 + end +end +``` + +### 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 ->(resources:) do + 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..619f4d2 --- /dev/null +++ b/docs/conditions.md @@ -0,0 +1,336 @@ +# 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 ->(resources:) do + 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 ->(resources:) do + 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 ->(resources:) do + # 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 ->(resources:) do + 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 ->(resources:) do + 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 ->(resources:) do + 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 ->(resources:) do + 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 ->(resources:) do + 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 ->(resources:) do + 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 { allow(user).to receive(:onboarding_awaiting?).and_return(true) } + + 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..c1f9205 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,466 @@ +# 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 + + 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 :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 +end + +class Organization::PremiumFeature < Organization::FeatureBase + prefix :org_premium + + resource :organization, type: Organization, option: true + resource :user, type: User, option: true, required: false + + condition ->(resources:) do + 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 ->(resources:) do + # 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 ->(resources:) do + 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 ->(resources:) do + 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 + +class ApplicationController < ActionController::Base + before_action :check_maintenance + + private + + def check_maintenance + # Global maintenance (no user required) + if MaintenanceFeature.info.features.all.any? { |f| f.name == :maintenance_global } && + Flipper.enabled?(:maintenance_global) + render "maintenance", status: 503 + return + end + + # User-specific maintenance bypass for admins + if current_user&.admin? + return + end + + # Check service-specific maintenance + if controller_name == "api" && Flipper.enabled?(:maintenance_api) + render json: { error: "API under maintenance" }, status: 503 + end + end +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) + +# In controllers +class AdminController < ApplicationController + before_action :require_admin + + private + + def require_admin + feature = AdminFeature.with(user: current_user) + unless feature.enabled? + redirect_to root_path, alert: "Access denied" + end + end +end +``` + +## 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 ->(resources:) do + # 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 ->(resources:) do + # 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..46c124f --- /dev/null +++ b/docs/features.md @@ -0,0 +1,173 @@ +# 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 +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..ad72a20 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,118 @@ +# 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 :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 +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..a04d25f --- /dev/null +++ b/docs/groups.md @@ -0,0 +1,266 @@ +# 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 +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 +end + +class CustomFeature < Featury::Base + action :enabled?, web: :enabled? do |features:, **options| + features.all? { |feature| CustomFeatureSystem.enabled?(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..cbe63e8 --- /dev/null +++ b/docs/info-and-introspection.md @@ -0,0 +1,497 @@ +# 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 + +### Admin Dashboard + +```ruby +class FeaturesController < ApplicationController + def index + @feature_classes = [ + User::OnboardingFeature, + BillingFeature, + PaymentSystemFeature + ] + + @features = @feature_classes.map do |klass| + info = klass.info + + { + class: klass.name, + actions: info.actions.web.all, + features: info.features.all, + groups: info.groups.all.map { |g| g.group_class.name } + } + end + end + + def show + feature_class = params[:class].constantize + info = feature_class.info + + @feature = { + actions: info.actions.web.all, + enabled_action: info.actions.web.enabled, + enable_action: info.actions.web.enable, + disable_action: info.actions.web.disable, + features: info.features.all, + groups: info.groups.all, + tree: { + features: info.tree.features, + groups: info.tree.groups + } + } + end +end +``` + +### 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..d8fed47 --- /dev/null +++ b/docs/working-with-features.md @@ -0,0 +1,419 @@ +# 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 +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 :any_enabled?, web: :enabled? do |features:, **options| + features.any? { |feature| Flipper.enabled?(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 :disabled?, web: :regular do |features:, **options| + features.any? { |feature| !Flipper.enabled?(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 From c02e18b01b9d04e307cc617539b91aae6908fc0b Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Mon, 1 Dec 2025 00:21:01 +0700 Subject: [PATCH 2/4] Improve README structure and enhance documentation links **Changes:** - Updated section headers in `README.md` to include emojis, improving visual organization. - Refined "Documentation" section to include a link to the official site, `featury.servactory.com`, for guides and API documentation. - Enhanced "Why Featury?" section with bullet points for better clarity and readability. - Added acknowledgment of contributors and polished the "Contributing" section with a welcoming tone. - Mirrored documentation updates in `docs/README.md` to ensure consistency in links and quick references. These changes enhance readability, navigation, and access to comprehensive resources for users and contributors. --- README.md | 39 +++++++++++++++++++-------------------- docs/README.md | 3 +++ 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 712b4bf..bbf5ef5 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,11 @@ Release Date

-## Documentation +## 📚 Documentation -Complete documentation is available in the [docs](./docs) directory: +See [featury.servactory.com](https://featury.servactory.com) for comprehensive guides and API documentation. + +Complete documentation is also available in the [docs](./docs) directory: - [Getting Started](./docs/getting-started.md) - [Features](./docs/features.md) @@ -29,21 +31,16 @@ Complete documentation is available in the [docs](./docs) directory: - [Examples](./docs/examples.md) - [Best Practices](./docs/best-practices.md) -## 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 +## 💡 Why Featury? -**Rich Introspection** — Full visibility into features, actions, and resources through the comprehensive info API +- 🎯 **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 -**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 +## 🚀 Quick Start ### Installation @@ -141,12 +138,14 @@ feature.enable # => true feature.disable # => true ``` -## Contributing +## 🤝 Contributing + +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. + +## 🙏 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 index c767fa8..bc66303 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,8 +41,11 @@ Featury is a feature flag management library that provides: - **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) From 8a28f107326c81dd24293cbd2772e738fc5d4dd3 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Mon, 1 Dec 2025 00:56:27 +0700 Subject: [PATCH 3/4] Refactor conditions and extend action examples across docs **Changes:** - Refactored condition blocks in all documentation files to consistently use the `lambda { |resources:| ... }` syntax, replacing the `->` syntax for better readability and alignment with convention. - Added detailed action examples (`add`, `disabled?`, `enable`, `disable`) across multiple documentation pages (`actions.md`, `best-practices.md`, `features.md`, `groups.md`, `examples.md`). - Enhanced section descriptions in `best-practices.md` and `examples.md` to better highlight use cases and demonstrate practical feature implementations. - Removed outdated examples and redundant sections (`AdminController`, `MaintenanceFeature`) from relevant documents to streamline content. These updates improve clarity, consistency, and accessibility for developers using the documentation. --- docs/actions.md | 8 ++++ docs/best-practices.md | 33 ++++++++------- docs/conditions.md | 36 ++++++++--------- docs/examples.md | 73 +++++++++++----------------------- docs/features.md | 16 ++++++++ docs/getting-started.md | 8 ++++ docs/groups.md | 48 ++++++++++++++++++++++ docs/info-and-introspection.md | 43 -------------------- docs/working-with-features.md | 52 ++++++++++++++++++++++++ 9 files changed, 190 insertions(+), 127 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index 145291b..fbbf9be 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -12,6 +12,10 @@ class ApplicationFeature < Featury::Base 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 @@ -19,6 +23,10 @@ class ApplicationFeature < Featury::Base 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 ``` diff --git a/docs/best-practices.md b/docs/best-practices.md index 32eeff1..1f8b6c0 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -250,9 +250,9 @@ Always check for presence when using `required: false`: class AnalyticsFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition ->(resources:) do + condition lambda { |resources:| resources.user.nil? || resources.user.analytics_enabled? - end + } end # Avoid - Will raise error if user is nil @@ -366,6 +366,7 @@ RSpec.describe User::OnboardingFeature do before { allow(user).to receive(:onboarding_awaiting?).and_return(true) } it "proceeds to check feature flags" do + allow(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user).and_return(true) expect(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user) User::OnboardingFeature.enabled?(user: user) end @@ -489,24 +490,22 @@ end ### Use .info for Runtime Discovery -Build admin UIs using the `.info` API: +Use the `.info` API to dynamically discover features and actions: ```ruby -class FeaturesController < ApplicationController - def index - @feature_classes = [BillingFeature, PaymentFeature] +feature_classes = [BillingFeature, PaymentFeature] - @features = @feature_classes.map do |klass| - info = klass.info +features = feature_classes.map do |klass| + info = klass.info - { - name: klass.name, - features: info.features.all, - actions: info.actions.web.all - } - end - end + { + 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 @@ -561,7 +560,7 @@ end ```ruby # Avoid - Complex business logic in conditions class PremiumFeature < ApplicationFeature - condition ->(resources:) do + condition lambda { |resources:| user = resources.user subscription = user.subscription @@ -569,7 +568,7 @@ class PremiumFeature < ApplicationFeature # 50 lines of complex logic # ... - end + } end # Good - Delegate to model methods diff --git a/docs/conditions.md b/docs/conditions.md index 619f4d2..d1887fc 100644 --- a/docs/conditions.md +++ b/docs/conditions.md @@ -31,9 +31,9 @@ class User::OnboardingFeature < ApplicationFeature resource :user, type: User, option: true resource :account, type: Account, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.user.active? && resources.account.premium? - end + } end ``` @@ -88,9 +88,9 @@ class OrganizationFeature < ApplicationFeature resource :organization, type: Organization, option: true resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.organization.active? && resources.user.admin? - end + } feature :admin_panel end @@ -104,10 +104,10 @@ When using `required: false`, check for resource presence: class AnalyticsFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition ->(resources:) do + condition lambda { |resources:| # Check if user is provided before accessing it resources.user.nil? || resources.user.analytics_enabled? - end + } feature :tracking end @@ -121,10 +121,10 @@ end class BillingFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.user.subscription&.active? && resources.user.subscription&.plan&.premium? - end + } feature :api end @@ -136,9 +136,9 @@ end class SeasonalFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| Date.current.month.in?([11, 12]) && resources.user.active? - end + } feature :holiday_theme end @@ -150,9 +150,9 @@ end class ExperimentalFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| ExperimentService.user_enrolled?(resources.user.id) - end + } feature :beta_ui end @@ -264,9 +264,9 @@ end class PremiumFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.user.subscription&.tier&.in?(["premium", "enterprise"]) - end + } feature :advanced_analytics end @@ -278,9 +278,9 @@ end class OnboardingStepFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.user.onboarding_state == "step_2" - end + } feature :step_2_completion end @@ -292,9 +292,9 @@ end class RegionalFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.user.country_code.in?(["US", "CA", "GB"]) - end + } feature :regional_payment_method end diff --git a/docs/examples.md b/docs/examples.md index c1f9205..83b99cf 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -24,6 +24,10 @@ class ApplicationFeature < Featury::Base 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 @@ -104,6 +108,10 @@ class Organization::FeatureBase < Featury::Base 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 @@ -111,6 +119,10 @@ class Organization::FeatureBase < Featury::Base 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 @@ -119,9 +131,9 @@ class Organization::PremiumFeature < Organization::FeatureBase resource :organization, type: Organization, option: true resource :user, type: User, option: true, required: false - condition ->(resources:) do + condition lambda { |resources:| resources.organization.subscription&.plan&.in?(["premium", "enterprise"]) - end + } feature :advanced_analytics, description: "Advanced analytics dashboard" feature :custom_branding, description: "Custom branding options" @@ -177,10 +189,10 @@ class AlphaFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition ->(resources:) do + condition lambda { |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" @@ -217,9 +229,9 @@ class RegionalFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |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" @@ -231,9 +243,9 @@ class EUFeature < ApplicationFeature resource :user, type: User, option: true - condition ->(resources:) do + condition lambda { |resources:| resources.user.country_code.in?(EU_COUNTRY_CODES) - end + } feature :gdpr_tools, description: "GDPR compliance tools" feature :sepa_payments, description: "SEPA payment support" @@ -313,30 +325,6 @@ class MaintenanceFeature < ApplicationFeature feature :scheduled_jobs, description: "Background jobs maintenance mode" end -class ApplicationController < ActionController::Base - before_action :check_maintenance - - private - - def check_maintenance - # Global maintenance (no user required) - if MaintenanceFeature.info.features.all.any? { |f| f.name == :maintenance_global } && - Flipper.enabled?(:maintenance_global) - render "maintenance", status: 503 - return - end - - # User-specific maintenance bypass for admins - if current_user&.admin? - return - end - - # Check service-specific maintenance - if controller_name == "api" && Flipper.enabled?(:maintenance_api) - render json: { error: "API under maintenance" }, status: 503 - end - end -end # Usage @@ -395,19 +383,6 @@ AdminFeature.enabled?(user: regular_user) # => false (condition fails) SuperAdminFeature.enabled?(user: super_admin) # => true SuperAdminFeature.enabled?(user: admin_user) # => false (condition fails) -# In controllers -class AdminController < ApplicationController - before_action :require_admin - - private - - def require_admin - feature = AdminFeature.with(user: current_user) - unless feature.enabled? - redirect_to root_path, alert: "Access denied" - end - end -end ``` ## Time-Based Features @@ -420,10 +395,10 @@ class SeasonalFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition ->(resources:) do + condition lambda { |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" @@ -435,13 +410,13 @@ class ScheduledFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition ->(resources:) do + condition lambda { |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 diff --git a/docs/features.md b/docs/features.md index 46c124f..3ecf4f1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -102,6 +102,22 @@ class ApplicationFeature < Featury::Base 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 ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index ad72a20..dfd924e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,6 +30,10 @@ class ApplicationFeature < Featury::Base 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 @@ -37,6 +41,10 @@ class ApplicationFeature < Featury::Base 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 ``` diff --git a/docs/groups.md b/docs/groups.md index a04d25f..c28b62b 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -137,6 +137,22 @@ 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 @@ -163,12 +179,44 @@ 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 diff --git a/docs/info-and-introspection.md b/docs/info-and-introspection.md index cbe63e8..68fbf5b 100644 --- a/docs/info-and-introspection.md +++ b/docs/info-and-introspection.md @@ -366,49 +366,6 @@ audit_features(User::OnboardingFeature) ## Practical Examples -### Admin Dashboard - -```ruby -class FeaturesController < ApplicationController - def index - @feature_classes = [ - User::OnboardingFeature, - BillingFeature, - PaymentSystemFeature - ] - - @features = @feature_classes.map do |klass| - info = klass.info - - { - class: klass.name, - actions: info.actions.web.all, - features: info.features.all, - groups: info.groups.all.map { |g| g.group_class.name } - } - end - end - - def show - feature_class = params[:class].constantize - info = feature_class.info - - @feature = { - actions: info.actions.web.all, - enabled_action: info.actions.web.enabled, - enable_action: info.actions.web.enable, - disable_action: info.actions.web.disable, - features: info.features.all, - groups: info.groups.all, - tree: { - features: info.tree.features, - groups: info.tree.groups - } - } - end -end -``` - ### API Documentation Generator ```ruby diff --git a/docs/working-with-features.md b/docs/working-with-features.md index d8fed47..083ce26 100644 --- a/docs/working-with-features.md +++ b/docs/working-with-features.md @@ -141,6 +141,22 @@ 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 @@ -161,9 +177,29 @@ User::OnboardingFeature.enabled?(user: user) ```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 @@ -174,9 +210,25 @@ User::OnboardingFeature.any_enabled?(user: user) ```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 From 3b44ce9a7a7185ebc0a8fcfb50061083edeafd8f Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Mon, 1 Dec 2025 01:19:41 +0700 Subject: [PATCH 4/4] Refactor lambda condition blocks in documentation **Changes:** - Updated all `lambda {}` condition blocks to consistently use the `do...end` syntax for improved readability. - Enhanced examples in `best-practices.md`, `examples.md`, and `conditions.md` with additional context and updated feature logic. - Adjusted test cases for `onboarding` conditions to include checks for new feature flags in `best-practices.md` and `conditions.md`. - Applied syntax adjustments to improve alignment with Ruby conventions across documentation files. These updates streamline documentation, improve readability, and ensure cohesive examples for developers. --- docs/best-practices.md | 14 ++++++++------ docs/conditions.md | 41 ++++++++++++++++++++++------------------- docs/examples.md | 24 ++++++++++++------------ 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 1f8b6c0..af33f18 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -250,9 +250,9 @@ Always check for presence when using `required: false`: class AnalyticsFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition lambda { |resources:| + condition(lambda do |resources:| resources.user.nil? || resources.user.analytics_enabled? - } + end) end # Avoid - Will raise error if user is nil @@ -363,10 +363,12 @@ RSpec.describe User::OnboardingFeature do describe "condition" do context "when user is awaiting onboarding" do - before { allow(user).to receive(:onboarding_awaiting?).and_return(true) } + 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 - allow(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user).and_return(true) expect(Flipper).to receive(:enabled?).with(:user_onboarding_passage, user) User::OnboardingFeature.enabled?(user: user) end @@ -560,7 +562,7 @@ end ```ruby # Avoid - Complex business logic in conditions class PremiumFeature < ApplicationFeature - condition lambda { |resources:| + condition(lambda do |resources:| user = resources.user subscription = user.subscription @@ -568,7 +570,7 @@ class PremiumFeature < ApplicationFeature # 50 lines of complex logic # ... - } + end) end # Good - Delegate to model methods diff --git a/docs/conditions.md b/docs/conditions.md index d1887fc..63acb9b 100644 --- a/docs/conditions.md +++ b/docs/conditions.md @@ -31,9 +31,9 @@ class User::OnboardingFeature < ApplicationFeature resource :user, type: User, option: true resource :account, type: Account, option: true - condition lambda { |resources:| + condition(lambda do |resources:| resources.user.active? && resources.account.premium? - } + end) end ``` @@ -88,9 +88,9 @@ class OrganizationFeature < ApplicationFeature resource :organization, type: Organization, option: true resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| resources.organization.active? && resources.user.admin? - } + end) feature :admin_panel end @@ -104,10 +104,10 @@ When using `required: false`, check for resource presence: class AnalyticsFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition lambda { |resources:| + condition(lambda do |resources:| # Check if user is provided before accessing it resources.user.nil? || resources.user.analytics_enabled? - } + end) feature :tracking end @@ -121,10 +121,10 @@ end class BillingFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| resources.user.subscription&.active? && resources.user.subscription&.plan&.premium? - } + end) feature :api end @@ -136,9 +136,9 @@ end class SeasonalFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| Date.current.month.in?([11, 12]) && resources.user.active? - } + end) feature :holiday_theme end @@ -150,9 +150,9 @@ end class ExperimentalFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| ExperimentService.user_enrolled?(resources.user.id) - } + end) feature :beta_ui end @@ -264,9 +264,9 @@ end class PremiumFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| resources.user.subscription&.tier&.in?(["premium", "enterprise"]) - } + end) feature :advanced_analytics end @@ -278,9 +278,9 @@ end class OnboardingStepFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| resources.user.onboarding_state == "step_2" - } + end) feature :step_2_completion end @@ -292,9 +292,9 @@ end class RegionalFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + condition(lambda do |resources:| resources.user.country_code.in?(["US", "CA", "GB"]) - } + end) feature :regional_payment_method end @@ -309,7 +309,10 @@ RSpec.describe User::OnboardingFeature do let(:user) { User.new } context "when user is awaiting onboarding" do - before { allow(user).to receive(:onboarding_awaiting?).and_return(true) } + 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) diff --git a/docs/examples.md b/docs/examples.md index 83b99cf..815d10e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -131,9 +131,9 @@ class Organization::PremiumFeature < Organization::FeatureBase resource :organization, type: Organization, option: true resource :user, type: User, option: true, required: false - condition lambda { |resources:| + 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" @@ -189,10 +189,10 @@ class AlphaFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition lambda { |resources:| + 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" @@ -229,9 +229,9 @@ class RegionalFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + 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" @@ -243,9 +243,9 @@ class EUFeature < ApplicationFeature resource :user, type: User, option: true - condition lambda { |resources:| + 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" @@ -395,10 +395,10 @@ class SeasonalFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition lambda { |resources:| + 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" @@ -410,13 +410,13 @@ class ScheduledFeature < ApplicationFeature resource :user, type: User, option: true, required: false - condition lambda { |resources:| + 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