Coder Social home page Coder Social logo

light-service's Introduction

LightService

Gem Version CI Tests codecov Code Climate License Download Count

LightService is a powerful and flexible service skeleton framework with an emphasis on simplicity

Table of Contents

Why LightService?

What do you think of this code?

class TaxController < ApplicationController
  def update
    @order = Order.find(params[:id])
    tax_ranges = TaxRange.for_region(order.region)

    if tax_ranges.nil?
      render :action => :edit, :error => "The tax ranges were not found"
      return # Avoiding the double render error
    end

    tax_percentage = tax_ranges.for_total(@order.total)

    if tax_percentage.nil?
      render :action => :edit, :error => "The tax percentage  was not found"
      return # Avoiding the double render error
    end

    @order.tax = (@order.total * (tax_percentage/100)).round(2)

    if @order.total_with_tax > 200
      @order.provide_free_shipping!
    end

    redirect_to checkout_shipping_path(@order), :notice => "Tax was calculated successfully"
  end
end

This controller violates SRP all over. Also, imagine what it would take to test this beast. You could move the tax_percentage finders and calculations into the tax model, but then you'll make your model logic heavy.

This controller does 3 things in order:

  • Looks up the tax percentage based on order total
  • Calculates the order tax
  • Provides free shipping if the total with tax is greater than $200

The order of these tasks matters: you can't calculate the order tax without the percentage. Wouldn't it be nice to see this instead?

(
  LooksUpTaxPercentage,
  CalculatesOrderTax,
  ProvidesFreeShipping
)

This block of code should tell you the "story" of what's going on in this workflow. With the help of LightService you can write code this way. First you need an organizer object that sets up the actions in order and executes them one-by-one. Then you need to create the actions with one method (that will do only one thing).

This is how the organizer and actions interact with each other:

LightService

class CalculatesTax
  extend LightService::Organizer

  def self.call(order)
    with(:order => order).reduce(
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      )
  end
end

class LooksUpTaxPercentageAction
  extend LightService::Action
  expects :order
  promises :tax_percentage

  executed do |context|
    tax_ranges = TaxRange.for_region(context.order.region)
    context.tax_percentage = 0

    next context if object_is_nil?(tax_ranges, context, 'The tax ranges were not found')

    context.tax_percentage = tax_ranges.for_total(context.order.total)

    next context if object_is_nil?(context.tax_percentage, context, 'The tax percentage was not found')
  end

  def self.object_is_nil?(object, context, message)
    if object.nil?
      context.fail!(message)
      return true
    end

    false
  end
end

class CalculatesOrderTaxAction
  extend ::LightService::Action
  expects :order, :tax_percentage

  # I am using ctx as an abbreviation for context
  executed do |ctx|
    order = ctx.order
    order.tax = (order.total * (ctx.tax_percentage/100)).round(2)
  end

end

class ProvidesFreeShippingAction
  extend LightService::Action
  expects :order

  executed do |ctx|
    if ctx.order.total_with_tax > 200
      ctx.order.provide_free_shipping!
    end
  end
end

And with all that, your controller should be super simple:

class TaxController < ApplicationContoller
  def update
    @order = Order.find(params[:id])

    service_result = CalculatesTax.for_order(@order)

    if service_result.failure?
      render :action => :edit, :error => service_result.message
    else
      redirect_to checkout_shipping_path(@order), :notice => "Tax was calculated successfully"
    end

  end
end

I gave a talk at RailsConf 2013 on simple and elegant Rails code where I told the story of how LightService was extracted from the projects I had worked on.

Getting started

Requirements

This gem requires ruby 2.x. Use of generators requires Rails 5+ (tested on Rails 5.x & 6.x only. Will probably work on Rails versions as old as 3.2)

Installation

In your Gemfile:

gem 'light-service'

And then

bundle install

Or install it yourself as:

gem install light-service

Your first action

LightService's building blocks are actions that are normally composed within an organizer, but can be run independently. Let's make a simple greeter action. Each action can take an optional list of expected inputs and promised outputs. If these are specified and missing at action start and stop respectively, an exception will be thrown.

class GreetsPerson
  extend ::LightService::Action

  expects :name
  promises :greeting

  executed do |context|
    context.greeting = "Hey there, #{name}. You enjoying LightService so far?"
  end
end

When an action is run, you have access to its returned context, and the status of the action. You can invoke an action by calling .execute on its class with key: value arguments, and inspect its status and context like so:

outcome = GreetsPerson.execute(name: "Han")

if outcome.success?
  puts outcome.greeting # which was a promised context value
elsif outcome.failure?
  puts "Rats... I can't say hello to you"
end

You will notice that actions are set up to promote simplicity, i.e. they either succeed or fail, and they have very clear inputs and outputs. Ideally, they should do exactly one thing. This makes them as easy to test as unit tests.

Your first organizer

LightService provides a facility to compose actions using organizers. This is great when you have a business process to execute that has multiple steps. By composing actions that do exactly one thing, you can sequence simple actions together to perform complex multi-step business processes in a clear manner that is very easy to reason about.

There are advanced ways to sequence actions that can be found later in the README, but we'll keep this simple for now. First, let's add a second action that we can sequence to run after the GreetsPerson action from above:

class RandomlyAwardsPrize
  extend ::LightService::Action

  expects :name, :greeting
  promises :did_i_win

  executed do |context|
    prize_num  = "#{context.name}__#{context.greeting}".length
    prizes     = ["jelly beans", "ice cream", "pie"]
    did_i_win  = rand((1..prize_num)) % 7 == 0
    did_i_lose = rand((1..prize_num)) % 13 == 0

    if did_i_lose
      # When failing, send a message as an argument, readable from the return context
      context.fail!("you are exceptionally unlucky")
    else
      # You can specify 'optional' context items by treating context like a hash.
      # Useful for when you may or may not be returning extra data. Ideally, selecting
      # a prize should be a separate action that is only run if you win.
      context[:prize]   = "lifetime supply of #{prizes.sample}" if did_i_win
      context.did_i_win = did_i_win
    end
  end
end

And here's the organizer that ties the two together. You implement a call class method that takes some arguments and from there sends them to with in key: value format which forms the initial state of the context. From there, chain reduce to with and send it a list of action class names in sequence. The organizer will call each action, one after the other, and build up the context as it goes along.

class WelcomeAPotentiallyLuckyPerson
  extend LightService::Organizer

  def self.call(name)
    with(:name => name).reduce(GreetsPerson, RandomlyAwardsPrize)
  end
end

When an organizer is run, you have access to the context as it passed through all actions, and the overall status of the organized execution. You can invoke an organizer by calling .call on the class with the expected arguments, and inspect its status and context just like you would an action:

outcome = WelcomeAPotentiallyLuckyPerson.call("Han")

if outcome.success?
  puts outcome.greeting # which was a promised context value

  if outcome.did_i_win
    puts "And you've won a prize! Lucky you. Please see the front desk for your #{outcome.prize}."
  end
else # outcome.failure? is true, and we can pull the failure message out of the context for feedback to the user.
  puts "Rats... I can't say hello to you, because #{outcome.message}."
end

Because organizers generally run through complex business logic, and every action has the potential to cause a failure, testing an organizer is functionally equivalent to an integration test.

For further examples, please visit the project's Wiki and review the "Why LightService" section above.

Stopping the Series of Actions

When nothing unexpected happens during the organizer's call, the returned context will be successful. Here is how you can check for this:

class SomeController < ApplicationController
  def index
    result_context = SomeOrganizer.call(current_user.id)

    if result_context.success?
      redirect_to foo_path, :notice => "Everything went OK! Thanks!"
    else
      flash[:error] = result_context.message
      render :action => "new"
    end
  end
end

However, sometimes not everything will play out as you expect it. An external API call might not be available or some complex business logic will need to stop the processing of the Series of Actions. You have two options to stop the call chain:

  1. Failing the context
  2. Skipping the rest of the actions

Failing the Context

When something goes wrong in an action and you want to halt the chain, you need to call fail! on the context object. This will push the context in a failure state (context.failure? # will evalute to true). The context's fail! method can take an optional message argument, this message might help describing what went wrong. In case you need to return immediately from the point of failure, you have to do that by calling next context.

In case you want to fail the context and stop the execution of the executed block, use the fail_and_return!('something went wrong') method. This will immediately leave the block, you don't need to call next context to return from the block.

Here is an example:

class SubmitsOrderAction
  extend LightService::Action
  expects :order, :mailer

  executed do |context|
    unless context.order.submit_order_successful?
      context.fail_and_return!("Failed to submit the order")
    end

    # This won't be executed
    context.mailer.send_order_notification!
  end
end

fail-actions

In the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd had a failure, that pushed the context into a failure state and the 4th action was skipped.

Skipping the rest of the actions

You can skip the rest of the actions by calling context.skip_remaining!. This behaves very similarly to the above-mentioned fail! mechanism, except this will not push the context into a failure state. A good use case for this is executing the first couple of action and based on a check you might not need to execute the rest. Here is an example of how you do it:

class ChecksOrderStatusAction
  extend LightService::Action
  expects :order

  executed do |context|
    if context.order.send_notification?
      context.skip_remaining!("Everything is good, no need to execute the rest of the actions")
    end
  end
end

skip-actions

In the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd decided to skip the rest, the 4th action was not invoked. The context was successful.

Benchmarking Actions with Around Advice

Benchmarking your action is needed when you profile the series of actions. You could add benchmarking logic to each and every action, however, that would blur the business logic you have in your actions.

Take advantage of the organizer's around_each method, which wraps the action calls as its reducing them in order.

Check out this example:

class LogDuration
  def self.call(context)
    start_time = Time.now
    result = yield
    duration = Time.now - start_time
    LightService::Configuration.logger.info(
      :action   => context.current_action,
      :duration => duration
    )

    result
  end
end

class CalculatesTax
  extend LightService::Organizer

  def self.call(order)
    with(:order => order).around_each(LogDuration).reduce(
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      )
  end
end

Any object passed into around_each must respond to #call with two arguments: the action name and the context it will execute with. It is also passed a block, where LightService's action execution will be done in, so the result must be returned. While this is a little work, it also gives you before and after state access to the data for any auditing and/or checks you may need to accomplish.

Before and After Action Hooks

In case you need to inject code right before and after the actions are executed, you can use the before_actions and after_actions hooks. It accepts one or multiple lambdas that the Action implementation will invoke. This addition to LightService is a great way to decouple instrumentation from business logic.

Consider this code:

class SomeOrganizer
  extend LightService::Organizer

  def self.call(ctx)
    with(ctx).reduce(actions)
  end

  def self.actions
    [
      OneAction,
      TwoAction,
      ThreeAction
    ]
  end
end

class TwoAction
  extend LightService::Action
  expects :user, :logger

  executed do |ctx|
    # Logging information
    if ctx.user.role == 'admin'
       ctx.logger.info('admin is doing something')
    end

    ctx.user.do_something
  end
end

The logging logic makes TwoAction more complex, there is more code for logging than for business logic.

You have two options to decouple instrumentation from real logic with before_actions and after_actions hooks:

  1. Declare your hooks in the Organizer
  2. Attach hooks to the Organizer from the outside

This is how you can declaratively add before and after hooks to the Organizer:

class SomeOrganizer
  extend LightService::Organizer
  before_actions (lambda do |ctx|
                           if ctx.current_action == TwoAction
                             return unless ctx.user.role == 'admin'
                             ctx.logger.info('admin is doing something')
                           end
                         end)
  after_actions (lambda do |ctx|
                          if ctx.current_action == TwoAction
                            return unless ctx.user.role == 'admin'
                            ctx.logger.info('admin is DONE doing something')
                          end
                        end)

  def self.call(ctx)
    with(ctx).reduce(actions)
  end

  def self.actions
    [
      OneAction,
      TwoAction,
      ThreeAction
    ]
  end
end

class TwoAction
  extend LightService::Action
  expects :user

  executed do |ctx|
    ctx.user.do_something
  end
end

Note how the action has no logging logic after this change. Also, you can target before and after action logic for specific actions, as the ctx.current_action will have the class name of the currently processed action. In the example above, logging will occur only for TwoAction and not for OneAction or ThreeAction.

Here is how you can declaratively add before_hooks or after_hooks to your Organizer from the outside:

SomeOrganizer.before_actions =
  lambda do |ctx|
    if ctx.current_action == TwoAction
      return unless ctx.user.role == 'admin'
      ctx.logger.info('admin is doing something')
    end
  end

These ideas are originally from Aspect Oriented Programming, read more about them here.

Expects and Promises

The expects and promises macros are rules for the inputs/outputs of an action. expects describes what keys it needs to execute, and promises makes sure the keys are in the context after the action is reduced. If either of them are violated, a LightService::ExpectedKeysNotInContextError or LightService::PromisedKeysNotInContextError exception respectively will be thrown.

This is how it's used:

class FooAction
  extend LightService::Action
  expects :baz
  promises :bar

  executed do |context|
    context.bar = context.baz + 2
  end
end

The expects macro will pull the value with the expected key from the context, and makes it available to you through a reader.

The promises macro will not only check if the context has the promised keys, it also sets them for you in the context if you use the accessor with the same name, much the same way as the expects macro works.

The context object is essentially a smarter-than-normal Hash. Take a look at this spec to see expects and promises used with and without accessors.

Default values for optional Expected keys

When you have an expected key that has a sensible default which should be used everywhere and only overridden on an as-needed basis, you can specify a default value. An example use-case is a flag that allows a failure from a service under most circumstances to avoid failing an entire workflow because of a non-critical action.

LightService provides two mechanisms for specifying default values:

  1. A static value that is used as-is
  2. A callable that takes the current context as a param

Using the above use case, consider an action that sends a text message. In most cases, if there is a problem sending the text message, it might be OK for it to fail. We will expect an allow_failure key, but set it with a default, like so:

class SendSMS
  extend LightService::Action
  expects :message, :user
  expects :allow_failure, default: true

  executed do |context|
    sms_api = SMSService.new(key: ENV["SMS_API_KEY"])
    status  = sms_api.send(ctx.user.mobile_number, ctx.message)

    if !status.sent_ok?
      ctx.fail!(status.err_msg) unless ctx.allow_failure
    end
  end
end

Default values can also be processed dynamically by providing a callable. Any values already specified in the context are available to it via Hash key lookup syntax. e.g.

class SendSMS
  extend LightService::Action
  expects :message, :user
  expects :allow_failure, default: ->(ctx) { !ctx[:user].admin? } # Admins must always get SMS'

  executed do |context|
    sms_api = SMSService.new(key: ENV["SMS_API_KEY"])
    status  = sms_api.send(ctx.user.mobile_number, ctx.message)

    if !status.sent_ok?
      ctx.fail!(status.err_msg) unless ctx.allow_failure
    end
  end
end

Note that default values must be specified one at a time on their own line.

You can then call an action or organizer that uses an action with defaults without specifying the expected key that has a default.

Key Aliases

The aliases macro sets up pairs of keys and aliases in an organizer. Actions can access the context using the aliases.

This allows you to put together existing actions from different sources and have them work together without having to modify their code. Aliases will work with or without action expects.

Say for example you have actions AnAction and AnotherAction that you've used in previous projects. AnAction provides :my_key but AnotherAction needs to use that value but expects :key_alias. You can use them together in an organizer like so:

class AnOrganizer
  extend LightService::Organizer

  aliases :my_key => :key_alias

  def self.call(order)
    with(:order => order).reduce(
      AnAction,
      AnotherAction,
    )
  end
end

class AnAction
  extend LightService::Action
  promises :my_key

  executed do |context|
    context.my_key = "value"
  end
end

class AnotherAction
  extend LightService::Action
  expects :key_alias

  executed do |context|
    context.key_alias # => "value"
  end
end

Logging

Enable LightService's logging to better understand what goes on within the series of actions, what's in the context or when an action fails.

Logging in LightService is turned off by default. However, turning it on is simple. Add this line to your project's config file:

LightService::Configuration.logger = Logger.new(STDOUT)

You can turn off the logger by setting it to nil or /dev/null.

LightService::Configuration.logger = Logger.new('/dev/null')

Watch the console while you are executing the workflow through the organizer. You should see something like this:

I, [DATE]  INFO -- : [LightService] - calling organizer <TestDoubles::MakesTeaAndCappuccino>
I, [DATE]  INFO -- : [LightService] -     keys in context: :tea, :milk, :coffee
I, [DATE]  INFO -- : [LightService] - executing <TestDoubles::MakesTeaWithMilkAction>
I, [DATE]  INFO -- : [LightService] -   expects: :tea, :milk
I, [DATE]  INFO -- : [LightService] -   promises: :milk_tea
I, [DATE]  INFO -- : [LightService] -     keys in context: :tea, :milk, :coffee, :milk_tea
I, [DATE]  INFO -- : [LightService] - executing <TestDoubles::MakesLatteAction>
I, [DATE]  INFO -- : [LightService] -   expects: :coffee, :milk
I, [DATE]  INFO -- : [LightService] -   promises: :latte
I, [DATE]  INFO -- : [LightService] -     keys in context: :tea, :milk, :coffee, :milk_tea, :latte

The log provides a blueprint of the series of actions. You can see what organizer is invoked, what actions are called in what order, what do the expect and promise and most importantly what keys you have in the context after each action is executed.

The logger logs its messages with "INFO" level. The exception to this is the event when an action fails the context. That message is logged with "WARN" level:

I, [DATE]  INFO -- : [LightService] - calling organizer <TestDoubles::MakesCappuccinoAddsTwoAndFails>
I, [DATE]  INFO -- : [LightService] -     keys in context: :milk, :coffee
W, [DATE]  WARN -- : [LightService] - :-((( <TestDoubles::MakesLatteAction> has failed...
W, [DATE]  WARN -- : [LightService] - context message: Can't make a latte from a milk that's too hot!

The log message will show you what message was added to the context when the action pushed the context into a failure state.

The event of skipping the rest of the actions is also captured by its logs:

I, [DATE]  INFO -- : [LightService] - calling organizer <TestDoubles::MakesCappuccinoSkipsAddsTwo>
I, [DATE]  INFO -- : [LightService] -     keys in context: :milk, :coffee
I, [DATE]  INFO -- : [LightService] - ;-) <TestDoubles::MakesLatteAction> has decided to skip the rest of the actions
I, [DATE]  INFO -- : [LightService] - context message: Can't make a latte with a fatty milk like that!

You can specify the logger on the organizer level, so the organizer does not use the global logger.

class FooOrganizer
  extend LightService::Organizer
  log_with Logger.new("/my/special.log")
end

Error Codes

You can add some more structure to your error handling by taking advantage of error codes in the context. Normally, when something goes wrong in your actions, you fail the process by setting the context to failure:

class FooAction
  extend LightService::Action

  executed do |context|
    context.fail!("I don't like what happened here.")
  end
end

However, you might need to handle the errors coming from your action pipeline differently. Using an error code can help you check what type of expected error occurred in the organizer or in the actions.

class FooAction
  extend LightService::Action

  executed do |context|
    unless (service_call.success?)
      context.fail!("Service call failed", error_code: 1001)
    end

    # Do something else

    unless (entity.save)
      context.fail!("Saving the entity failed", error_code: 2001)
    end
  end
end

Action Rollback

Sometimes your action has to undo what it did when an error occurs. Think about a chain of actions where you need to persist records in your data store in one action and you have to call an external service in the next. What happens if there is an error when you call the external service? You want to remove the records you previously saved. You can do it now with the rolled_back macro.

class SaveEntities
  extend LightService::Action
  expects :user

  executed do |context|
    context.user.save!
  end

  rolled_back do |context|
    context.user.destroy
  end
end

You need to call the fail_with_rollback! method to initiate a rollback for actions starting with the action where the failure was triggered.

class CallExternalApi
  extend LightService::Action

  executed do |context|
    api_call_result = SomeAPI.save_user(context.user)

    context.fail_with_rollback!("Error when calling external API") if api_call_result.failure?
  end
end

Using the rolled_back macro is optional for the actions in the chain. You shouldn't care about undoing non-persisted changes.

The actions are rolled back in reversed order from the point of failure starting with the action that triggered it.

See this acceptance test to learn more about this functionality.

You may find yourself directly using an action that can roll back by calling .execute instead of using it from within an Organizer. If this action fails and attempts a rollback, a FailWithRollbackError exception will be raised. This is so that the organizer can rollback the actions one by one. If you don't want to wrap your call to the action with a begin, rescue FailWithRollbackError block, you can introspect the context like so, and keep your usage of the action clean:

class FooAction
  extend LightService::Action

  executed do |context|
    # context.organized_by will be nil if run from an action,
    # or will be the class name if run from an organizer
    if context.organized_by.nil?
      context.fail!
    else
      context.fail_with_rollback!
    end
  end
end

Localizing Messages

Built-in localization adapter

The built-in adapter simply uses a manually created dictionary to search for translations.

# lib/light_service_translations.rb
LightService::LocalizationMap.instance[:en] = {
  :foo_action => {
    :light_service => {
      :failures => {
        :exceeded_api_limit => "API limit for service Foo reached. Please try again later."
      },
      :successes => {
        :yay => "Yaaay!"
      }
    }
  }
}
class FooAction
  extend LightService::Action

  executed do |context|
    unless service_call.success?
      context.fail!(:exceeded_api_limit)

      # The failure message used here equates to:
      # LightService::LocalizationMap.instance[:en][:foo_action][:light_service][:failures][:exceeded_api_limit]
    end
  end
end

Nested classes will work too: App::FooAction, for example, would be translated to app/foo_action hash key.

:en is the default locale, but you can switch it whenever you want with

LightService::Configuration.locale = :it

If you have I18n loaded in your project the default adapter will automatically be updated to use it. But would you want to opt for the built-in localization adapter you can force it with

LightService::Configuration.localization_adapter = LightService::LocalizationAdapter

I18n localization adapter

If I18n is loaded into your project, LightService will automatically provide a mechanism for easily translating your error or success messages via I18n.

class FooAction
  extend LightService::Action

  executed do |context|
    unless service_call.success?
      context.fail!(:exceeded_api_limit)

      # The failure message used here equates to:
      # I18n.t(:exceeded_api_limit, scope: "foo_action.light_service.failures")
    end
  end
end

This also works with nested classes via the ActiveSupport #underscore method, just as ActiveRecord performs localization lookups on models placed inside a module.

module PaymentGateway
  class CaptureFunds
    extend LightService::Action

    executed do |context|
      if api_service.failed?
        context.fail!(:funds_not_available)
      end

      # this failure message equates to:
      # I18n.t(:funds_not_available, scope: "payment_gateway/capture_funds.light_service.failures")
    end
  end
end

If you need to provide custom variables for interpolation during localization, pass that along in a hash.

module PaymentGateway
  class CaptureFunds
    extend LightService::Action

    executed do |context|
      if api_service.failed?
        context.fail!(:funds_not_available, last_four: "1234")
      end

      # this failure message equates to:
      # I18n.t(:funds_not_available, last_four: "1234", scope: "payment_gateway/capture_funds.light_service.failures")

      # the translation string itself being:
      # => "Unable to process your payment for account ending in %{last_four}"
    end
  end
end

Custom localization adapter

You can also provide your own custom localization adapter if your application's logic is more complex than what is shown here.

To provide your own custom adapter, use the configuration setting and subclass the default adapter LightService provides.

LightService::Configuration.localization_adapter = MyLocalizer.new

# lib/my_localizer.rb
class MyLocalizer < LightService::I18n::LocalizationAdapter

  # I just want to change the default lookup path
  # => "light_service.failures.payment_gateway/capture_funds"
  def i18n_scope_from_class(action_class, type)
    "light_service.#{type.pluralize}.#{action_class.name.underscore}"
  end
end

To get the value of a fail! or succeed! message, simply call #message on the returned context.

Orchestrating Logic in Organizers

The Organizer - Action combination works really well for simple use cases. However, as business logic gets more complex, or when LightService is used in an ETL workflow, the code that routes the different organizers becomes very complex and imperative.

In the past, this was solved using Orchestrators. As of Version 0.9.0 Orchestrators have been deprecated. All their functionality is now usable directly within Organizers. Read on to understand how to orchestrate workflows from within a single Organizer.

Let's look at a piece of code that does basic data transformations:

class ExtractsTransformsLoadsData
  def self.run(connection)
    context = RetrievesConnectionInfo.call(connection)
    context = PullsDataFromRemoteApi.call(context)

    retrieved_items = context.retrieved_items
    if retrieved_items.empty?
      NotifiesEngineeringTeamAction.execute(context)
    end

    retrieved_items.each do |item|
      context[:item] = item
      TransformsData.call(context)
    end

    context = LoadsData.call(context)

    SendsNotifications.call(context)
  end
end

The LightService::Context is initialized with the first action, that context is passed around among organizers and actions. This code is still simpler than many out there, but it feels very imperative: it has conditionals, iterators in it. Let's see how we could make it a bit more simpler with a declarative style:

class ExtractsTransformsLoadsData
  extend LightService::Organizer

  def self.call(connection)
    with(:connection => connection).reduce(actions)
  end

  def self.actions
    [
      RetrievesConnectionInfo,
      PullsDataFromRemoteApi,
      reduce_if(->(ctx) { ctx.retrieved_items.empty? }, [
        NotifiesEngineeringTeamAction
      ]),
      iterate(:retrieved_items, [
        TransformsData
      ]),
      LoadsData,
      SendsNotifications
    ]
  end
end

This code is much easier to reason about, it's less noisy and it captures the goal of LightService well: simple, declarative code that's easy to understand.

The 9 different orchestrator constructs an organizer can have:

  1. reduce_until
  2. reduce_if
  3. reduce_if_else
  4. reduce_case
  5. iterate
  6. execute
  7. with_callback
  8. add_to_context
  9. add_aliases

reduce_until behaves like a while loop in imperative languages, it iterates until the provided predicate in the lambda evaluates to true. Take a look at this acceptance test to see how it's used.

reduce_if will reduce the included organizers and/or actions if the predicate in the lambda evaluates to true. This acceptance test describes this functionality.

reduce_if_else takes three arguments, a condition lambda, a first set of "if true" steps, and a second set of "if false" steps. If the lambda evaluates to true, the "if true" steps are executed, otherwise the "else steps" are executed. This acceptance test describes this functionality.

reduce_case behaves like a Ruby case statement. The first parameter value is the key of the value within the context that will be worked with. The second parameter when is a hash where the keys are conditional values and the values are steps to take if the condition matches. The final parameter else is a set of steps to take if no conditions within the when parameter are met. This acceptance test describes this functionality.

iterate gives your iteration logic, the symbol you define there has to be in the context as a key. For example, to iterate over items you will use iterate(:items) in your steps, the context needs to have items as a key, otherwise it will fail. The organizer will singularize the collection name and will put the actual item into the context under that name. Remaining with the example above, each element will be accessible by the name item for the actions in the iterate steps. This acceptance test should provide you with an example.

To take advantage of another organizer or action, you might need to tweak the context a bit. Let's say you have a hash, and you need to iterate over its values in a series of action. To alter the context and have the values assigned into a variable, you need to create a new action with 1 line of code in it. That seems a lot of ceremony for a simple change. You can do that in a execute method like this execute(->(ctx) { ctx[:some_values] = ctx.some_hash.values }). This test describes how you can use it.

Use with_callback when you want to execute actions with a deferred and controlled callback. It works similar to a Sax parser, I've used it for processing large files. The advantage of it is not having to keep large amount of data in memory. See this acceptance test as a working example.

add_to_context can add key-value pairs on the fly to the context. This functionality is useful when you need a value injected into the context under a specific key right before the subsequent actions are executed. Keys are also made available as accessors on the context object and can be used just like methods exposed via expects and promises. This test describes its functionality.

Your action needs a certain key in the context but it's under a different one? Use the function add_aliases to alias an existing key in the context under the desired key. Take a look at this test to see an example.

ContextFactory for Faster Action Testing

As the complexity of your workflow increases, you will find yourself spending more and more time creating a context (LightService::Context it is) for your action tests. Some of this code can be reused by clever factories, but still, you are using a context that is artificial, and can be different from what the previous actions produced. This is especially true, when you use LightService in ETLs, where you start out with initial data and your actions are mutating its state.

Here is an example:

class SomeOrganizer
  extend LightService::Organizer

  def self.call(ctx)
    with(ctx).reduce(actions)
  end

  def self.actions
    [
       ETL::ParsesPayloadAction,
       ETL::BuildsEnititiesAction,
       ETL::SetsUpMappingsAction,
       ETL::SavesEntitiesAction,
       ETL::SendsNotificationAction
    ]
  end
end

You should test your workflow from the outside, invoking the organizerโ€™s call method and verify that the data was properly created or updated in your data store. However, sometimes you need to zoom into one action, and setting up the context to test it is tedious work. This is where ContextFactory can be helpful.

In order to test the third action ETL::SetsUpMappingAction, you have to have several entities in the context. Depending on the logic you need to write code for, this could be a lot of work. However, by using the ContextFactory in your spec, you could easily have a prepared context thatโ€™s ready for testing:

require 'spec_helper'
require 'light-service/testing'

RSpec.describe ETL::SetsUpMappingsAction do
  let(:context) do
    LightService::Testing::ContextFactory
      .make_from(SomeOrganizer)
      .for(described_class)
      .with(:payload => File.read(โ€˜spec/data/payload.jsonโ€™)
  end

  it โ€˜works like it shouldโ€™ do
    result = described_class.execute(context)
    expect(result).to be_success
  end
end

This context then can be passed to the action under test, freeing you up from the 20 lines of factory or fixture calls to create a context for your specs.

In case your organizer has more logic in its call method, you could create your own test organizer in your specs like you can see it in this acceptance test. This is reusable in all your action tests.

Rails support

LightService includes Rails generators for creating both Organizers and Actions along with corresponding tests. Currently only RSpec is supported (PR's for supporting MiniTest are welcome)

Note: Generators are namespaced to light_service not light-service due to Rake name constraints.

Organizer generation

rails generate light_service:organizer My::SuperFancy::Organizer
# -- or
rails generate light_service:organizer my/super_fancy/organizer

Options for this generator are:

  • --dir=<SOME_DIR>. <SOME_DIR> defaults to organizers. Will write organizers to /app/organizers, and specs to /spec/organizers
  • --no-tests. Default is --tests. Will generate a test file matching the namespace you've supplied.

Action generation

rails generate light_service:action My::SuperFancy::Action
# -- or
rails generate light_service:action my/super_fancy/action

Options for this generator are:

  • --dir=<SOME_DIR>. <SOME_DIR> defaults to actions. Will write actions to /app/actions, and specs to /spec/actions
  • --no-tests. Defaults is --tests. Will generate a test file matching the namespace you've supplied.
  • --no-roll-back. Default is --roll-back. Will generate a rolled_back block for you to implement with roll back functionality.

Advanced action generation

You are able to optionally specify expects and/or promises keys during generation

rails generate light_service:action CrankWidget expects:one_fish,two_fish promises:red_fish,blue_fish

When specifying expects, convenience variables will be initialized in the executed block so that you don't have to call them through the context. A stub context will be created in the test file using these keys too.

When specifying promises, specs will be created testing for their existence after executing the action.

Other implementations

Language Repo Author
Python pyservice @adomokos
PHP light-service @douglasgreyling
JavaScript light-service.js @douglasgreyling

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

Huge thanks to the contributors!

Release Notes

Follow the release notes in this document.

License

LightService is released under the MIT License.

light-service's People

Contributors

adomokos avatar alitsiya avatar bschmeck avatar bwvoss avatar gee-forr avatar house9 avatar inoda avatar jaredsmithse avatar jeansebtr avatar jenpayne avatar jeremy-hanna avatar jpmoral avatar kphan32 avatar macfanatic avatar markfchavez avatar mattr- avatar miltzi avatar ngpestelos avatar ni3t avatar olleolleolle avatar padi avatar petergoldstein avatar ramontayag avatar robwilliams avatar rvirani1 avatar smartinez87 avatar sphynx79 avatar thegene avatar toofishes avatar wafflewitch 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  avatar  avatar  avatar  avatar

light-service's Issues

Removing Error Codes

I am thinking about (deprecating first) and then removing error codes. I don't think it's widely used, it was added for a specific use case in the past.

Would anybody be against that?

Action.skip_if_no

We've recently had a few cases where this happens

class CoffeeMaker
  include LightService::Organizer

  def self.execute
    reduce(GrindsCoffee,SkimsMilk,AddsMilk,AddsSugar)
  end
end

Of course, if you don't SkimsMilk, you'll never AddsMilk. Right now, we're doing it like this:

class AddsMilk
  include LightService::Action
  expects :cup, :milk

  executed do |ctx|
    if ctx.milk # set to nil in `SkimsMilk` so that `AddsMilk` doesn't raise errors
      ctx.cup.add ctx.milk
    end
  end
end

It would be nice if we could do this:

class AddsMilk
  include LightService::Action
  expects :cup
  skip_if_no :milk # now we don't need to set `ctx.milk = nil` in `SkimsMilk`

  executed do |ctx|
      ctx.cup.add ctx.milk
  end
end

skip_all! doesn't fit our use case because we should still have some โ˜• even without milk.

Failing the context when not all the promised keys are in it

I had to do this recently:

class SomeAction
  includes LightService::Action
  expects :one_thing
  promises :the_other_thing

  executed |context| do
    unless context.one_thing.ok?
      context.fail!("I don't like what happened here!")
     end

    context.the_other_thing = "Everything's good"
  end
end

However, I received a PromisedKeysNotInContextError when I had to fail the context as it was early in the process and the_other_thing was not added to it yet.

I should be able to push the context into a failure state regardless of the promised keys being in the context or not.

Orchestrator not raising failing context

Hi i just want to ask why orchestrator does not raise failing context unlike organizers? is this intended or is there another way to identify whenever an organizer/action fails when executing steps in orhcestrator?

Remove Key Aliases

At one point, we needed key aliasing for our actions to avoid 1 line Actions. You can find more about it here.

Now that we have Orchestrator functionality in Organizers, I think this feature is obsolete. We could accomplish the same thing with an execute actions.

So instead of doing this:

class SomeAction
   aliases :my_key => :key_alias
end

We could do this:

class SomeOrganizer
  extend LightService::Organizer

  def self.call(id)
    with(:id => id).reduce(actions)
  end

  def self.actions
    [SomeAction1,
     SomeAction2,
     execute(->(ctx) { ctx[:key_alias] = ctx[:my_key] }),
     AnotherAction]
  end
end

See how the execute block now is responsible for aliasing.

Let me know if you're NOT OK with deprecating (and eventually removing in 1.0) key aliasing.

Logger in Orchestrator

when i use LightService::Organizer i activate the logger:
LightService::Configuration.logger = Logger.new(STDOUT)
and i see the log work fine

module AppMeter
  class Meter
     extend LightService::Organizer

    def self.run()
      with({}).reduce(
        [
          InitAutoit,
          InitBrowser,
          MinimizeDos,
          OpenSite,
          ClickConsenti
        ]
      )
    end
end
end

but in when i use Orchestrator i don't see nothing

module AppMeter
  class Meter
     extend LightService::Orchestrator

    def self.run()
      with({}).reduce(
        [
          InitAutoit,
          InitBrowser,
          MinimizeDos,
          OpenSite,
          ClickConsenti,
          iterate(:links, [DownloadFiles])
        ]
      )
    end
end
end

my program work fine but i don't see the log.

How to test LightService::Organizers

example:

class CalculatesTax
  include LightService::Organizer

  def self.for_order(order)
    with(order: order).reduce \
      [
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      ]
  end
end

Do you test for the return value of LightService::Organizer, or do you test if the LS::Actions are called in the order actions receive .execute?

The Need for Super-Organizers

As our business requirements and code complexity grows, we see the need of weaving together Organizers just like we do with Actions. We have conventions: an Action can not call other Action(s), an Organizer can not invoke other Organizer(s). This is how we try to keep our code simple and easy to follow.

We have been calling multiple Organizers from controller actions which is a nice way of pushing out logic from Rails, but it seems it's not enough. We could build new organizers that reuse all the actions from the other organizers, but I am not sure that's the best solution, as the Organizer is responsible for one specific logic. For example, one Organizer handles Payment, the other communicates to an external API if the Payment was successful.

We've been considering "Orchestrators" or "Super Organizers", that would treat Organizers as higher level Actions. With this change the Organizers have a uniform interface, they might express expects and promises as well.

Do you have any thoughts on this?

ContextFactory fully executes an Organizer when used with callback action

Summary:

Trying to use a ContextFactory for testing an Organizer with a with_callback (and possibly other methods) will lead to full execution of the Organizer to build the returned context.

Failure recreation:

# create a new organizer Callback and Action
module TestDoubles
  class CallbackOrganizer
    extend LightService::Organizer

    def self.call(number)
      with(:number => number).reduce(actions)
    end

    def self.actions
      [
        AddsOneAction,
        with_callback(CallbackAction, [
          AddsTwoAction,
          AddsThreeAction
        ])
      ]
    end
  end

  class CallbackAction
    extend LightService::Action
    expects :number, :callback

    executed do |context|
      context.number += 10
      context.number =
        context.callback.call(context).fetch(:number)
    end
  end
end

RSpec.describe TestDoubles::AddsTwoAction do
  it "does not execute a callback entirely" do
    context = LightService::Testing::ContextFactory
                      .make_from(TestDoubles::CallbackOrganizer)
                      .for(described_class)
                      .with(:number => 0)

    # add 1, add 10, then stop before executing first add 2
    expect(context.number).to eq(11)
  end
end

# => Failures:

  1) TestDoubles::AddsTwoAction does not execute a callback entirely from a ContextFactory
     Failure/Error: expect(context.number).to eq(11)

       expected: 11
            got: 16

Technicals:

What is going on is the ContextFactory enumerates through the duplicated organizer.actions, however when used with a with_callback(FooAction, [ SomeStepAction ]) the callback action and subsequent steps get flattened into a lazily executed Proc here.

Therefore a ContextFactoryOrganizer.organizer .actions will never have the action constant in the array to match on.

Proposed Solution:
The ContextFactoryOrganizer is going to have to get smarter and build out an Action execution path tree for these steps and wrap them in a new organizer that halts at the expected Action

Promises and Expectations

@adomokos, I saw an internal tool used at Github that is a lot like LightService. One nice they had was this notion of Promises and Expectations. Used the following way:

class FrothMilk
  include LightService::Action
  expects :milk, :cup
  promises :frothed_milk_in_cup

  executed {...}
end

You can immediately tell what this action is expects from the context, and what this action promises to set in the context. An organizer would find this useful because it can see the chain of actions and check if they're compatible by looking at the expectations and promises.

What do you think? Would you like this included in LightService?

Some hash methods not working in LightService::Context

Based on issue #1, context should work like a hash with additional features. We encountered a problem in using some of LS::Context's methods such as #delete. The solution can be any of the ff.:

  1. we enlist all methods that you want to expose, or
  2. delegate everything to the instance variable @context if method is missing.

Let me know if this was intended by design.

Use `call` for method name in the organizer

Right now, LS does not enforce a method name for organizers. You need to use the executed block in action, which generates the execute method, but there is no guideline for organizers. In fact, I've done this in the past:

FindsUser.by_id(id)

I am proposing a rule, that would warn the user to name the organizer's entry method like this:

FindsUser.by_id(id)
# Warning: The organizer should have a class method `call`.

And this warning wouldn't be triggered when the organizer has a call method.

FindsUser.call(id)

We began our work on "Orchestrators" ( see #65 for more details ), and we need to have a class method with a predetermined name an orchestrator can call in the call chain.

Concurrency Support

I just want to start a high level conversation about how LightService could support concurrency, agnostic of the underlying Ruby interpreter -- eg. how can we run multiple actions, organizers, or orchestrators "at the same time", allowing the interpreter to schedule the work appropriately.

I feel like this would be an important next step in advancing this project, either in practice or in conceptualization.

`call` interface

@adomokos, what do you think about a call interface as well?

MyOrganizer.call(1, 2)
MyOrganizer.(1,2) # apparently, you can do this!

Transition from include -> extend

Right now you can mix-in an organizer or action specific logic by "including" the module, like this:

class SomeOrganizer
  include LightService::Organizer

  def self.for(user)
  end
end

This is silly, the logic mixed into the class will be at class level, and the default for that in Ruby is "extend". We should phase out "include" with a warning and just use "extend" going forward.

Thoughts?

Organizer#with has wrong specs

      def with(data = {})
        @context = data.kind_of?(::LightService::Context) ?
                     data :
                     LightService::Context.make(data)
        self
      end

All specs still pass even when it's changed to this:

      def with(data = {})
        @context = LightService::Context.make(data)
        self
      end

a quick check on spec/organizer_spec.rb says #with should not create a context given that the parameters is a context.

PR coming soon...

[Orchestrators] iterators and organizers

Hey,

Really happy about orchestrators so far !

I just noticed that the context passed to an organizer from an iterator didn't include the singularized key (:link in my case), but did for an action...

I read the acceptance spec but it only test it with an action too. I will take a look to write another spec.

Have a good day

Passing a hash of args on execute

When we test an action we first create a context like this

ctx = LightService:Context.make({:arg1 => 'arg1', :arg2 => 'arg2'})
Action.execute(ctx)

Maybe it will be better if we can just pass in the hash of args when we call execute like this

Action.execute({:arg => 'arg1', :arg2 => 'arg2'})

then the hash will be converted into a LightService::Context in the background

Thoughts?

Allowing aliases for :expects attributes on a LightService::Context object

Let's say we have a generic action that expects a user to be supplied in the context.

Later, we create a new Organizer and for the context of that Organizer and its associated Actions, instead of user we care about the idea of an owner.

However, I want to use the generic action that expects the context to have an user. Currently, this causes me to have to create a small, specific action to convert the idea of an owner to an user:

class ExampleAction
  expects :owner
  promises :user

  executed do |context|
    context.user = context.owner
  end
end

This can quickly get out of hand though. Alternatively, at the time I create the owner object, I could associate it with an user at the same time:

class ExampleAction2
  expects :params
  promises :owner, :user

  executed do |context|
    context.user = context.owner = User.new(context.params)
  end
end

The trouble I have here is that our use of an action in this Organizer requiring an user leaks into an action that is really focused on the idea of an owner, which means that a more generic action meant to be reused is now leaking into other actions in order to be reused.

I'd like to propose we add the idea of expects aliases to LightService:

class ExampleAction3
  expects :user
  alias_context :user, :owner

  executed do |context|
    context.user.behavior!

    # which is equivalent to saying:
    # context.owner.behavior!
  end
end

What I hope ExampleAction3 demonstrates is how LightService can alias an object in the context
with another object in the context, and correctly send messages to the aliased object in the context when the aliased object exists. If there is no aliased object in the context, then expects and the context validation works as normal.

LS should support warnings

I came across this situation today: a series of action completed, there was no failure but an action completed in a state that I needed to communicate back to the caller.

Right now, LS supports successes and failures. When you fail an action, it immediately stops the processing for the remaining actions. I think we should be able to push the context into a warning state that would not stop the processing for the rest of the actions but could bubble up its message as a warning to the caller.

`delete` method

Referencing #9

The use case: In a LS::Action, I want to delete some unused values inside context before handing it off to the next action in sequence. context can get very messy

e.g.
If there are 10 actions and each adds a key-value pair in context, by the 10th action, there's at least pairs, not all of which are used at all.

Parallel actions

Another useful feature I saw in Github's internal tool (see #30), was parallelizing of actions. What do you think about:

class MakeCapuccino
  include LightService::Organizer

  def self.run
    with({}).reduce(
      parallel(
        FrothMilk,
        MakeEspresso,
      ),
      CombineMilkAndEspresso,
    )
  end
end

Accessing context values directly in executed block

In the README there is an example action:

class CalculatesOrderTaxAction
  extend ::LightService::Action
  expects :order, :tax_percentage

  executed do |context|
    order.tax = (order.total * (tax_percentage/100)).round(2)
  end
end

This doesn't actually work, order has to be accessed through context, it's not available directly in the executed block.

I've actually hit this issue myself and it would be nice to be able to reference the items in the context that have been defined with expects without the prefix..

My workaround at the moment is to assign the variables myself:

class CalculatesOrderTaxAction
  extend ::LightService::Action
  expects :order, :tax_percentage

  executed do |context|
    order = context.order
    tax_percentage = context.tax_percentage

    order.tax = (order.total * (tax_percentage/100)).round(2)
  end
end

Do you think this kind of sugar is worth adding?

Skipping the remaining actions in a `reduce_until` leads to an infinite loop

Summary

Including a ctx.skip_remaining! in a reduce_until step causes an infinite loop of execution.

Recreation

class OrchestratorTestSkipState
  extend LightService::Orchestrator

  def self.run_inside_skip
    with(:number => 1).reduce([
                                reduce_until(->(ctx) { ctx[:number] == 3 }, [
                                             TestDoubles::SkipAllAction,
                                             TestDoubles::AddOneAction ])
                              ])
  end
end

it 'skips all the rest of the actions' do
  result = OrchestratorTestSkipState.run_inside_skip

  expect(result).to be_success
  expect(result[:number]).to eq(1)
end

Expressing Action Prerequisite or "after"

Usually, the order of actions are dependent on previous actions, but I caught myself rearranging orders where it makes sense. However, sometimes the action has to be invoked after another one.

Would it make sense to introduce an after macro that would express this dependency?

So the action would look like this:

class SomeAction
  extend LightService::Action

  after SomeOtherAction
  expects :user
  promises :client

  executed do |ctx|
    ...
  end
end

And when somebody places this action into a workflow where the SomeOtherAction was not invoked prior to SomeAction#execute call, it will throw an error.

I am hesitant to add this logic as it could be abused, but maybe, somebody else bumped into this before.

Let me know what you think!

Handling errors

We've been using LightService in the context of a resque job, and I want to know your take on handling errors.

For quite some time, we've been doing it in this form (consider this a use case):

class SomeOrganizer
  @queue = :high

  include LightService::Organizer

  def self.perform
    begin
      with(context).reduce [SomeAction]
    rescue SomeError => e
      # do something unique
      raise e, "modified message different from e.message"
    end
  end
end

class SomeAction
  include LightService::Action
  executed do |context|
    begin
      # do some action
    rescue AnotherError => e
      # do something unique
      raise e, "unique message"
    end
  end
end

It's quite tedious to do this for every action/organizer, so maybe we can do this in the topmost organizer:

class SomeOrganizer
  include LightService::Organizer

  def self.execute
    begin
      with(context).reduce [SomeAction]
    rescue SomeError => e
      # do something unique
      raise e, "modified message different from e.message"
    rescue AnotherError => e
      # do something unique
      raise e, "unique message"     
    end
  end
end

... which begins to clutter when we rescue a lot of errors.

Here's one option we can implement to make it cleaner and less nested:

In Rails, there's rescue_from

http://apidock.com/rails/ActionController/Rescue/ClassMethods/rescue_from

If we copy that, then maybe something like this could work:

class SomeOrganizer
  include LightService::Organizer
  rescue_from SomeError, :with => :do_something
  rescue_from AnotherError, :with => :do_something_else

  def self.execute
    with(context).reduce [SomeAction]
  end

  def self.do_something(e)
    # do something unique
    raise e, "modified message different from e.message"
  end

  def self.do_something_else(e)
    # do something unique
    raise e, "unique message"     
  end

end

This is different from set_failure! since we may not have reign over the services we use, e.g. SomeAction is a Service Object using a 3rd party client-gem raising some custom error class. We can force ourselves to use set_failure!, but we'd end up using rescue to catch the errors anyway.

Removing Rollback Feature

I added the rollback feature to LS a while ago. I have not used it since. Is this something people are using?

I am considering removing it in the next major version.

`before_action` and `after_action` events

We are thinking about using LS for ETL processing. However, instrumentation is key, and measuring how long each action takes is important to us.

I am thinking about exposing 2 events from the LS pipeline.

  1. Before Action
  2. After Action

That way we could hook into the events and measure the execution. I would also think that this kind of events would be beneficial for others to extend LS.

This is how it would look like:

class SomeOrganizer
  extend LightService::Organizer

  def self.call(user)
    with(:user).reduce(actions)
  end

  def self.actions
    [
      OneAction,
      TwoAction,
      ThreeAction,
      FourAction
    ]
  end

  def register_event_handlers
    for(OneAction).before_action do |ctx|
      puts "OneAction before action event"
    end

    for(OneAction).after_action do |ctx|
      puts "OneAction after action event"
    end
  end
end

This way extending LS wiht custom event handling mechanism is feasible. I would love to use this functionaity as an "around advice"-like benchmarking in my actions.

Has anybody tried doing something like this before? If your answer is yes, what did you do? Maybe we could use that in LS.

If not, what do you think about this approach?

Thanks!

"ctx.fail!" should skip the rest of the code in the current action

In the past week, I've observed two developers being confused with the use of ctx.fail!.

Consider this code:

class FindsUserAction
  extend LightService::Action

  expects :params
  promises :user

  executed do |ctx|
    ctx.fail!("user_id wasn't in the params") unless params.keys.include?('user_id')
    
    ctx.user = User.find(params[:user_id])
  end
end

They were surprised to see that the code after the ctx.fail! in this action got executed.
Yes, as you need to explicitly return from the executed block by calling next:

class FindsUserAction
  extend LightService::Action

  expects :params
  promises :user

  executed do |ctx|
    if params.keys.include?('user_id') == false
      ctx.fail!("user_id wasn't in the params")
      next ctx
    end 

    ctx.user = User.find(params[:user_id])
  end
end

After seeing the engineers got tripped on this one, I am of the opinion, that the first example should behave as the developers expect it: when fail! is invoked, it should return from the execution context immediately without explicitly calling next ctx.

Remove Orchestrators - Merge its Functionality into Organizers

The idea of Orchestrators was born when we had way too much logic in a Rails controller action. It made a lot of sense then and there, but as we used it more, we realized it just has unnecessary complexity.

I'd like to kill Orchestrators and add all its functionality to Organizers, that way an Orchestrator could be a series of Actions:

[
  FindsClientAction,
  SubmitsOrderAction,
  SendsNotificationsAction
]

or have more complex workflows:

[
  FindsClientsAction,
  iterates(:clients, [
    MarksClientInactive,
    SendsNotificationAction
   ]
]

All this work is happening in the 1.0 branch,
and it won't be backward compatible.

LightService::Organizer in class instance

Hi,

i need to use organizzer in instance class, i try this

class Test 
extend LightService::Organizer

def call
    with({}).reduce(
        Hello
    )
end

but i receive undefined method `with' for ...

I try also use

include LightService::Organizer

but i receive :
DEPRECATION WARNING: including LightService::Organizer is deprecated. Please use extend LightService::Organizer

My question is how can instance one class ad use organizzer inside this class.

Thanks

How to test actions?

Is it possible if I could test an LightService::Action using rspec without having to depend on LightService::Context?

Thanks for writing this gem!

after_fail callback

To make my test failures more meaningful when asserting result.success?, I'd like to throw an error when ctx.fail! is called in my test environment. I believe this is best done by adding a callback/hook to an orchestrator that would run when the orchestrator fails. I'm opening this issue to present an idea of how that could work, and welcome people to discuss it (and then I'd be glad to implement it).

Would we rather have something naive like this:

class CalculatesTax
  extend LightService::Organizer

  def self.call(order)
    with(:order => order).reduce(
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      )
  end

  def self.after_fail
    # Do something
  end
end

or something a bit more like Active Record does callbacks, like this:

class CalculatesTax
  extend LightService::Organizer

  after_fail :do_something

  def self.call(order)
    with(:order => order).reduce(
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      )
  end

  def self.do_something
    # Do something
  end
end

I'm not sure a given orchestrator is likely to have as many reasons to stack several same-type callbacks on top of each other, so I'm not sure anything beyond the naive solution is needed; but I'd welcome people's take about it.

Organizer also deals with serving the context the proper way?

module LightService
  module Organizer
    #...
    def with(data = {})
      @context = data.kind_of?(::LightService::Context) ?
                 data :
                 LightService::Context.make(data)
      self
    end
    #...
  end
end

Why can't it be something like:

module LightService
  module Organizer
    #...
    def with(data = {})
      @context = LightService::Context.make(data)
      self
    end
    #...
  end
end

or ...

module LightService
  module Organizer
    #...
    def with(data = {})
      @context = LightService::Context.return_or_make(data)
      self
    end
    #...
  end
end

Rename LightService from light-service to light_service

This is the recommended naming pattern. I chose light-service based on a blog post I read at the time when I started LightService, but I think now that the name is misleading.

I am not alone with this unique naming convention. The project RestClient follows the same naming convention.

I would be curious to see what other folks are thinking about the possibility of renaming the gem from light-service to light_service.

Promised keys can be nil

Is this expected behavior?

class Action
  include LightService::Action
  promises :something

  executed do |ctx|
    ctx.something = nil
  end
end

ctx = LightService::Context.new
Action.execute(ctx) # => {:something=>nil}

Basically, you can circumvent giving something to a promised key by setting it to nil. Based on a variety of ruby literature, and blogs, avoiding nil can save me from a lot of trouble. I'm not sure if this should be part of light-service though.

`fail` with expects/promises macros makes refactoring difficult

I embarked on a refactoring of my app yesterday and began incorporating expects/promises into my actions. I like not needing to fetch everything, but as I refactored I kept causing spec failures.
The spec failures return the following:

     Failure/Error: FetchesInAppPurchases.for_game(game)
     LightService::ExpectedKeysNotInContextError:
       expected :games to be in the context
     # ./app/services/fetches_in_app_purchases.rb:13:in `for_game'
     # ./spec/services/fetches_in_app_purchases_spec.rb:20:in `block (3 levels) in <top (required)>'

See the backtrace names the service (specifically my call to with) but it is difficult to determine which action of the six is having the problem. I have no recourse but to keep close track of the context while refactoring, double-checking the available keys or inserting tracer statements to delete later.

I didn't know about the Kernel#fail method, though apparently it is an alias for Kernel#raise. What I don't understand is why the exception raised by ContextKeyVerifier, cleanly called in the body of the singleton execute method, traces to the call to ::with. I think it's because the following call to #with is inside the Organizer module and I guess rspec cuts out anything in the backtrace not in the project directory.

I think the thing to do is to return the name of the Action in the error message. Sorry I don't have any more time to investigate this now.

Allows custom key on context

Sometimes, I'd like to be able to add custom key on the context especially when you have condition in your service.

Example:

class Contributions::Create
  extend LightService::Action

  expects :contribution, :user
  promises :redirect_to

  executed do |context|

    if context.contribution.wire_transfer?
      service = CreateWithWireTransfer.for_project

      if service.success?
        context.redirect_to = nil # <= I'd like to be able to not set this
      else
        context.fail!(service.message, service.error_code)
      end
    elsif context.contribution.credit_card?
      service = CreateWithCreditCard.for_project

      if service.success?
        context.redirect_to = service.redirect_to
      else
        context.fail!(service.message, service.error_code)
      end
    end
  end
end

Coerce LightService::Action#execute to turn hash into context

Referencing: #1

Testing forces the gem user to "have to know" the internals of LightService i.e. discover that failure? should be stubbed everytime a hash is to be passed under execute. Ideally, LightService::Actions should be testable individually without having to stub the context hash everytime.

While reading through the source isn't necessarily bad, an extra indirection can waste developer time.

Is coercing a hash to be a context a good idea? It does make it more developer-friendly but I'm not entirely sure if that violates some other principle.

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.