Skip to content

Commit 81dd5a0

Browse files
committed
Add docs for contract nested attributes
1 parent 3636488 commit 81dd5a0

File tree

1 file changed

+78
-1
lines changed

1 file changed

+78
-1
lines changed

docs/03-code-internals/19-service-objects.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ Here, all the API from `ActiveModel` is available. In this example, we define we
574574
> 💡 Use cast types extensively as they’ll provide you with proper objects before any validation happens.
575575
>
576576
> Rails ships with cast types for [`big_integer`](https://api.rubyonrails.org/classes/ActiveModel/Type/BigInteger.html), [`binary`](https://api.rubyonrails.org/classes/ActiveModel/Type/Binary.html), [`boolean`](https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html), [`date`](https://api.rubyonrails.org/classes/ActiveModel/Type/Date.html), [`datetime`](https://api.rubyonrails.org/classes/ActiveModel/Type/DateTime.html), [`decimal`](https://api.rubyonrails.org/classes/ActiveModel/Type/Decimal.html), [`float`](https://api.rubyonrails.org/v7.1.4/classes/ActiveModel/Type/Float.html), [`immutable_string`](https://api.rubyonrails.org/v7.1.4/classes/ActiveModel/Type/ImmutableString.html), [`integer`](https://api.rubyonrails.org/v7.1.4/classes/ActiveModel/Type/Integer.html), [`string`](https://api.rubyonrails.org/v7.1.4/classes/ActiveModel/Type/String.html) and [`time`](https://api.rubyonrails.org/v7.1.4/classes/ActiveModel/Type/Time.html).
577-
> Custom cast types can be defined, we ship one: [`array`](https://github.com/discourse/discourse/blob/main/lib/active_support_type_extensions/array.rb).
577+
> Custom cast types can be defined, we ship tow: [`array`](https://github.com/discourse/discourse/blob/main/lib/active_support_type_extensions/array.rb) and [`symbol`](https://github.com/discourse/discourse/blob/main/lib/active_support_type_extensions/symbol.rb).
578578
579579
> 🙅 Don’t define attributes if you don’t transform them or validate them. The primary purpose of a contract is to validate data, it can also be used to cast or massage data before using it (usually a contract does both).
580580
@@ -589,6 +589,83 @@ Some methods have been added to the contract object to make your life a bit easi
589589
- `#slice` and `#merge` are available.
590590
- `#to_hash` has been implemented, so the contract object will be automatically cast as a hash by Ruby depending on the context. For example, with an ActiveRecord model, you can do this: `user.update(**params)`.
591591

592+
### Nested attributes
593+
594+
It’s fairly common to get nested parameters from an endpoint. This usually helps keeping things organized. Contracts support nested hashes and nested arrays.
595+
Let’s imagine we have a service that needs to respond to that kind of input:
596+
597+
```json
598+
{
599+
"user": {
600+
"id": 2,
601+
"username": "new username"
602+
},
603+
"page": {
604+
"sort": "asc",
605+
"number": 3
606+
}
607+
}
608+
```
609+
610+
We can easily define a contract to validate such input:
611+
612+
```rb
613+
params do
614+
attribute :user, :hash do
615+
attribute :id, :integer
616+
attribute :username, :string
617+
618+
validates :id, :username, presence: true
619+
end
620+
621+
attribute :page, :hash, default: -> { {} } do
622+
attribute :sort, :symbol, default: :asc
623+
attribute :number, :integer, default: 1
624+
625+
validates :sort, inclusion: { in: %i[asc desc] }
626+
validates :number, numericality: { only_integer: true, greater_than: 0 }
627+
end
628+
629+
validates :user, presence: true
630+
end
631+
```
632+
633+
For each nested attribute we want to validate, we just have to open a block and inside that block, we can define attributes and validations as usual. Inside that block, we have access to another contract object, so everything we can do in the parent contract is available in the nested one.
634+
635+
There are some things to notice:
636+
637+
- Here, before opening the block, we’re using `:hash` to tell our contract to expect another hash. If we had an array of hashes, we would have used `:array` instead.
638+
- A nested attribute is just an attribute, so if you want to validate its presence, you have to add a validation for it (as done for the `user` attribute). Otherwise, it will be considered as an optional attribute.
639+
- When using default values, like we do for the `page` attribute, make sure to provide a default value for the whole attribute (here, an empty hash) to avoid `nil` errors when accessing nested attributes.
640+
641+
After validation, errors will be defined directly on the main contract, as usual. We’re using the same approach Rails does for nested attributes in models. For example, if the `username` attribute is blank, the error will be available at `errors[:"user.username"]`. The full message will be `User username can't be blank`.
642+
643+
Now, let’s imagine our input has arrays of hashes, like this:
644+
645+
```json
646+
{
647+
"tags": [{ "name": "tag1" }, { "name": "tag2" }]
648+
}
649+
```
650+
651+
Then a contract to validate that input would look like this:
652+
653+
```rb
654+
params do
655+
attribute :tags, :array do
656+
attribute :name, :string
657+
658+
validates :name, presence: true
659+
end
660+
661+
validates :tags, presence: true
662+
end
663+
```
664+
665+
If there are errors, it will use the index of the array to define them. For example, if the second tag has no name, the error will be available at `errors[:"tags[1].name"]`. The full message will be `Tags[1] name can't be blank`.
666+
667+
And of course, you can combine nested hashes and arrays as you see fit.
668+
592669
### Reusing contracts
593670

594671
Sometimes, you may want to reuse a contract between different services. For example, you could have a `Create` and an `Update` service for the same concept, and both would share most of their contract logic.

0 commit comments

Comments
 (0)