ActiveInteraction

repository·main·Indexed 24 days ago

https://github.com/aaronlasseigne/active_interaction

A Ruby library for implementing service objects to manage business logic and validate inputs. It integrates with Rails and uses ActiveModel for validations. The library provides a variety of filters (such as string, integer, boolean, hash, and record) to define and validate input attributes before executing business logic via the #execute method.

Tokens
9.5K
Snippets
27
Records
50
Agent score
77%

What's inside ActiveInteraction

  1. Merge errors from models into interactions

    main

    When an interaction wraps a model operation (like save), you can use errors.merge!(model.errors) inside the #execute method. This transfers validation errors from the underlying model to the interaction outcome, allowing the controller to handle them as if they were interaction errors.

      def execute
        account = Account.new(inputs)
    
        unless account.save
          errors.merge!(account.errors)
        end
    
        account
      end
  2. Compose interactions within other interactions

    main

    You can run one interaction from within another using the #compose method.

    • If the composed interaction succeeds, it returns the result (equivalent to calling .run!).
    • If the composed interaction fails, execution halts immediately and its errors are merged into the caller's errors.

    You can also use .import_filters to bring in input filters from another interaction, which is useful when delegating to another interaction using inputs.

    class Add < ActiveInteraction::Base
      integer :x, :y
    
      def execute
        x + y
      end
    end
    
    class AddThree < ActiveInteraction::Base
      integer :x
    
      def execute
        compose(Add, x: x, y: 3)
      end
    end
    
    # Using import_filters to delegate
    class AddAndDouble < ActiveInteraction::Base
      import_filters Add
    
      def execute
        compose(Add, inputs) * 2
      end
    end
  3. Define filters in an ActiveInteraction

    main

    Filters are defined inside an interaction class using class methods. Each filter defines an input attribute and its type.

    Filter Signature:

    • name: The symbolic name of the attribute.
    • options (optional): A hash of options including:
      • default: The fallback value if nil is provided. Set default: nil to make a filter optional.
      • desc: A human-readable description for documentation.
      • index_errors: (Boolean) When set to true, errors for nested attributes are indexed (e.g., name[1]).
      • strip: (Boolean) For string filters, controls whether leading/trailing whitespace is removed (defaults to true).
      • base: (Integer) For integer filters, sets the radix (default is 10).
      • digits: (Integer) For decimal filters, specifies significant digits.
      • format: (String) For date/time filters, specifies a format string for .strptime.
      • class: (Class/String/Symbol) For object filters, specifies the required class.
      • converter: (Symbol/Proc) For object filters, a method or proc to transform input into the required class.
      • finder: (Symbol) For record filters, specifies the method used to locate the record.
      • from: (Class/Module) For interface filters, specifies the required ancestor.
      • methods: (Array of Symbols) For interface filters, defines an anonymous interface requiring specific methods.
    • block (optional): Used by array and hash filters to define sub-filters.
    array :x, :y, :z,
      default: nil,
      desc: 'an example filter' do
      # Sub-filters go here
    end
  4. Use interactions as ActiveModel form objects

    main

    Interactions behave like ActiveModel, meaning they can be initialized with .new and used directly in Rails forms.

    To make an interaction work effectively with Rails forms, implement the #to_model method. This ensures the view uses the correct model context.

    In the controller, you can initialize a new interaction instance to pass to the new action:

    def new
      @account = CreateAccount.new
    end
    class CreateAccount < ActiveInteraction::Base
      string :first_name, :last_name
    
      validates :first_name, :last_name, presence: true
    
      def to_model
        Account.new
      end
    
      def execute
        account = Account.new(inputs)
    
        unless account.save
          errors.merge!(account.errors)
        end
    
        account
      end
    end
  5. Handle 'Not Found' errors in Rails controllers

    main

    Calling .run! on an interaction that fails to find a record will raise an ActiveInteraction::InvalidInteractionError. In Rails, this results in a 500 error instead of a 404.

    To correctly trigger a 404, use .run and manually raise ActiveRecord::RecordNotFound if the outcome is invalid.

    # GET /accounts/:id
    def show
      @account = find_account!
    end
    
    private
    
    def find_account!
      outcome = FindAccount.run(params)
    
      if outcome.valid?
        outcome.result
      else
        fail ActiveRecord::RecordNotFound, outcome.errors.full_messages.to_sentence
      end
    end
  6. Use ActiveModel validations in interactions

    main

    ActiveInteraction integrates with ActiveModel validations. After ActiveInteraction verifies the input types via filters, it then runs any validates declarations you have defined. If either the type filters or the validations fail, the #execute method will not run.

    class SayHello < ActiveInteraction::Base
      string :name
    
      validates :name, presence: true
    
      def execute
        "Hello, #{name}!"
      end
    end
    
    # This will raise an error because of the ActiveModel presence validation
    SayHello.run!(name: '')
    # => ActiveInteraction::InvalidInteractionError: Name can't be blank
  7. Use interactions as Form Objects

    main

    An interaction can be used as a form object in Rails. To make it behave like a specific model (e.g., for form_for to generate correct URLs and parameter names), implement the to_model method to return an instance of that model.

    When using .run, the returned outcome can be used to check validity and display errors in the view, similar to an ActiveRecord object.

    # In the interaction class
    class CreateAccount < ActiveInteraction::Base
      def to_model
        Account.new
      end
    end
    
    # In the controller
    def create
      outcome = CreateAccount.run(params.fetch(:account, {}))
      if outcome.valid?
        redirect_to(outcome.result)
      else
        @account = outcome
        render(:new)
      end
    end
    
    # In the view
    <%= form_for @account do |f| %>
      <%= f.text_field :first_name %>
    <% end %>
  8. Run interactions in Rails controllers

    main

    When using interactions in controllers, you have two primary ways to execute them:

    1. .run!: Use this when you expect the interaction to succeed. If it fails, it will raise an ActiveInteraction::InvalidInteractionError. This is suitable for actions like index where failure implies a developer error.
    2. .run: Use this when you want to handle success or failure gracefully (e.g., in create or update actions). It returns an outcome object that you can check using .valid?.

    Note on Strong Parameters: You do not need to use params.require or params.permit. Interactions automatically ignore any inputs that were not explicitly defined by filters.

    # Using .run! for expected success
    def index
      @accounts = ListAccounts.run!
    end
    
    # Using .run for handling validation failures
    def create
      outcome = CreateAccount.run(params.fetch(:account, {}))
    
      if outcome.valid?
        redirect_to(outcome.result)
      else
        @account = outcome
        render(:new)
      end
    end
  9. Define and run an interaction

    main

    To create an interaction, subclass ActiveInteraction::Base, define your inputs using filters, and implement #execute.

    Use .run(hash) to execute an interaction safely. It returns an outcome object that you can inspect for validity and errors. Use .result to retrieve the value returned by #execute.

    Use .run!(hash) to execute an interaction strictly. If inputs are invalid, it raises an ActiveInteraction::InvalidInteractionError. If valid, it returns the result of #execute directly.

    require 'active_interaction'
    
    class Square < ActiveInteraction::Base
      float :x
    
      def execute
        x**2
      end
    end
    
    # Using .run (returns an outcome object)
    outcome = Square.run(x: 2.1)
    outcome.valid? # => true
    outcome.result # => 4.41
    
    # Using .run! (returns the result or raises error)
    Square.run!(x: 2.1) # => 4.41
    Square.run!(x: 'invalid') # Raises ActiveInteraction::InvalidInteractionError
  10. Integrate ActiveInteraction with Rails

    main

    ActiveInteraction can replace models or controllers for handling business logic in Rails applications.

    It is recommended to place interactions in app/interactions and group them by model to maintain organization:

    app/
      controllers/
        accounts_controller.rb
      interactions/
        accounts/
          create_account.rb
          destroy_account.rb
          find_account.rb
          list_accounts.rb
          update_account.rb
      models/
        account.rb
      views/
        account/
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
  11. Configure i18n translations for ActiveInteraction

    main

    ActiveInteraction is i18n aware. You can customize error messages and attribute names by adding translations to your locale files (e.g., config/locales/en.yml).

    Key translation namespaces:

    • active_interaction.types: To rename type names (e.g., string to text).
    • active_interaction.errors.messages: To customize error message templates.
    • active_interaction.attributes: To provide human-readable names for specific interaction attributes.
    en:
      active_interaction:
        attributes:
          product:
            num: 'Number'
        types:
          string: 'text'
        errors:
          messages:
            invalid_type: '%{type} is not valid'