Skip to content

Commit 89be127

Browse files
authored
DEV: Handle nested attributes in contracts (#36348)
This change adds the ability to validate more complex structures in the Ruby service contracts. Contracts were limited to flat structures, which is fine most of the time, but it can become tedious when managing lots of attributes. With this new feature, contracts like this one can be defined: ```ruby attribute :channel_id, :integer attribute :record, :hash do attribute :id, :integer attribute :created_at, :datetime attribute :enabled, :boolean end attribute :user, :hash do attribute :username, :string attribute :age, :integer validates :username, presence: true end attribute :items, :array do attribute :name, :string validates :name, presence: true end validates :channel_id, presence: true ``` Two nested types are available: `hash` and `array`. Each block creates a new contract, meaning coercions, validations and callbacks are available as usual.
1 parent 36750cb commit 89be127

File tree

5 files changed

+370
-10
lines changed

5 files changed

+370
-10
lines changed

lib/service/base.rb

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,7 @@ def initialize(name, method_name = name, class_name: nil, default_values_from: n
245245

246246
def run_step
247247
contract =
248-
class_name.new(
249-
**default_values.merge(context[:params].slice(*attributes)),
250-
options: context[:options],
251-
)
248+
class_name.new(**default_values.merge(context[:params]), options: context[:options])
252249
context[contract_name] = contract
253250
if contract.invalid?
254251
context[result_key].fail(errors: contract.errors, parameters: contract.raw_attributes)
@@ -271,11 +268,7 @@ def default?
271268
def default_values
272269
return {} unless default_values_from
273270
model = context[default_values_from]
274-
(model.try(:attributes).try(:with_indifferent_access) || model).slice(*attributes)
275-
end
276-
277-
def attributes
278-
class_name.attribute_names.map(&:to_sym)
271+
model.try(:attributes).try(:with_indifferent_access) || model
279272
end
280273
end
281274

lib/service/contract_base.rb

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,29 @@ class Service::ContractBase
88

99
delegate :slice, :merge, to: :to_hash
1010

11+
class << self
12+
def attribute(name, cast_type = nil, **options, &block)
13+
return super(name, cast_type, **options) unless block_given?
14+
15+
nested_contract_class =
16+
Class.new(Service::ContractBase) do
17+
define_singleton_method(:name) { "#{name}_contract".classify }
18+
class_eval(&block)
19+
end
20+
super(
21+
name,
22+
Service::NestedContractType.new(
23+
contract_class: nested_contract_class,
24+
nested_type: cast_type || :hash,
25+
),
26+
**options,
27+
)
28+
end
29+
end
30+
1131
def initialize(*args, options: nil, **kwargs)
1232
@__options__ = options
33+
kwargs.deep_symbolize_keys!.slice!(*self.class.attribute_names.map(&:to_sym))
1334
super(*args, **kwargs)
1435
end
1536

@@ -18,10 +39,45 @@ def options
1839
end
1940

2041
def to_hash
21-
attributes.symbolize_keys
42+
attributes.symbolize_keys.deep_transform_values do
43+
_1.is_a?(Service::ContractBase) ? _1.to_hash : _1
44+
end
2245
end
2346

2447
def raw_attributes
2548
@attributes.values_before_type_cast
2649
end
50+
51+
def valid?(context = nil)
52+
[super, nested_attributes_valid?].all?
53+
end
54+
55+
private
56+
57+
def nested_attributes_valid?
58+
nested_attributes.map(&method(:validate_nested)).all?
59+
end
60+
61+
def nested_attributes
62+
@attributes.each_value.select { _1.type.is_a?(Service::NestedContractType) && _1.value }
63+
end
64+
65+
def validate_nested(attribute)
66+
Array
67+
.wrap(attribute.value)
68+
.map
69+
.with_index do |contract, index|
70+
next true if contract.valid?
71+
import_nested_errors(contract, attribute, index)
72+
false
73+
end
74+
.all?
75+
end
76+
77+
def import_nested_errors(contract, attribute, index)
78+
array_index = "[#{index}]" if attribute.value.is_a?(Array)
79+
contract.errors.each do |error|
80+
errors.import(error, attribute: :"#{attribute.name}#{array_index}.#{error.attribute}")
81+
end
82+
end
2783
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
module Service
4+
class NestedContractType < ActiveModel::Type::Value
5+
attr_reader :contract_class, :nested_type
6+
7+
def initialize(contract_class:, nested_type: :hash)
8+
super()
9+
@contract_class = contract_class
10+
@nested_type = nested_type.to_s.inquiry
11+
end
12+
13+
def cast_value(value)
14+
case value
15+
when ->(*) { nested_type.hash? }
16+
cast_hash(value)
17+
when ->(*) { nested_type.array? && value.is_a?(Array) }
18+
value.filter_map(&method(:cast_hash))
19+
else
20+
nil
21+
end
22+
end
23+
24+
private
25+
26+
def cast_hash(value)
27+
return unless value.is_a?(Hash)
28+
contract_class.new(**value)
29+
end
30+
end
31+
end
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Service::ContractBase, type: :model do
4+
subject(:contract) { contract_class.new(params) }
5+
6+
describe "Nested attributes" do
7+
let(:contract_class) do
8+
Class.new(described_class) do
9+
def self.name = "TestContract"
10+
11+
attribute :channel_id, :integer
12+
13+
attribute :record, :hash do
14+
attribute :id, :integer
15+
attribute :created_at, :datetime
16+
attribute :enabled, :boolean
17+
end
18+
19+
attribute :user do # without an explicit type, it defaults to :hash
20+
attribute :username, :string
21+
attribute :age, :integer
22+
23+
validates :username, presence: true
24+
end
25+
26+
attribute :records, :array do
27+
attribute :name
28+
29+
validates :name, presence: true
30+
end
31+
32+
validates :channel_id, :user, presence: true
33+
end
34+
end
35+
let(:params) do
36+
{
37+
channel_id: 123,
38+
record: {
39+
id: 1,
40+
created_at: "2025-12-25 00:00",
41+
enabled: true,
42+
},
43+
user: {
44+
username: "alice",
45+
age: 30,
46+
},
47+
records: [{ name: "first" }, { name: "second" }],
48+
}
49+
end
50+
let(:created_at) { Time.zone.parse("2025-12-25 00:00") }
51+
52+
describe "Validations" do
53+
it { is_expected.to validate_presence_of(:channel_id) }
54+
it { is_expected.to validate_presence_of(:user) }
55+
56+
context "when user is defined" do
57+
subject(:user_contract) { contract.user }
58+
59+
it { is_expected.to validate_presence_of(:username) }
60+
61+
context "when user has errors" do
62+
before { params[:user].delete(:username) }
63+
64+
it "marks the main contract as invalid" do
65+
expect(contract).to be_invalid
66+
expect(contract.errors).to include(:"user.username")
67+
end
68+
end
69+
end
70+
71+
context "when records is defined" do
72+
subject(:records_contracts) { contract.records }
73+
74+
it { is_expected.to all validate_presence_of(:name) }
75+
76+
context "when records has errors" do
77+
before { params[:records][1].delete(:name) }
78+
79+
it "marks the main contract as invalid" do
80+
expect(contract).to be_invalid
81+
expect(contract.errors).to include(:"records[1].name")
82+
end
83+
end
84+
end
85+
end
86+
87+
it "casts nested attributes to contract objects" do
88+
expect(contract).to have_attributes(
89+
record: a_kind_of(Service::ContractBase),
90+
user: a_kind_of(Service::ContractBase),
91+
records: all(a_kind_of(Service::ContractBase)),
92+
)
93+
end
94+
95+
it "exposes nested attribute values" do
96+
expect(contract).to have_attributes(
97+
record: an_object_having_attributes(id: 1, enabled: true, created_at:),
98+
user: an_object_having_attributes(username: "alice", age: 30),
99+
records:
100+
a_collection_containing_exactly(
101+
an_object_having_attributes(name: "first"),
102+
an_object_having_attributes(name: "second"),
103+
),
104+
)
105+
end
106+
107+
it "converts to a nested hash" do
108+
expect(contract.to_hash).to include(
109+
channel_id: 123,
110+
record: {
111+
id: 1,
112+
enabled: true,
113+
created_at:,
114+
},
115+
user: {
116+
username: "alice",
117+
age: 30,
118+
},
119+
records: [{ name: "first" }, { name: "second" }],
120+
)
121+
end
122+
123+
context "with multiple levels of nesting" do
124+
let(:contract_class) do
125+
Class.new(described_class) do
126+
def self.name = "TestContract"
127+
128+
attribute :data do
129+
attribute :nested do
130+
attribute :value, :string
131+
132+
validates :value, presence: true
133+
end
134+
end
135+
136+
attribute :items, :array do
137+
attribute :name, :string
138+
139+
attribute :nested, :array do
140+
attribute :value, :string
141+
142+
validates :value, presence: true
143+
end
144+
145+
validates :name, presence: true
146+
end
147+
end
148+
end
149+
let(:params) do
150+
{
151+
data: {
152+
nested: {
153+
value: "deep",
154+
},
155+
},
156+
items: [{ name: "item 1", nested: [{ value: "deep" }] }],
157+
}
158+
end
159+
160+
it { is_expected.to be_valid }
161+
162+
it "handles deeply nested structures" do
163+
expect(contract.data.nested.value).to eq("deep")
164+
expect(contract.items[0].nested[0].value).to eq("deep")
165+
end
166+
167+
it "properly converts to a hash" do
168+
expect(contract.to_hash).to include(
169+
data: {
170+
nested: {
171+
value: "deep",
172+
},
173+
},
174+
items: [{ name: "item 1", nested: [{ value: "deep" }] }],
175+
)
176+
end
177+
178+
context "when there are errors at several levels" do
179+
let(:params) do
180+
{
181+
data: {
182+
nested: {
183+
value: "deep",
184+
},
185+
},
186+
items: [
187+
{ name: "item 1", nested: [{ value: "" }] },
188+
{ nested: [{ value: "1" }, { value: "" }] },
189+
],
190+
}
191+
end
192+
193+
it "reports all the proper errors" do
194+
expect(contract).to be_invalid
195+
expect(contract.errors).to include(
196+
:"items[0].nested[0].value",
197+
:"items[1].name",
198+
:"items[1].nested[1].value",
199+
)
200+
end
201+
end
202+
end
203+
end
204+
end

0 commit comments

Comments
 (0)