diff --git a/README.md b/README.md index 23731b0..cb97f8a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/telephone/service.rb b/lib/telephone/service.rb index 68cff4f..c8bb946 100644 --- a/lib/telephone/service.rb +++ b/lib/telephone/service.rb @@ -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 @@ -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) diff --git a/spec/telephone_spec.rb b/spec/telephone_spec.rb index 650e6c3..940d3e9 100644 --- a/spec/telephone_spec.rb +++ b/spec/telephone_spec.rb @@ -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