From e653a424c524a0a0392ca050067deca08f34119f Mon Sep 17 00:00:00 2001 From: Benjamin Hargett Date: Tue, 14 Oct 2025 10:47:29 -0400 Subject: [PATCH 1/2] feat: support callable default values --- lib/telephone/service.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/telephone/service.rb b/lib/telephone/service.rb index 68cff4f..69a4dfd 100644 --- a/lib/telephone/service.rb +++ b/lib/telephone/service.rb @@ -20,7 +20,11 @@ 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| + evaluated_defaults = self.class.defaults.transform_values do |value| + value.respond_to?(:call) ? value.call : value + end + + evaluated_defaults.merge(attributes).each do |key, value| send("#{key}=", value) end @@ -51,14 +55,20 @@ 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. + # # @example # class SomeService < Telephone::Service # argument :foo, default: "bar" # argument :baz, required: true + # argument :timestamp, default: -> { DateTime.current } # # def call # puts foo # puts baz + # puts timestamp # end # end def argument(arg, default: nil, required: false) From 5dda2867a588c8548e32370516310b9c6597b7ed Mon Sep 17 00:00:00 2001 From: Benjamin Hargett Date: Wed, 17 Dec 2025 20:40:32 -0500 Subject: [PATCH 2/2] feat: add instance context and dependency support for callable defaults --- README.md | 18 +++++++ lib/telephone/service.rb | 33 +++++++++--- spec/telephone_spec.rb | 111 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 8 deletions(-) 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 69a4dfd..c8bb946 100644 --- a/lib/telephone/service.rb +++ b/lib/telephone/service.rb @@ -20,12 +20,22 @@ class Service # Primary responsibility of initialize is to instantiate the # attributes of the service object with the expected values. def initialize(attributes = {}) - evaluated_defaults = self.class.defaults.transform_values do |value| - value.respond_to?(:call) ? value.call : value + attributes.each do |key, value| + send("#{key}=", value) end - evaluated_defaults.merge(attributes).each do |key, value| - send("#{key}=", value) + 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 @@ -59,15 +69,22 @@ class << self # 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 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