Giter VIP home page Giter VIP logo

actor's Introduction

ServiceActor

This Ruby gem lets you move your application logic into small composable service objects. It is a lightweight framework that helps you keep your models and controllers thin.

Photo of theater seats

Contents

Installation

Add the gem to your application’s Gemfile by executing:

bundle add service_actor

Extensions

For Rails generators, you can use the service_actor-rails gem:

bundle add service_actor-rails

For TTY prompts, you can use the service_actor-promptable gem:

bundle add service_actor-promptable

Usage

Actors are single-purpose actions in your application that represent your business logic. They start with a verb, inherit from Actor and implement a call method.

# app/actors/send_notification.rb
class SendNotification < Actor
  def call
    # …
  end
end

Trigger them in your application with .call:

SendNotification.call # => <ServiceActor::Result…>

When called, an actor returns a result. Reading and writing to this result allows actors to accept and return multiple arguments. Let’s find out how to do that and then we’ll see how to chain multiple actors together.

Inputs

To accept arguments, use input to create a method named after this input:

class GreetUser < Actor
  input :user

  def call
    puts "Hello #{user.name}!"
  end
end

You can now call your actor by providing the correct arguments:

GreetUser.call(user: User.first)

Outputs

An actor can return multiple arguments. Declare them using output, which adds a setter method to let you modify the result from your actor:

class BuildGreeting < Actor
  output :greeting

  def call
    self.greeting = "Have a wonderful day!"
  end
end

The result you get from calling an actor will include the outputs you set:

actor = BuildGreeting.call
actor.greeting # => "Have a wonderful day!"
actor.greeting? # => true

If you only have one value you want from an actor, you can skip defining an output by making it the return value of .call() and calling your actor with .value():

class BuildGreeting < Actor
  input :name

  def call
    "Have a wonderful day, #{name}!"
  end
end

BuildGreeting.value(name: "Fred") # => "Have a wonderful day, Fred!"

Fail

To stop the execution and mark an actor as having failed, use fail!:

class UpdateUser < Actor
  input :user
  input :attributes

  def call
    user.attributes = attributes

    fail!(error: "Invalid user") unless user.valid?

    # …
  end
end

This will raise an error in your application with the given data added to the result.

To test for the success of your actor instead of raising an exception, use .result instead of .call. You can then call success? or failure? on the result.

For example in a Rails controller:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    actor = UpdateUser.result(user: user, attributes: user_attributes)
    if actor.success?
      redirect_to actor.user
    else
      render :new, notice: actor.error
    end
  end
end

Play actors in a sequence

To help you create actors that are small, single-responsibility actions, an actor can use play to call other actors:

class PlaceOrder < Actor
  play CreateOrder,
       PayOrder,
       SendOrderConfirmation,
       NotifyAdmins
end

Calling this actor will now call every actor along the way. Inputs and outputs will go from one actor to the next, all sharing the same result set until it is finally returned.

If you use .value() to call this actor, it will give the return value of the final actor in the play chain.

Rollback

When using play, if an actor calls fail!, the following actors will not be called.

Instead, all the actors that succeeded will have their rollback method called in reverse order. This allows actors a chance to cleanup, for example:

class CreateOrder < Actor
  output :order

  def call
    self.order = Order.create!()
  end

  def rollback
    order.destroy
  end
end

Rollback is only called on the previous actors in play and is not called on the failing actor itself. Actors should be kept to a single purpose and not have anything to clean up if they call fail!.

Inline actors

For small work or preparing the result set for the next actors, you can create inline actors by using lambdas. Each lambda has access to the shared result. For example:

class PayOrder < Actor
  input :order

  play -> actor { actor.order.currency ||= "EUR" },
       CreatePayment,
       UpdateOrderBalance,
       -> actor { Logger.info("Order #{actor.order.id} paid") }
end

You can also call instance methods. For example:

class PayOrder < Actor
  input :order

  play :assign_default_currency,
       CreatePayment,
       UpdateOrderBalance,
       :log_payment

  private

  def assign_default_currency
    order.currency ||= "EUR"
  end

  def log_payment
    Logger.info("Order #{order.id} paid")
  end
end

If you want to do work around the whole actor, you can also override the call method. For example:

class PayOrder < Actor
  # …

  def call
    Time.with_timezone("Paris") do
      super
    end
  end
end

Play conditions

Actors in a play can be called conditionally:

class PlaceOrder < Actor
  play CreateOrder,
       Pay
  play NotifyAdmins, if: -> actor { actor.order.amount > 42 }
  play CreatePayment, unless: -> actor { actor.order.currency == "USD" }
end

Input aliases

You can use alias_input to transform the output of an actor into the input of the next actors.

class PlaceComment < Actor
  play CreateComment,
       NotifyCommentFollowers,
       alias_input(commenter: :user),
       UpdateUserStats
end

Input options

Defaults

Inputs can be optional by providing a default value or lambda.

class BuildGreeting < Actor
  input :name
  input :adjective, default: "wonderful"
  input :length_of_time, default: -> { ["day", "week", "month"].sample }
  input :article,
        default: -> context { context.adjective.match?(/^aeiou/) ? "an" : "a" }

  output :greeting

  def call
    self.greeting = "Have #{article} #{length_of_time}, #{name}!"
  end
end

actor = BuildGreeting.call(name: "Jim")
actor.greeting # => "Have a wonderful week, Jim!"

actor = BuildGreeting.call(name: "Siobhan", adjective: "elegant")
actor.greeting # => "Have an elegant week, Siobhan!"

Allow nil

By default inputs accept nil values. To raise an error instead:

class UpdateUser < Actor
  input :user, allow_nil: false

  # …
end

Conditions

You can ensure an input is included in a collection by using inclusion:

class Pay < Actor
  input :currency, inclusion: %w[EUR USD]

  # …
end

This raises an argument error if the input does not match one of the given values.

Declare custom conditions with the name of your choice by using must:

class UpdateAdminUser < Actor
  input :user,
        must: {
          be_an_admin: -> user { user.admin? }
        }

  # …
end

This will raise an argument error if any of the given lambdas returns a falsey value.

Types

Sometimes it can help to have a quick way of making sure we didn’t mess up our inputs.

For that you can use the type option and giving a class or an array of possible classes. If the input or output doesn’t match these types, an error is raised.

class UpdateUser < Actor
  input :user, type: User
  input :age, type: [Integer, Float]

  # …
end

You may also use strings instead of constants, such as type: "User".

When using a type condition, allow_nil defaults to false.

Custom input errors

Use a Hash with is: and message: keys to prepare custom error messages on inputs. For example:

class UpdateAdminUser < Actor
  input :user,
        must: {
          be_an_admin: {
            is: -> user { user.admin? },
            message: "The user is not an administrator"
          }
        }

  # ...
end

You can also use incoming arguments when shaping your error text:

class UpdateUser < Actor
  input :user,
        allow_nil: {
          is: false,
          message: (lambda do |input_key:, **|
            "The value \"#{input_key}\" cannot be empty"
          end)
        }

  # ...
end
See examples of custom messages on all input arguments

Inclusion

class Pay < Actor
  input :provider,
        inclusion: {
          in: ["MANGOPAY", "PayPal", "Stripe"],
          message: (lambda do |value:, **|
            "Payment system \"#{value}\" is not supported"
          end)
        }
end

Must

class Pay < Actor
  input :provider,
        must: {
          exist: {
            is: -> provider { PROVIDERS.include?(provider) },
            message: (lambda do |value:, **|
              "The specified provider \"#{value}\" was not found."
            end)
          }
        }
end

Default

class MultiplyThing < Actor
  input :multiplier,
        default: {
          is: -> { rand(1..10) },
          message: (lambda do |input_key:, **|
            "Input \"#{input_key}\" is required"
          end)
        }
end

Type

class ReduceOrderAmount < Actor
  input :bonus_applied,
        type: {
          is: [TrueClass, FalseClass],
          message: (lambda do |input_key:, expected_type:, given_type:, **|
            "Wrong type \"#{given_type}\" for \"#{input_key}\". " \
              "Expected: \"#{expected_type}\""
          end)
        }
end

Allow nil

class CreateUser < Actor
  input :name,
        allow_nil: {
          is: false,
          message: (lambda do |input_key:, **|
            "The value \"#{input_key}\" cannot be empty"
          end)
        }
end

Testing

In your application, add automated testing to your actors as you would do to any other part of your applications.

You will find that cutting your business logic into single purpose actors will make it easier for you to test your application.

FAQ

Howtos and frequently asked questions can be found on the wiki.

Thanks

This gem is influenced by (and compatible with) Interactor.

Thank you to the wonderful contributors.

Thank you to @nicoolas25, @AnneSottise & @williampollet for the early thoughts and feedback on this gem.

Photo by Lloyd Dirks.

Contributing

See CONTRIBUTING.md.

License

The gem is available as open source under the terms of the MIT License.

actor's People

Contributors

afuno avatar arturgin avatar dependabot[bot] avatar fabdbt avatar jamesarosen avatar llhhaa avatar mdotvasylenchuk avatar pboling avatar petergoldstein avatar rockwellll avatar sontixyou avatar stephannv avatar sunny avatar viralpraxis avatar williampollet avatar yimajo avatar ylecuyer avatar yyamanoi1222 avatar zw963 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

actor's Issues

Feature: Promptable

I use Actors to run complex business logic, often from console or a rake task. I'd love to be able to integrate prompts without needing to think about how to integrate.

It would only be a few lines of code either way, but it seems like a helpful enough ability to provide it to everyone.
https://github.com/piotrmurach/tty-prompt

One downside is adding a new dependency to this gem. If that's too big an ask, then I can make a plugem called service_actor-promptable

What about improving the README about testing?

Thank you for this nice gem! 😄
In past I used the Interactor gem quite a lot, I recently switched to Actor and I find it a nice improvement.

As in the subject: perhaps it could be nice to suggest some testing strategies for actors.

For example, with RSpec I recently wrote something like this:

RSpec.describe ParseFileName, type: :actor do
  describe ".result" do
    subject(:result) { described_class.result(file_name:) }

    context "with a valid file_name" do
      let(:file_name) { "sample_file_name_20221229010101.csv" }

      it { expect(result).to be_a_success }

      it "parses the file_name and returns an Hash" do
        expected_hash = {
          prefix: :sample,
          name: "file_name",
          timestamp: Time.zone.strptime("20221229010101", "%Y%m%d%H%M%S")
        }
        expect(result.formatted_attributes).to eq expected_hash
      end
    end

    context "with an invalid file_name" do
      let(:file_name) { "wrong_name.csv" }

      it { expect(result).to be_a_failure }
      it { expect(result.error).to eq "Invalid format for \"#{file_name}\"" }
    end
  end
end

In this case I preferred to use result to avoid checking exceptions.

WDYT?

Validating params or attributes

First of all, thanks for the great work! 💯

In interactor with the interactor-contracts gem, it's possible to validate a hash like this:

expects do
  required(:user).filled(type?: User)
  required(:params).schema do
    required(:email).filled(:str?)
    required(:first_name).filled(:str?)
    optional(:middle_name)
  end
end

Is there a similar way? Or a recommended way of achieving the same thing?

[Feature][Solution included] Allows boolean outputs to have `result.boolean?` accessors instead of `result.boolean`

Hey,

Thank you very much for this small yet precious gem. I really like how simple it is and working with it.

I had this idea for quite some time. Only recently i was able to pull it off.

So, i had an actor that we either creating or updating a record. I had a output :new_record on the actor. Moreover, i could access it using ActorClass.result.new_record.

However, i wanted it to give the impression that this accessor(new_record) is a boolean output. In accordance with Ruby's best practices for naming boolean methods and postfixing them with a ? character. I added some logic to support that. I would like your opinion. And, maybe shipping this behaviour by default in this gem.

For starters, i have my own ApplicationActor that inherits from Actor. Which looks like this

class ApplicationActor
  include ServiceActor::Base

  def self.call(options = nil, **arguments)
    result = super options, **arguments

    result.each_pair do |key, value|
      if outputs.dig(key, :boolean)
        result.define_singleton_method "#{key}?", -> { value }
      end
    end
  end
end

I'm adding some extra behaviour to Actor.call static method. Simply calling it. Then for each key in the result struct i determine if it has the :boolean argument.

To tell Application actor to generate these methods. It can be like so

class AnyActor < ApplicationActor
  output :new_record, boolean: true
end

Am i doing anything wrong here?.

Thank you for your work on this gem and hope this helps someone trying to do the same.

Keep Zeitwerk as a development dependency only

This article by @pepawel gives an interesting way to remove the Zeitwerk dependency in production and replace it with a script that builds a loader.

It sounds like an interesting idea, so if anybody wants to tackle this, it should be rather straightforward by following the article and make the ServiceActor gem a bit more lightweight and easy to install 🪶

See also this discussion.

Inhibit method redefined warning if user redefined input method in service object.

Following is a example:

class InhibitMethodRedefinedWarning < Actor
  input :page, default: 1, type: [Integer, String]

  def call
    result.page = page
  end

  private

  def page
    result.page.to_i
  end
end

when i run test, get many warn message like this:

/home/zw963/Stocks/marketbet_crawler/app/services/retrieve_insider_history.rb:97: warning: method redefined; discarding old days
/home/zw963/others/.rvm/gems/ruby-3.0.2@marketbet_crawler/gems/service_actor-3.1.2/lib/service_actor/attributable.rb:26: warning: previous definition of days was here

If this gem expect user override input same name method this way, it should do some hack for avoid produce those warning.
the offical recommand way for this is to add a alias. as following code, (i copy from sequel gem).

you can check discuss here.

https://groups.google.com/g/sequel-talk/c/fwa1Pwye_CY/m/DANKiBGOBQAJ

And this hack should fix many warnings when run rspec --warning for gem spec.

Missing note on stubbing ServiceActor::Result

I'm trying to fake a service actor result in a Rails request spec. The test I would like to write looks like this:

context 'when search fails (e.g. because of HTTP 503 on Google side)' do
  let(:params) { { query: 'Darkstorm Galaxy' } }

  before do
    allow(Google::AutocompletePlaces).to receive(:result).and_return(
      instance_double(ServiceActor::Result, success?: false, error: 'lol')
    )
  end

  it 'returns suggestions' do
    get api_authenticated_address_search_suggestions_path, params: params, headers: auth_headers

    expect(response).to have_http_status(:service_unavailable)
    expect(response.parsed_body).to include('application_errors' => be_a(String))
  end
end

But this doesn't work because error is derived from method_missing. And I shouldn't use double because of RSpec best practices enforced by rubocop.

What's your take on that?

[Discuss] actor default: option not so useful for my usage.

Hi, i am a user come from interactor, i use this gem for all my service , until, i meet a issue, i want a guarantee to ensure pass correct name input into my service, so, i switch to use service_actor, one if the reason is, this gem not use 'activesupport'.

Following is my code:

I define my service like this:

class RetrieveStocks < Actor
  input :sort_column
  input :sort_direction
  input :page, default: '1'
  input :per, default: '20'
  input :stock_name

  def call
    # ...
  end
end

Then, invoke my service in my roda routers, like this:

     sort_column, sort_direction, stock_name, page, per = r.params.values_at('sort_column', 'sort_direction', 'stock_name', 'page', 'per')
	 
      result = RetrieveStocks.(
        sort_column: sort_column,
        sort_direction: sort_direction,
        page: page,
        per: per,
        stock_name: stock_name,
      )

Following is my issue, because above page, per is come from request params,
so, them maybe string, or nil(when user not use paginate), when pass both
of them into service, if request not use paginate, will pass page and per as nil.
like following:

      result = RetrieveStocks.(
        sort_column: sort_column,
        sort_direction: sort_direction,
        page: nil,
        per: nil,
      )

For this case, what i want is, use page/per default value, but, it not work.

AFAIK, the only solution is, run compact! before passed in.

      result = RetrieveStocks.({
        sort_column: sort_column,
        sort_direction: sort_direction,
        page: nil,
        per: nil,
		}.compact!)

But i consider this solution is not good, so, just curious, what is the expected way to do this?

Thank you.


EDIT:

I workaround this issue use following code.

params = r.params.slice('sort_column', 'sort_direction', 'stock_name', 'page', 'per')

result = RetrieveStocks.(params)

It works anyway, though, i still curious, how to handle this case when use with actor.

Invoking undefined methods on a result does not raise errors

please check following example.

# i assign result.institutions = ??? in this service.
result = RetrieveInstitutions.result(params)

if result.success?
    @institutions = result.institutions1 # But i never defined this method in above service, but when invoke it, not raise any error, just return nil, this is not acceptable?
end

Above result.institutions1 never raise any error, just return nil, which make result.success? almost no any means for this case.

Dependency injection

First of all, thank you for the work in this lib! I've found it very useful and I'm giving it a go in a personal project to play around.
I've found a hurdle that I would appreciate if you can provide some tips on how to proceed, hopefully, it could help others as well.

I see play as a way of dependency injection, although inside of an actor I would like to call another actor and perform some logic basic on inputs and outputs.

class ServiceClient < Actor
  input :token, allow_nil: false
  input :term, allow_nil: false

  def call
    Faraday.new('http://example.com/service').get("", query: term)
  end
end
class Persistor < Actor
  input :client, default: ServiceClient
  
  def class
    outcome = ServiceClient.result(token: 'token', term: 'actor')
    puts outcome.status   if outcome.success?
  end
end

The problem is when I use input :client, default: ServiceClient it tries to initialize the class and then failed because of the missing required arguments. If I move provider as a private method it works.

Is there any way to achieve dependency injection?

[FeatureRequest] Collection of admissibles values for input

Would'nt it be great to be able to specify a collection of admissible values for certain inputs ?

For instance, you could have:

class BuildGreeting < Actor
  input :name
  input :adjective, default: 'wonderful', in: ['brillant', 'wonderful', 'smart', 'dazzling', 'blazing']
  input :length_of_time, default: -> { ['day', 'week', 'month'].sample }

  output :greeting

  def call
    self.greeting = "Have a #{adjective} #{length_of_time} #{name}!"
  end
end

Or, if you defined your GREAT_ADJECTIVES_LIST anywhere else in the code,

class BuildGreeting < Actor
  input :name
  input :adjective, default: 'wonderful', in: GREAT_ADJECTIVES_LIST
  input :length_of_time, default: -> { ['day', 'week', 'month'].sample }

  output :greeting

  def call
    self.greeting = "Have a #{adjective} #{length_of_time} #{name}!"
  end
end

New notation for array inputs

Hey @sunny !!

Thanks for the amazing work :D

I have an idea, regarding inputs and outputs.

What do you think about accepting a new notation when expecting an array of similar objects, such as:

    inputs :orders, type: [Orders]

(Similar to the GraphQL notation)

This way it will be easier to type arrays.

Evaluate conditions only once

When the if: condition is used on play, the lambda is evaluated once per actor, when it should only be evaluated once before playing the whole group.

class PrintThings < Actor
  play -> _ { puts "one" },
       -> _ { puts "two" },
       -> _ { puts "three" },
       if: -> _ { puts "should be evaluated only once"; true }
end

PrintThings.call

Current output of calling this actor:

should be evaluted only once
one
should be evaluted only once
two
should be evaluted only once
three

Expected:

should be evaluted only once
one
two
three

Release notes for 3.8.0

We got tones of tests crashing with 3.8.0 update. It's be great to have some release notes for the new version with a summary of what has changes, some deprecations and reasoning behind them and maybe some migration paths described as well.

Invoking undefined methods on `ServiceActor::Result` will lead to runtime errors in the next major release of Actor.

Hello.

After updating to version 3.8.0, a large number of project services in RSpec began to crash with this comment:

DEPRECATED: Invoking undefined methods on `ServiceActor::Result` will lead to runtime errors in the next major release of Actor.

In one of the examples, the service was mocked using allow and the expected result was indicated:

allow(MyService).to(
  receive(:result)
    .and_return(
      ServiceActor::Result.new(failure?: true, error: "Some error")
    )
)

But at the same time, when actually checking the result of the service, the following data appears:

service_result.failure?
# => false

service_result.success?
# => true

Please tell me what I'm doing wrong and what happened?

Repeated `play` execution on 3.9.0

After upgrading to 3.9.0, I noticed something odd is happening. I managed to simplify it to the following MRE:

# frozen_string_literal: true

# some other actor defined somewhere
Another = Class.new(ApplicationActor) do
  output :outcome

  def call
    p "ENTER"

    self.outcome = nil
  end
end

class TestActor < ApplicationActor
  play Another
end

Invoking it shows Another#call was executed twice:

[3] pry(main)> TestActor.result;
"ENTER"
"ENTER"

And on 3.8.1:

[2] pry(main)> TestActor.result;
"ENTER"

I think this is a critical bug -- I was extremely lucky to notice it before deploying to production. In my case it was doubled external API calls..

Type example

Hi.

Have a question.

Where create type class?

What is the content of a type class?

Using `Kernel.tap` and `Kernel.then` via `ServiceActor::Result` receiver

#138 made Result class inherit from BasicObject. One thing I missied is that now it's impossible to call tap/then on service result:

SomeActor.call.tap { do_something(_1) }
SomeActor.call.then { _1.some_output = ... }

I think we can add these methods because collisions here are unlikely to happen and it seems reasonable in general

Play symbols

This is a feature idea for using Ruby symbols as play arguments.

Currently

Sometimes, we need to interlace small actions with larger ones inside a play. For that, instead of giving it actors we can give it lambda actions.

For example :

class PlaceOrder < ApplicationActor
  input :order
  input :log, default: true

  play -> actor do
         actor.order.currency ||= Current.currency
         actor.order.save! if actor.order.changes.any?
       end,
       Orders::Create,
       Orders::Notify,
       -> actor { Logger.log("Order #{actor.order.id} created") if actor.log }
end

Ideally

In some cases it could be nice to be able to give it instance methods as well:

class PlaceOrder < ApplicationActor
  input :order
  input :log, default: true

  play :set_current_currency,
       Orders::Create,
       Orders::Notify,
       :log_order_creation

  private

  def set_current_currency
   order.currency ||= Current.currency
   order.save! if order.changes.any?
  end

  def log_order_creation
    Logger.log("Order #{order.id} created") if log
  end
end

Breaking changes on ServiceActor::Result `failure?` after dropping `ostruct` ???

My tests started to fail after upgrading to 3.8.x because changes on Result.

My code before 3.8.x:

allow(MyActor).to receive(:result).and_return(ServiceActor::Result.new(failure?: true))

result = MyActor.result
result.success? # => false
result.failure? # => true

After upgrading to 3.8.x:

allow(MyActor).to receive(:result).and_return(ServiceActor::Result.new(failure?: true))

result = MyActor.result
result.success? # => true
result.failure? # => false

I think what caused the problem was this line: c73d688#diff-6d7487eb2415132d6b0752596159f369f60fc82446b1a372ea0d5841fed7678cL25

Fixing things on my side is easy, I have to change all ServiceActor::Result.new(failure?: true, ...) by ServiceActor::Result.new(failure: true, ...), but I'm reporting here because it could be a problem for more people.

"default" and "in" input attributes don't behave as expected.

If I have an input that has both the default and in attribute, and I call the actor without that input, I would expect the default to be used. Instead I get a ServiceActor::ArgumentError because my lack of input doesn't satisfy the in: property.

Example

class MyActor < Actor
  input :word, default: "one", in: ["one", "two", "three"]
  ...
end

When calling this actor like MyActor.call, I would expect the word variable to be set to "one".

Why object type should be passed as String and not directly be a constant ?

Hi there,

First of all, thank you for your work. I find it really cool and looks like a good solution to improve basic service objects.

My question here is: why can't we pass directly constants to check the type of inputs and should pass it as string instead ?

like this:

class GreetUser < Actor
  input :user, type: User

  def call
    puts "Hello #{user.name}!"
  end
end

I didn't find any reason in the code of the gem or I am ignorant about a deeper problem.

All I found about this is in lib/actor/type_checkable.rb:

types = Array(type_definition).map { |name| Object.const_get(name) }

which confirms to me we could pass directly constant instead of String since you "constantize" it anyway.

looking forward to your reply,
thanks !
Pagehey

Old bundler & deprecation warnings

❯ bundle install                                                                                                                                                100% █ ─╯
Bundler 2.4.0.dev is running, but your lockfile was generated with 2.1.4. Installing Bundler 2.1.4 and restarting using that version.
Fetching gem metadata from https://rubygems.org/.
Fetching bundler 2.1.4
Installing bundler 2.1.4
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
Fetching gem metadata from https://rubygems.org/.........
Using rake 13.0.6
Using ast 2.4.2
Using bundler 2.1.4
Using parallel 1.22.1
Using parser 3.1.2.0
Using rainbow 3.1.1
Fetching regexp_parser 2.3.1
Installing regexp_parser 2.3.1
Using rexml 3.2.5
Fetching rubocop-ast 1.17.0
Installing rubocop-ast 1.17.0
Using ruby-progressbar 1.11.0
Using unicode-display_width 2.1.0
Using rubocop 1.28.2
Fetching code-scanning-rubocop 0.5.0
Installing code-scanning-rubocop 0.5.0
Using coderay 1.1.3
Fetching diff-lcs 1.4.4
Installing diff-lcs 1.4.4
Fetching interactor 3.1.2
Installing interactor 3.1.2
Using method_source 1.0.0
Using pry 0.14.1
Fetching rspec-support 3.10.3
Installing rspec-support 3.10.3
Fetching rspec-core 3.10.1
Installing rspec-core 3.10.1
Fetching rspec-expectations 3.10.1
Installing rspec-expectations 3.10.1
Fetching rspec-mocks 3.10.2
Installing rspec-mocks 3.10.2
Fetching rspec 3.10.0
Installing rspec 3.10.0
Fetching rubocop-performance 1.12.0
Installing rubocop-performance 1.12.0
Fetching rubocop-rspec 2.6.0
Installing rubocop-rspec 2.6.0
Using service_actor 3.1.3 from source at `.`
Bundle complete! 9 Gemfile dependencies, 26 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

Similarly, when running bin/rake

❯ bin/rake                                                                                                                                      100% █  144 Mbps WiFi ─╯
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
/Users/pboling/.asdf/installs/ruby/3.1.2/bin/ruby -I/Users/pboling/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/rspec-core-3.10.1/lib:/Users/pboling/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/rspec-support-3.10.3/lib /Users/pboling/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/rspec-core-3.10.1/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.

Randomized with seed 5086
..................................................................................

Finished in 0.02617 seconds (files took 0.16946 seconds to load)
82 examples, 0 failures

Problems between checks within the same `input`

There is this code in the application:

class ApplicationService
  include ServiceActor::Base
end
class CurrencyService < ApplicationService
  input :currency,
        type: String,
        inclusion: {
          in: %w[USD EUR],
          message: ->(value:, **) { "Incorrect currency: #{value}" }
        }
end

When using it:

CurrencyService.call(currency: :GBP)

Gives an error:

ServiceActor::ArgumentError: Incorrect currency: GBP

That is, the check for the type of the input value is ignored.

[Question] allow_nil, type, and must supported at the same time?

First, great work with the gem!

I was wondering: are these attributes supported at the same time?

Why

Given the scenario that I have an interactor that looks like this:

class MyInteractor < Actor

    # weekdays is an Array of strings
    input :weekdays, type: Array, must: { 
      be_valid: ->(weekdays) { weekdays.all?(SomeValidation)  } 
    }, default: DefaultWeekdays, allow_nil: true
end

In this scenario, weekdays is optional. If this input is not sent, it defaults to something else. The issue I am having at the moment is that it can also be nil and that would also trigger the usage of the defaults.

I get a ServiceActor::ArgumentError, because in the must: validation weekdays is nil -- fixing it implies in using weekdays.nil? || ... to allow this optional scenario.

Is that expected or am I missing something here?

Again, thanks for the great work 🙏

Question / feature request: hooks around call?

Hey :)
I wonder if there is a way to execute some callback code before / after / around the call method of an actor.

My use case is the following:

  • I have an actor which plays actors in a sequence (is this a playbook? an orchestrator? something else?)
  • My actors inherit from ApplicationActor (which simply has include ServiceActor::Base)
  • I need to log the execution of each actor.

I tried to add to ApplicationActor the following:

  class << self
    def call(**args)
      ActiveSupport::Notifications.instrument("ApplicationActor", extra: self) do
        super
      end
    end
  end

but it gets executed only on the call of the "main" actor.

Any idea?

[Enhancement] Add declarative syntax for delegated context variables

Sometimes we instantiate objects manually in the actor, from a main input. To do that, most of the time we override the call method in the players, or create attr_readers from the main input.

What do you think about adding a declarative syntax to avoid being forced to override call in such use cases ?

An actor looking like this

module Projects
  class Confirm < Actor
    input :project, type: Project

    def call
      result.owner = project.owner
      result.rewards = project.rewards

      super
    end

    play ConfirmOwner, ConfirmRewards, ConfirmProject
  end
end

Could be simplified like this for instance :

module Projects
  class Confirm < Actor
    input :project, type: Project

    declare :owner, :rewards, on: :project

    play ConfirmOwner, ConfirmRewards, ConfirmProject
  end
end

[Discuss] Why we need the output: option as describe on README?

after i read output section in README, i have a question.

do we need this? i found it works as interactor, following is a example

class RetrieveStocks < Actor
  input :stock_name, default: nil

  def call
     stocks = Stock.where(:stocks[:name] => stock_name)

    if stocks.empty?
      fail!(message: "no stocks!")
    else
	  # we can assign use a setter like this, why we need output?
      result.stocks = stocks
    end
  end
end

params = r.params.slice('sort_column', 'sort_direction', 'stock_name', 'page', 'per')

result = RetrieveStocks.call(params)

if result.success?
   # get assigned value, it works
  @stocks = result.stocks
end

Thank you.

Ruby 2.3

Hello. Is it possible to add support for ruby 2.3?

I need to use fail_on but my project uses ruby 2.3

Uploading to ruby 2.4 is in the backlog. Apparently we will do it in a few months.

My alternative is to fork and upload a gem momentarily.

I can also fork and use github on the Gemfile but I don't like the idea.

Misleading `ServiceActor::ArgumentError` message under specific circumstances

Given the following actor

class TestActor < Actor
  input :value, type: Integer
  output :value_result, type: Integer, allow_nil: true
end

Evaluating TestActor.call(value: 1) leads to

ServiceActor::ArgumentError:
  The "value_result" input on "TestActor" is missing

(note the "input" while value_result is defined as output)

It does not happen if you define output without allow_nil: true

Actor's output names collision with `Object`'s instance methods

class OutputWithBlacklistedName < Actor
  input :value, type: Integer

  output :object_id, type: Integer

  play -> actor { actor.object_id = actor.value.succ }
end

OutputWithBlacklistedName.call(value: 1).object_id != 2

Before #127 object_id would be correctly set (I doubt that overriding object_id is a good idea though).

One possible solution is to inherit Result from BasicObject and explicitly disallow output names that collide with Result.instance_methods

Is `DefaultCheck` obsolete?

I struggle to figure out in which case DefaultCheck is used.

Seems like return if @result.key?(@input_key) (lib/service_actor/checks/default_check.rb:54) is always evaluated to true (both in specs and real-world scenarios).

  1. Defaultable (which runs first) sets the default is required
  2. NiCheck (runs second) raise if nil
  3. Finally DefaultCheck.check is fired and has nothing to do

Am I missing something?

Problem with `zeitwerk` after update to version 3.4.0

There seems to be something wrong with zeitwerk in release 3.4.0. The application started crashing instantly after updating the gem version from 3.3.0 to 3.4.0.

Failure/Error: require File.expand_path("../config/environment", __dir__)

NameError:
  uninitialized constant Actor::ServiceActor

    include ServiceActor::Base
            ^^^^^^^^^^^^
# /usr/local/bundle/gems/service_actor-3.4.0/lib/service_actor.rb:7:in `<class:Actor>'
# /usr/local/bundle/gems/service_actor-3.4.0/lib/service_actor.rb:6:in `<main>'

The application code, especially that associated with Actor, has not changed.

Now I'm trying to figure out what's wrong.

Can't set an hash as a default value for an input

Hi, it seems that setting an hash as a default value for an input results in a ServiceActor::ArgumentError.

Here's an example, slightly changed from the example in the readme:

class BuildGreeting < Actor
  input :name

  # Here's the change: the original example used a string as a default.
  # input :adjective, default: "wonderful"
  input :adjective, default: { text: "wonderful" }

  input :length_of_time, default: -> { %w[day week month].sample }
  input :article,
        default: lambda { |context|
                   puts context.adjective[:text]
                   puts context.adjective[:text].match?(/^[aeiou]/)
                   /^[aeiou]/.match?(context.adjective[:text]) ? "an" : "a"
                 }

  output :greeting

  def call
    self.greeting = "Have #{article} #{adjective[:text]} #{length_of_time}, #{name}!"
  end
end

actor = BuildGreeting.call(name: "Jim")
puts actor.greeting # => I'd expect "Have a wonderful week, Jim!", but I get a `ServiceActor::ArgumentError`

Wrapping the default value in a lambda does work, but I wouldn't expect to need such a workaround:

  # This works, but it's kinda ugly
  input :adjective, default: -> { return { text: "wonderful" } }

Thanks and keep up the great work!

Get "TypeError: exception class/object expected" error when update from 3.1.3 to 3.6

See following backtrace.

TypeError: exception class/object expected
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/result.rb:22:in `raise'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/result.rb:22:in `fail!'
    /home/common/Stocks/marketbet_crawler/app/services/retrieve_jin10_message.rb:42:in `call'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/core.rb:48:in `_call'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/checkable.rb:13:in `_call'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/defaultable.rb:53:in `_call'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/failable.rb:33:in `_call'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/core.rb:16:in `call'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/service_actor-3.6.0/lib/service_actor/core.rb:26:in `result'
    app/routes/jin10/messages.rb:4:in `block (2 levels) in <class:App>'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/hash_paths.rb:120:in `block in hash_paths'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/request.rb:536:in `always'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/hash_paths.rb:120:in `hash_paths'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/hash_routes.rb:296:in `hash_routes'
    /home/common/Stocks/marketbet_crawler/app/app.rb:147:in `block (3 levels) in <class:App>'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/_optimized_matching.rb:140:in `block in _is1'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/request.rb:536:in `always'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/_optimized_matching.rb:140:in `_is1'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/_optimized_matching.rb:25:in `is'
    /home/common/Stocks/marketbet_crawler/app/app.rb:146:in `block (2 levels) in <class:App>'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/request.rb:536:in `always'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/_optimized_matching.rb:124:in `_verb'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/request.rb:104:in `get'
    /home/common/Stocks/marketbet_crawler/app/app.rb:103:in `block in <class:App>'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda.rb:518:in `_roda_run_main_route'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/_before_hook.rb:27:in `_roda_run_main_route'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda.rb:496:in `block in _roda_handle_main_route'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda.rb:494:in `catch'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda.rb:494:in `_roda_handle_main_route'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda/plugins/error_handler.rb:88:in `_roda_handle_main_route'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/roda-3.64.0/lib/roda.rb:380:in `block in base_rack_app_callable'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/rack-test-2.0.2/lib/rack/test.rb:358:in `process_request'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/rack-test-2.0.2/lib/rack/test.rb:165:in `custom_request'
    /home/common/.rvm/gems/ruby-3.2.1@marketbet_crawler/gems/rack-test-2.0.2/lib/rack/test.rb:114:in `get'
    /home/common/.rvm/rubies/ruby-3.2.1/lib/ruby/3.2.0/forwardable.rb:240:in `get'
    /home/common/Stocks/marketbet_crawler/test/routes/jin10/messages_test.rb:6:in `block (2 levels) in <top (required)>'

The source code which raise this error is here

Thanks.

warning: method redefined; discarding old orig_name=

While doing:

bin/rake

The following warnings appear:

/Users/afuno/Projects/GitHub/afuno/actor/lib/service_actor/playable.rb:46: warning: method redefined; discarding old orig_name=
/Users/afuno/.rbenv/versions/2.7.4/lib/ruby/2.7.0/ostruct.rb:191: warning: previous definition of orig_name= was here
/Users/afuno/Projects/GitHub/afuno/actor/lib/service_actor/playable.rb:46: warning: method redefined; discarding old name=
/Users/afuno/.rbenv/versions/2.7.4/lib/ruby/2.7.0/ostruct.rb:191: warning: previous definition of name= was here

Feature request: use other input values in default

I'd love to be able to use other input values in the Proc form for default:

input :foo, type: [String], allow_nil: false
input :bar, type: [String], default: ->(inputs) { "Default Bar: #{inputs[:foo]}" }

You probably don't want the Proc to be able to modify inputs, so we should pass a defensive copy:

def default_for_normal_mode_with(result, key, default)
  if default.is_a?(Proc)
    args = []
    args.push(result.dup) if default.arity != 0
    default = default.call(*args)
  end

  result[key] = default
end

If you're interested in this, I'm happy to open a pull request.

[Proposal] Default outputs via `output_of` actor class method

In a project I'm working on, we've taken to using actors as a way to isolate and encapsulate complex bits of fetching logic. For example:

class GetVersionChanges < ApplicationActor
  input :version
  input :filters, default: {}
  output :output

  def call
    # complicated database calls
    # version_changes = ...
    this.output = version_changes
  end
end

GetVersionChanges.call(version:, filters:).output

Calling .call().output for these gets a bit tiring as use of the pattern grows throughout the app. What I'd like to do is something like this:

class GetVersionChanges < ApplicationActor
  input :version
  input :filters, default: {}

  def call
    # complicated database calls
    version_changes # the return value is picked up and returned by `output_of`
  end
end

GetVersionChanges.output_of(version:, filters:)

In fact, I've monkey-patched the gem with the following to get this behavior in our application:

module ServiceActor::Core
  module ClassMethods
    # Get the return value of calling an actor with arguments.
    #
    #   CreateUser.output_of(name: "Joe")
    def output_of(result = nil, **arguments)
      call(result, **arguments)[:_default_output]
    end
  end

  def _call
    result._default_output = call
  end
end

It's rudimentary - "polluting" the result object with a _default_output attribute - but it works. Let me know if 1) this feature is a good fit for the gem and 2) if this implementation is sufficient, or if something more sophisticated is desired. Thanks!

Consider removing `OpenStruct` usage

Ruby MRI 3.3 throws performance-related warnings:

gems/service_actor-3.7.0/lib/service_actor/result.rb:11: warning: OpenStruct use is discouraged for performance reasons

Can we refactor ServiceActor::Result so that it doesn't inherit from OpenStruct?

Make Actor result + pattern matching more pleasant

Hi, I'm using sunny/actor on my projects and I like it a lot, thanks for this awesome project.

I'm doing some experiments with pattern matching + Result but I noticed some issues, for example:

My expectation:

case CreateUser.result(attributes: user_params)
in {success: true, user:}
  redirect_to user_path(user), notice: "success"
in {error: :invalid_record, user:}
  render "new", locals: { user: user }
in {error: :other_error}
  # do something else
end

But it can't be achieve because:

  1. case CreateUser.result(attributes: user_params)
    To have acess to succes/failure key, I need to use .to_h on result

  2. in {success: true, user:}
    result.to_h doesn't return success key, and returns failure? key only if failure? is true

So I have to write something like:

result = CreateUser.result(attributes: user_params)

case result.to_h
in {failure?: true, error: :invalid_record, user:}
  render "new", locals: { user: user }
in {error: :other_error}
  # do something else
in {user:}
  redirect_to, user_path(user), notice: "success"
end

I'm not an expert on Pattern Matching, so I'm not sure if I'm on the right way. So I tried adding a #deconstruct_keys to ServiceActor::Result and it worked fine:

def deconstruct_keys(keys)
  to_h.merge(success: success?, failure: failure?)
end

What do you think? If you are interested on this, I can try opening a PR with a first take on this.

Positional arguments support?

It's not pleasant to type full keyword arguments all the time for actors with only one or two inputs. So I figured out a way to run actor with positional arguments:

class ApplicationActor < Actor
  class << self
    attr_reader :defined_run_args

    # Define positional args for Actor.run()
    # Should be called after all inputs defined
    #
    # class HiActor < ApplicationActor
    #   input :planet
    #   input :name
    #
    #   has_run_args :name, :planet
    #
    #   def call
    #     puts "Hi, #{name} from #{planet}"
    #   end
    # end
    #
    # HiActor.run!('Dan', 'Earth')
    #
    def has_run_args(*args)
      if args.size != inputs.keys.size
        raise ServiceActor::ArgumentError, 'Run args must match inputs'
      end

      const_set('RUN_ARGS', args.freeze)
      @defined_run_args = true
    end

    def run!(*args)
      raise ServiceActor::ArgumentError, 'Actor run args not defined!' unless defined_run_args

      args_h = self::RUN_ARGS.map.with_index { |key, idx| [key, args[idx]] }.to_h
      call(args_h)
    end
  end
end

Just a small run! method built on top of call.

Is positional arguments support on the roadmap of Actor?

Aliases for inputs and outputs

Hey @sunny !!!!

Thanks for the great work \o/

@AnneSottise and I were wondering what were you thinking about adding aliases to inputs and ouputs, such as:

    input :description, type: String, default: nil, as: :aliased_description

Wouldn't it be great ?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.