Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ You can also give a default value for an argument.
argument :name, default: "Benjamin"
```

Defaults can also be callable (procs or lambdas) that are evaluated at runtime. Callable defaults have access to other attributes and are processed in definition order:

```ruby
class GreetingService < ApplicationService
argument :first_name, default: "John"
argument :last_name, default: "Doe"
argument :full_name, default: -> { "#{first_name} #{last_name}" }
argument :created_at, default: -> { Time.current }

def call
"Hello, #{full_name}!"
end
end

GreetingService.call.result #=> "Hello, John Doe!"
GreetingService.call(first_name: "Jane").result #=> "Hello, Jane Doe!"
```

### Validations

Since `Telephone::Service` includes `ActiveModel::Model`, you can define validations in the same way you would for an ActiveRecord model.
Expand Down
37 changes: 32 additions & 5 deletions lib/telephone/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,24 @@ class Service
# Primary responsibility of initialize is to instantiate the
# attributes of the service object with the expected values.
def initialize(attributes = {})
self.class.defaults.merge(attributes).each do |key, value|
attributes.each do |key, value|
send("#{key}=", value)
end

self.class.defaults.each do |key, value|
next if attributes.key?(key)

resolved = if value.is_a?(Proc)
instance_exec(&value)
elsif value.respond_to?(:call)
value.call
else
value
end

send("#{key}=", resolved)
end

super
yield self if block_given?
end
Expand Down Expand Up @@ -51,14 +65,27 @@ class << self
# to pass in a default, or set the argument to "required" to add a validation
# that runs before executing the block.
#
# The default value can be a static value or any callable object (Proc, lambda,
# method, or any object that responds to #call) that will be evaluated at
# runtime when the service is instantiated.
#
# Callable defaults are evaluated in the context of the service instance,
# so they can access other attributes. They are processed in definition order,
# meaning a callable can depend on any argument defined before it.
#
# To store a Proc as the actual value, wrap it in another lambda:
# argument :my_proc, default: -> { -> { puts "hi" } }
#
# @example
# class SomeService < Telephone::Service
# argument :foo, default: "bar"
# argument :baz, required: true
# argument :first_name, default: "John"
# argument :last_name, default: "Doe"
# argument :full_name, default: -> { "#{first_name} #{last_name}" }
# argument :timestamp, default: -> { DateTime.current }
#
# def call
# puts foo
# puts baz
# puts full_name
# puts timestamp
# end
# end
def argument(arg, default: nil, required: false)
Expand Down
111 changes: 111 additions & 0 deletions spec/telephone_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,117 @@ def call

expect(subject.call.success?).to be false
end

context "with callable defaults" do
it "evaluates callable defaults fresh on each instantiation" do
counter = {value: 0}
service = Class.new(Telephone::Service) do
argument :count, default: proc { counter[:value] += 1 }

def call
count
end
end

expect(service.new.count).to eq 1
expect(service.new.count).to eq 2
expect(service.new.count).to eq 3
end

it "works with any object responding to #call" do
callable_object = Class.new do
def call
"from callable object"
end
end.new

service = Class.new(Telephone::Service) do
argument :value, default: callable_object

def call
value
end
end

expect(service.new.value).to eq "from callable object"
end

it "allows overriding callable defaults with explicit values" do
service = Class.new(Telephone::Service) do
argument :value, default: -> { "default" }
end

expect(service.new(value: "custom").value).to eq "custom"
end

it "does not call non-callable defaults" do
service = Class.new(Telephone::Service) do
argument :data, default: "static string"

def call
data
end
end

expect(service.new.data).to eq "static string"
end

it "allows callables to access other attributes" do
service = Class.new(Telephone::Service) do
argument :first_name, default: "John"
argument :last_name, default: "Doe"
argument :full_name, default: -> { "#{first_name} #{last_name}" }

def call
full_name
end
end

expect(service.new.full_name).to eq "John Doe"
end

it "allows callables to depend on other callables in definition order" do
service = Class.new(Telephone::Service) do
argument :first_name, default: "John"
argument :last_name, default: "Doe"
argument :full_name, default: -> { "#{first_name} #{last_name}" }
argument :greeting, default: -> { "Hello, #{full_name}!" }

def call
greeting
end
end

expect(service.new.greeting).to eq "Hello, John Doe!"
end

it "allows callables to access explicitly provided attributes" do
service = Class.new(Telephone::Service) do
argument :first_name
argument :last_name
argument :full_name, default: -> { "#{first_name} #{last_name}" }

def call
full_name
end
end

expect(service.new(first_name: "Jane", last_name: "Smith").full_name).to eq "Jane Smith"
end

it "processes explicit attributes before callable defaults" do
service = Class.new(Telephone::Service) do
argument :name, default: "Default"
argument :message, default: -> { "Hello, #{name}!" }

def call
message
end
end

expect(service.new(name: "Custom").message).to eq "Hello, Custom!"
end
end
end

describe "#new" do
Expand Down