stannum 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/README.md +129 -1263
  4. data/config/locales/en.rb +4 -0
  5. data/lib/stannum/association.rb +293 -0
  6. data/lib/stannum/associations/many.rb +250 -0
  7. data/lib/stannum/associations/one.rb +106 -0
  8. data/lib/stannum/associations.rb +11 -0
  9. data/lib/stannum/attribute.rb +86 -8
  10. data/lib/stannum/constraints/base.rb +3 -5
  11. data/lib/stannum/constraints/enum.rb +1 -1
  12. data/lib/stannum/constraints/equality.rb +1 -1
  13. data/lib/stannum/constraints/format.rb +72 -0
  14. data/lib/stannum/constraints/hashes/extra_keys.rb +7 -12
  15. data/lib/stannum/constraints/identity.rb +1 -1
  16. data/lib/stannum/constraints/properties/base.rb +1 -1
  17. data/lib/stannum/constraints/properties/do_not_match_property.rb +11 -11
  18. data/lib/stannum/constraints/properties/match_property.rb +11 -11
  19. data/lib/stannum/constraints/properties/matching.rb +7 -7
  20. data/lib/stannum/constraints/signature.rb +2 -2
  21. data/lib/stannum/constraints/tuples/extra_items.rb +6 -6
  22. data/lib/stannum/constraints/type.rb +3 -3
  23. data/lib/stannum/constraints/types/array_type.rb +2 -2
  24. data/lib/stannum/constraints/types/hash_type.rb +4 -4
  25. data/lib/stannum/constraints/union.rb +1 -1
  26. data/lib/stannum/constraints/uuid.rb +30 -0
  27. data/lib/stannum/constraints.rb +2 -0
  28. data/lib/stannum/contract.rb +7 -7
  29. data/lib/stannum/contracts/array_contract.rb +2 -7
  30. data/lib/stannum/contracts/base.rb +15 -15
  31. data/lib/stannum/contracts/builder.rb +2 -2
  32. data/lib/stannum/contracts/hash_contract.rb +3 -9
  33. data/lib/stannum/contracts/indifferent_hash_contract.rb +2 -2
  34. data/lib/stannum/contracts/map_contract.rb +6 -10
  35. data/lib/stannum/contracts/parameters/arguments_contract.rb +1 -1
  36. data/lib/stannum/contracts/parameters/keywords_contract.rb +1 -1
  37. data/lib/stannum/contracts/parameters/signature_contract.rb +1 -1
  38. data/lib/stannum/contracts/parameters_contract.rb +4 -4
  39. data/lib/stannum/contracts/tuple_contract.rb +5 -5
  40. data/lib/stannum/entities/associations.rb +451 -0
  41. data/lib/stannum/entities/attributes.rb +116 -18
  42. data/lib/stannum/entities/constraints.rb +3 -2
  43. data/lib/stannum/entities/primary_key.rb +148 -0
  44. data/lib/stannum/entities/properties.rb +30 -8
  45. data/lib/stannum/entities.rb +5 -2
  46. data/lib/stannum/entity.rb +4 -0
  47. data/lib/stannum/errors.rb +9 -13
  48. data/lib/stannum/messages/default_strategy.rb +2 -2
  49. data/lib/stannum/parameter_validation.rb +10 -10
  50. data/lib/stannum/rspec/match_errors_matcher.rb +1 -1
  51. data/lib/stannum/rspec/validate_parameter.rb +2 -2
  52. data/lib/stannum/rspec/validate_parameter_matcher.rb +15 -13
  53. data/lib/stannum/schema.rb +62 -62
  54. data/lib/stannum/support/optional.rb +1 -1
  55. data/lib/stannum/version.rb +4 -4
  56. data/lib/stannum.rb +3 -0
  57. metadata +14 -79
data/README.md CHANGED
@@ -2,24 +2,23 @@
2
2
 
3
3
  A library for defining and validating data structures.
4
4
 
5
- Stannum defines the following objects:
6
-
7
- - [Constraints](#constraints): A validator object that responds to `#match`, `#matches?` and `#errors_for` for a given object.
8
- - [Contracts](#contracts): A collection of constraints about an object or its properties. Obeys the `Constraint` interface.
9
- - [Errors](#errors): Data object for storing validation errors. Supports arbitrary nesting of errors.
10
- - [Entities](#entities): Defines a mutable data object with a specified set of typed attributes.
11
-
12
- ## About
5
+ <blockquote>
6
+ Read The
7
+ <a href="https://www.sleepingkingstudios.com/stannum" target="_blank">
8
+ Documentation
9
+ </a>
10
+ </blockquote>
13
11
 
14
12
  Stannum provides a framework-independent toolkit for defining structured data entities and validations. It provides a middle ground between unstructured data (raw `Hash`es, `Structs`, or libraries like `Hashie`) and full frameworks like `ActiveModel`.
15
13
 
16
- First and foremost, Stannum provides you with the tools to validate your data. Using a `Stannum::Constraint`, you can apply your validation logic to literally any object, whether pure Ruby or from any framework or toolkit. Stannum provides a range of pre-defined constraints, including constraints for validating object types, defined methods, and more. You can also define custom constraints for any check that can output either `true` or `false`.
14
+ It defines the following objects:
17
15
 
18
- Finally, you can combine your constraints into a `Stannum::Contract` to combine multiple validations of your object and its properties. Stannum provides pre-defined contracts for asserting on objects, `Array`s, `Hash`es, and even method parameters.
16
+ - [Constraints](http://sleepingkingstudios.github.io/stannum/constraints): A validator object that responds to `#match`, `#matches?` and `#errors_for` for a given object.
17
+ - [Contracts](http://sleepingkingstudios.github.io/stannum/contracts): A collection of constraints about an object or its properties. Obeys the `Constraint` interface.
18
+ - [Errors](http://sleepingkingstudios.github.io/stannum/errors): Data object for storing validation errors. Supports arbitrary nesting of errors.
19
+ - [Entities](http://sleepingkingstudios.github.io/stannum/entities): Defines a mutable data object with a specified set of typed attributes.
19
20
 
20
- Stannum also defines the `Stannum::Entity` module for defining structured data entities that are not tied to any framework or datastore. Stannum entities have more functionality and a friendlier interface than a core library `Struct`, provide more structure than a `Hash` or hash-like object (such as an `OpenStruct` or `Hashie::Mash`), and are completely independent from the source of the data. Need to load seed data from a YAML configuration file, perform operations in a SQL database, cross-reference with a MongoDB data store, and use an in-memory data array for lightning-fast tests? A `Stannum::Entity` won't fight you every step of the way.
21
-
22
- ### Why Stannum?
21
+ ## Why Stannum?
23
22
 
24
23
  Stannum is not tied to any framework. You can create constraints and contracts to validate Ruby objects and Entities, data structures such as Arrays, Hashes, and Sets, and even framework objects such as `ActiveRecord::Model`s and `Mongoid::Document`s.
25
24
 
@@ -33,15 +32,19 @@ Still, most projects and applications use one framework to handle their data. Wh
33
32
 
34
33
  ### Compatibility
35
34
 
36
- Stannum is tested against Ruby (MRI) 2.7 through 3.2.
35
+ Stannum is tested against Ruby (MRI) 3.1 through 3.4.
37
36
 
38
37
  ### Documentation
39
38
 
40
- Documentation is generated using [YARD](https://yardoc.org/), and can be generated locally using the `yard` gem.
39
+ Code documentation is generated using [YARD](https://yardoc.org/), and can be generated locally using the `yard` gem.
40
+
41
+ The full documentation is available via [GitHub Pages](http://sleepingkingstudios.github.io/stannum), and includes the code documentation as well as a deeper explanation of Stannum's features and design philosophy. It also includes documentation for prior versions of the gem.
42
+
43
+ To generate documentation locally, see the [SleepingKingStudios::Docs](https://github.com/sleepingkingstudios/sleeping_king_studios-docs) gem.
41
44
 
42
45
  ### License
43
46
 
44
- Copyright (c) 2019-2021 Rob Smith
47
+ Copyright (c) 2019-2025 Rob Smith
45
48
 
46
49
  Stannum is released under the [MIT License](https://opensource.org/licenses/MIT).
47
50
 
@@ -57,1335 +60,198 @@ To contribute code, please fork the repository, make the desired updates, and th
57
60
 
58
61
  Please note that the `Stannum` project is released with a [Contributor Code of Conduct](https://github.com/sleepingkingstudios/stannum/blob/master/CODE_OF_CONDUCT.md). By contributing to this project, you agree to abide by its terms.
59
62
 
60
- <!-- ## Getting Started -->
63
+ ## Getting Started
64
+ Let's take a look at using Stannum to model a problem domain. Consider the following case study: we are implementing an ecommerce application. We want to ensure that our Order object is valid through each stage of our ordering process. For the sake of simplicity, let's say our process has three steps:
61
65
 
62
- ## Reference
66
+ - A customer creates an order.
67
+ - The customer is billed for the order.
68
+ - The order is shipped to the customer.
63
69
 
64
- <a id="constraints"></a>
70
+ ### Defining Entities
65
71
 
66
- ### Constraints
72
+ Our first step is to define some entities to represent our data.
67
73
 
68
74
  ```ruby
69
- require 'stannum/constraint'
70
- ```
75
+ class Customer
76
+ include Stannum::Entity
71
77
 
72
- Constraints provide the foundation for data validation in Stannum. Fundamentally, each `Stannum::Constraint`encapsulates a predicate - a statement that can be either true or false - that can be applied to other objects. If the statement is true about the object, that object "matches" the constraint. If the statement is false, the object does not match the constraint.
78
+ define_primary_key :id, Integer
73
79
 
74
- The easiest way to define a constraint is by passing a block to `Stannum::Constraint.new`:
80
+ define_attribute :email, String
81
+ define_attribute :address, String
75
82
 
76
- ```ruby
77
- constraint = Stannum::Constraint.new do |object|
78
- object.is_a?(String) && !object.empty?
83
+ define_association :many, :orders
79
84
  end
80
85
  ```
81
86
 
82
- Here, we've created a very simple constraint, which will match any non-empty string and will not match empty strings or other objects. When you define a constraint using `Stannum::Constraint.new`, the constraint will pass for an object if and only if the block returns `true` for that object. We can also pass in additional metadata about the constraint such as a type or message to display - we will revisit this in [Errors, Types and Messages](#constraints-errors-types-messages), below.
83
-
84
- #### Matching Objects
85
-
86
- Defining a constraint is only half the battle - next, we need to use the constraint. Each `Stannum::Constraint` defines a standard set of methods to match objects.
87
-
88
- First, the `#matches?` method will return true if the object matches the constraint, and will return false if the object does not match.
89
-
90
- ```ruby
91
- constraint.matches?(nil)
92
- #=> false
93
-
94
- constraint.matches?('')
95
- #=> false
96
-
97
- constraint.matches?('Greetings, programs!')
98
- #=> true
99
- ```
100
-
101
- Knowing that an object does not match isn't always enough information - we need to know why. Stannum defines the `Stannum::Errors` object for this purpose (see [Errors](#errors), below). We can use the `#match` method to both check whether an object matches the constraint and return any errors with one method call.
102
-
103
- ```ruby
104
- status, errors = constraint.matches?(nil)
105
- status
106
- #=> false
107
- errors
108
- #=> an instance of Stannum::Errors
109
- errors.empty?
110
- #=> false
111
-
112
- status, errors = constraint.matches?('Greetings, programs!')
113
- status
114
- #=> true
115
- errors
116
- #=> an instance of Stannum::Errors
117
- errors.empty?
118
- #=> true
119
- ```
120
-
121
- Finally, if we already know that an object does not match the constraint, we can check its errors using the `#errors_for` method.
122
-
123
- ```ruby
124
- errors = constraint.errors_for(nil)
125
- #=> an instance of Stannum::Errors
126
- errors.empty?
127
- #=> false
128
- ```
129
-
130
- *Important Note:* Stannum **does not** guarantee that `#errors_for` will return an empty `Errors` object for an object that matches the constraint. Always check whether the object matches the constraint before checking the errors.
131
-
132
- #### Negated Matching
133
-
134
- A constraint can also be used to check if an object does not match the constraint. Each `Stannum::Constraint` defines helpers for the negated use case.
135
-
136
- The `#does_not_match?` method is the inverse of `#matches?`. It will return false if the object matches the constraint, and will return true if the object does not match.
137
-
138
- ```ruby
139
- constraint.does_not_match?(nil)
140
- #=> true
141
-
142
- constraint.does_not_match?('')
143
- #=> true
144
-
145
- constraint.does_not_match?('Greetings, programs!')
146
- #=> false
147
- ```
148
-
149
- Negated matches can also generate errors objects. Whereas the errors from a standard match will list how the object fails to match the constraint, the errors from a negated match will list how the object does match the constraint. The `#negated_match` method will both check that the object does not match the constraint and return the relevant errors, while the `#negated_errors_for` method will return the negated errors for a matching object.
150
-
151
- <a id="constraints-errors-types-messages"></a>
152
-
153
- #### Errors, Types and Messages
154
-
155
- We can customize the error returned by the constraint for a non-matching object by setting the constraint type and/or message.
156
-
157
- ```ruby
158
- constraint = Stannum::Constraint.new(
159
- message: 'must be even',
160
- type: 'example.constraints.even'
161
- ) { |i| i.even? }
162
- ```
163
-
164
- The constraint `#type` identifies the kind of constraint. For example, a `case` or conditional statement that checks for an error of a particular variety would look at the error's type. The constraint `#message`, on the other hand, is a human-readable description of the error. A flash message or rendered might use the error's message to display the status to the user. An API response might provide both the type and the message.
165
-
166
- The constraint type and message are used to generate the corresponding error:
167
-
168
- ```ruby
169
- errors = constraint.errors_for(nil)
170
- errors.count
171
- #=> 1
172
- errors.first.message
173
- #=> 'must be even'
174
- errors.first.type
175
- #=> 'example.constraints.even'
176
-
177
- ```
178
-
179
- The error message can also be generated automatically from the type (see [Generating Messages](#errors-generating-messages), below).
180
-
181
- #### Constraint Subclasses
182
-
183
- Defining a subclass of `Stannum::Constraint` allows for greater control over the predicate logic and the generated errors.
87
+ Our `Customer` class represents a user who can create an order in our system. We define an `#id` primary key, attributes `#email` and `#address`, and a plural association to our `Order` entity.
184
88
 
185
89
  ```ruby
186
- class EvenIntegerConstraint < Stannum::Constraint
187
- NEGATED_TYPE = 'examples.constraints.odd'
188
- TYPE = 'examples.constraints.even'
90
+ class Payment
91
+ include Stannum::Entity
189
92
 
190
- def errors_for(actual, errors: nil)
191
- return super if actual.is_a?(Integer)
93
+ define_primary_key :id, Integer
192
94
 
193
- (errors || Stannum::Errors.new)
194
- .add('examples.constraints.type', type: Integer)
195
- end
95
+ define_attribute :amount, BigDecimal
196
96
 
197
- def matches?(actual)
198
- actual.is_a?(Integer) && actual.even?
199
- end
97
+ define_association :one, :order, foreign_key: true
200
98
  end
201
99
  ```
202
100
 
203
- Let's take it from the top. We start by defining `::NEGATED_TYPE` and `::TYPE` constraints. These serve two purposes: first, the constraint will use these values as the default `#type` and `#negated_type` properties, without having to pass in values to the constructor. Second, we are declaring the type of error this constraints will return to the rest of the code. This allows us to reference these values elsewhere, such as a `case` or conditional statement checking for the presense of this error.
204
-
205
- Second, we define our `#matches?` method. This method takes one parameter (the object being matched) and returns either `true` or `false`. Our other matching methods - `#does_not_match?`, `#match`, and `#negated_match` - will delegate to this implementation unless we specifically override them.
206
-
207
- Finally, we are defining the errors to be returned from our constraint using the `#errors_for` method. This method takes one required argument `actual`, which is the object being matched. If the object is an integer, then we fall back to the default behavior: `super` will add an error with a `#type` equal to the constraint's `#type` (or the `:type` passed into the constructor, if any). If the object is not an integer, then we instead display a custom error. In addition to the error `#type`, we are defining some error `#data`. In addition, `#errors_for` can take an optional keyword `:errors`, which is either an instance of `Stannum::Errors` or `nil`. This allows the user to pass an existing errors object to `#errors_for`, which will add its own errors to the given errors object instead of creating a new one.
208
-
209
- ```ruby
210
- errors = constraint.errors_for(nil)
211
- errors.count
212
- #=> 1
213
- errors.first.type
214
- #=> 'examples.constraints.type'
215
- errors.first.data
216
- #=> { type: Integer }
217
-
218
- errors = constraint.errors_for('')
219
- errors.count
220
- #=> 1
221
- errors.first.type
222
- #=> 'examples.constraints.even'
223
- errors.first.data
224
- #=> {}
225
- ```
226
-
227
- We can likewise define the behavior of the constraint when negated. We've already set the `::NEGATED_TYPE` constant, but we can go further and override the `#does_not_match?` and/or `#negated_errors_for` methods as well for full control over the behavior when performing a negated match.
228
-
229
- <a id="contracts"></a>
230
-
231
- ### Contracts
101
+ Our `Payment` class represents a payment submitted by our customer for an order. We again define an `#id` primary key, as well as an `#amount` attribute and a singular association to an `Order`. Note that we are specifying a foreign key for the `#order` association, which automatically creates an `#order_id` attribute.
232
102
 
233
103
  ```ruby
234
- require 'stannum/contract'
235
- ```
236
-
237
- A contract is a collection of constraints that validate an object and its properties. Each `Stannum::Contract` holds a set of `Stannum::Constraints`, each of which must match an object or the referenced property for that object to match the contract as a whole. Contracts also obey the Constraint interface, and can be used inside other contracts to compose complex or nested validations.
104
+ class Order
105
+ include Stannum::Entity
238
106
 
239
- Like constraints, contracts can be created by passing a block to `Stannum::Contract.new`:
107
+ define_primary_key :id, Integer
240
108
 
241
- ```ruby
242
- contract = Stannum::Contract.new do
243
- constraint(type: 'examples.constraints.numeric') do |actual|
244
- actual.is_a?(Numeric)
245
- end
109
+ define_attribute :amount, BigDecimal
246
110
 
247
- constraint(type: 'examples.constraints.integer') do |actual|
248
- actual.is_a?(Integer)
249
- end
111
+ define_association :one, :customer, foreign_key: true
112
+ define_association :one, :payment
250
113
 
251
- constraint(type: 'examples.constraints.in_range') do |actual|
252
- actual >= 0 && actual <= 10 rescue false
253
- end
114
+ constraint :customer, Stannum::Constraints::Presence.new
254
115
  end
255
-
256
- contract.matches?(nil)
257
- #=> false
258
- contract.errors_for(nil).map(&:type)
259
- #=> ['examples.constraints.numeric', 'examples.constraints.integer', 'examples.constraints.in_range']
260
-
261
- contract.matches?(99.0)
262
- #=> false
263
- contract.errors_for(99.0).map(&:type)
264
- #=> ['examples.constraints.integer', 'examples.constraints.in_range']
265
-
266
- contract.matches?(99)
267
- #=> false
268
- contract.errors_for(99).map(&:type)
269
- #=> ['examples.constraints.in_range']
270
-
271
- contract.matches?(5)
272
- #=> true
273
116
  ```
274
117
 
275
- As you can see, the contract matches the object against each of its constraints. If any of the constraints fail to match the object, then the contract also does not match. Finally, the errors from each failing constraint are aggregated together.
276
-
277
- You can also add constraints to an existing contract using the `#add_constraint` method.
278
-
279
- ```ruby
280
- constraint = Stannum::Constraint.new(type: 'examples.constraints.even') do |actual|
281
- actual.respond_to?(:even) && actual.even?
282
- end
118
+ Our core class for this workflow is the `Order` entity. We define our `#id` and `#amount`, and associations to the `Customer` and `Payment` entities - again, by passing `foreign_key: true` to our `#customer` association, we also define a `#customer_id` attribute. Finally, we are defining an additional constraint. When validating the `Order` using the default contract, we will require the `#customer` association to be populated - an `Order` must always have an associated `Customer`. However, we are *not* requiring the presence of a `Payment`, since that requirement is not applicable to the entire `Order` lifecycle.
283
119
 
284
- contract.add_constraint(constraint)
285
-
286
- contract.matches?(5)
287
- #=> false
288
- contract.errors_for(99).map(&:type)
289
- #=> ['examples.constraints.even']
120
+ ### Defining Validators
290
121
 
291
- contract.matches?(6)
292
- #=> true
293
- ```
122
+ Actually implementing the business logic for orders is outside the scope of Stannum - for a structured approach to defining your business logic, take a look at the [Cuprum](https://www.sleepingkingstudios.com/cuprum/) gem.
294
123
 
295
- The `#add_constraint` method returns the contract, so you can chain multiple `#add_constraint` calls together.
124
+ However, that doesn't mean we're finished. One of the challenges in implementing a multi-step process like our ordering flow is validation. Specifically, this kind of workflow requires *contextual* validation - an `Order` object that is valid for one part of the flow may not be valid for others. Rather than defining conditional logic in our `Order` class, let's instead apply the concept of a Validator: an object that is responsible for validating an entity or data structure *in a particular context*.
296
125
 
297
- #### Negated Matching
126
+ Let's start with our first step, order creation. Creating an order requires a valid `#id`, a valid `#amount` (can be zero at this point in the workflow), and an associated `#customer`. Fortunately, we already have a contract defined for these requirements: the existing `Order::Contract`, which validates the entity's attributes and any additional `constraint`s defined on the entity.
298
127
 
299
- Like a constraint, a contract can perform a negated match. Whereas an object matches the contract if **all** of the constraints match the object, the object will pass a negated match if **none** of the constraints match the object.
128
+ Here's how we could use that in our business logic:
300
129
 
301
130
  ```ruby
302
- contract = Stannum::Contract.new do
303
- constraint(type: 'examples.constraints.color') do |hsh|
304
- hsh[:color] == 'red'
305
- end
306
-
307
- constraint(type: 'examples.constraints.shape') do |hsh|
308
- hsh[:color] == 'circle'
309
- end
310
- end
131
+ customer = Customer.new(
132
+ id: 0,
133
+ email: 'user@example.com',
134
+ address: '123 Example St'
135
+ )
136
+ order = Order.new(id: 1)
311
137
 
312
- contract.matches?({ color: 'red', shape: 'circle' })
313
- #=> true
314
- contract.does_not_match?({ color: 'red', shape: 'circle' })
138
+ Order::Contract.matches?(order)
315
139
  #=> false
316
- contract.errors_for({ color: 'red', shape: 'square' }).map(&:type)
317
- #=> ['examples.constraints.color', 'examples.constraints.shape']
140
+ errors = Order::Contract.errors_for(order)
141
+ #=> an instance of Stannum::Errors
142
+ errors.summary
143
+ #=> "amount: is not a BigDecimal, customer: is nil or empty"
318
144
 
319
- contract.matches?({ color: 'red', shape: 'square' })
320
- #=> false
321
- contract.does_not_match?({ color: 'red', shape: 'square' })
322
- #=> false
323
- contract.errors_for({ color: 'red', shape: 'square' }).map(&:type)
324
- #=> ['examples.constraints.color']
145
+ order.amount = BigDecimal('0.0')
146
+ order.customer = customer
325
147
 
326
- contract.matches?({ color: 'blue', shape: 'square'})
327
- #=> false
328
- contract.does_not_match?({ color: 'blue', shape: 'square'})
148
+ Order::Contract.matches?(order)
329
149
  #=> true
330
150
  ```
331
151
 
332
- Note that for an object that partially matches the contract, both `#matches?` and `#does_not_match?` methods will return false. If you want to check whether **any** of the constraints do not match the object, use the `#matches?` method and apply the `!` boolean negation operator (or switch from an `if` to an `unless`).
333
-
334
- #### Constraining Properties
335
-
336
- Constraints can also define constraints on the *properties* of the matched object. This is a powerful feature for defining validations on objects and nested data structures. To define a property constraint, use the `property` macro in a contract constructor block, or use the `#add_property_constraint` method on an existing contract.
337
-
338
- ```ruby
339
- gadget_contract = Stannum::Contract.new do
340
- property :name, Stannum::Constraints::Presence.new
341
-
342
- property :name, Stannum::Constraints::Types::StringType.new
343
-
344
- property(:size, type: 'examples.constraints.size') do |size|
345
- %w[small medium large].include?(size)
346
- end
347
-
348
- property :manufacturer, Stannum::Contract.new do
349
- constraint Stannum::Constraints::Presence.new
350
-
351
- property :address, Stannum::Constraints::Presence.new
352
- end
353
- end
354
- ```
355
-
356
- There's a lot going on here, so let's break it down. First, we're defining constraints on the *properties* of the object, rather than on the object as a whole. In particular, note that we're setting multiple constraints on the `:name` property - an object will only match the contract if it's `#name` matches both of those constraints.
357
-
358
- We're also using some pre-defined constraints, rather than having to start from scratch. The `Presence` constraint validates that an object is not `nil` and not `#empty?`, while the `Types::StringType` constraint validates that the object is an instance of `String`. For a full list of pre-defined constraints, see [Built-In Constraints](#builtin-constraints) and [Contracts](#builtin-contracts), below. You can also define your own constraint classes and reference them in your contracts.
359
-
360
- Finally, note that the constraint for the `:manufacturer` property is itself a contract. We are asserting that the actual object has a non-`nil` `#manufacturer` property and that the manufacturer's `#address` is also non-`nil` (and not `#empty?`).
361
-
362
- ```ruby
363
- gadget = Gadget.new(manufacturer: Manufacturer.new)
364
- gadget_contract.matches?(gadget)
365
- #=> false
366
- gadget_contract.errors_for(gadget).map { |err| [err.path, err.type] }
367
- #=> [
368
- # [%i[name], 'stannum.constraints.absent'],
369
- # [%i[name], 'stannum.constraints.is_not_type'],
370
- # [%i[size], 'examples.constraints.size'],
371
- # [%i[manufacturer address], 'stannum.constraints.absent']
372
- # ]
373
- ```
374
-
375
- We've established that each error has a `#type`, which identifies which type of constraint failed to match the object. Here, we can see that each error also has a `#path` property, which represents the relative path of the property from the original matched object. For example, errors on the `gadget.name` property will have a path of `%i[name]`, while the error on the `gadget.manufacturer.address` will have a path of `%i[manufacturer address]`. A constraint without a property, i.e. on the matched object itself, will have a path of `[]`, an empty string.
152
+ If our contract `#matches?` the order, we proceed with the creation logic. Otherwise, we return an error message, possibly using the errors `#summary`.
376
153
 
377
- The errors for a property or nested contract can also be accessed using the `#[]` operator or the `#dig` method.
154
+ Now we move on to validating that an order is ready for billing. Our validation logic gets more complicated here: in addition to requiring a valid order (the same validations as above), we need to make sure that the billable amount is greater than zero.
378
155
 
379
- ```ruby
380
- gadget_contract.errors_for(gadget)[:manufacturer].map { |err| [err.path, err.type] }
381
- #=> [[%i[address], 'stannum.constraints.absent']]
382
-
383
- gadget_contract.errors_for(gadget).dig(:manufacturer, :address).map { |err| [err.path, err.type] }
384
- #=> [[[], 'stannum.constraints.absent']]
385
- ```
386
-
387
- Be careful when defining property constraints on a contract that might be matched against `nil` or an unknown object type - Ruby will raise a `NoMethodError` when trying to access the property. To avoid this, you can add a sanity constraint (see below) to ensure that the contract only validates the expected type of object.
388
-
389
- #### Sanity Constraints
390
-
391
- In some cases, before running through the full set of constraints in a contract, we want to run a quick sanity check to make sure the contract is even applicable to the object. By adding `sanity: true` when defining the constraint, you can mark a constraint as a sanity check.
392
-
393
- ```ruby
394
- gadget_contract.add_constraint(Stannum::Constraints::Type.new(Gadget), sanity: true)
395
- ```
396
-
397
- When matching an object, all of a contract's sanity constraints will be evaluated first. The remaining constraints will be matched against the object *only* if all of the sanity constraints match the object. This can be especially important if some of the constraints return nonsensical results or even raise exceptions when given an invalid object.
398
-
399
- ```ruby
400
- gadget_contract.matches?(nil)
401
- #=> false
402
- gadget_contract.errors_for(nil).map { |err| [err.path, err.type] }
403
- #=> [[[], 'stannum.constraints.is_not_type']]
404
- ```
405
-
406
- Likewise, when performing a negated match, the sanity constraints will be evaluated first, and the remaining constraints will be evaluated only if all of the sanity constraints match.
407
-
408
- #### Combining Contracts
409
-
410
- Stannum provides two mechanisms for composing contracts together. Each contract is a constraint, and so can be added to another contract (with or without a property or scope). This allows you to create and reuse validation logic simply by adding a contract as a constraint:
411
-
412
- ```ruby
413
- named_contract = Stannum::Contract.new do
414
- property :name, Stannum::Constraints::Presence.new
415
- end
416
-
417
- widget_contract = Stannum::Contract.new do
418
- constraint(Stannum::Constraints::Type.new(Widget))
419
-
420
- constraint(named_contract)
421
- end
422
-
423
- widget = Widget.new
424
- widget_contract.matches?(Widget.new)
425
- #=> false
426
- widget_contract.matches?(Widget.new(name: 'Whirlygig'))
427
- #=> true
428
- ```
429
-
430
- The second mechanism is contract *concatenation*. Under the hood, concatenation directly pulls in the constraints from a concatenated contract, rather than evaluating that contract on its own. This can be likened to inheriting methods from a superclass or an included Module.
156
+ One common approach is to add conditional validation to the `Order` class itself. For example, defining a `#status` attribute asserting that the `#amount` is greater than zero if the status matches a value. However, as new cases and conditions are added, this approach quickly becomes difficult to read and reason about. Instead, we're going to define a validator object.
431
157
 
432
158
  ```ruby
433
- gadget_contract = Stannum::Contract.new do
434
- constraint(Stannum::Constraints::Type.new(Gadget))
435
-
436
- concat(named_contract)
437
- end
438
- ```
439
-
440
- Using concatenation, you have finer control over the constraints that are added to the contract. Specifically, when defining a contract you can mark certain constraints as excluded from concatenation by adding the `concatenatable: false` keyword to `#add_constraint`. As an example, this can be useful if you want to inherit constraints about the properties of an object, but not potentially conflicting constraints about the object's type.
159
+ module Orders
160
+ module Contracts
161
+ IS_BILLABLE = Stannum::Contract.new do
162
+ concat(Order::Contract)
441
163
 
442
- <a id="array-contracts"></a>
443
-
444
- #### Array Contracts
445
-
446
- By default, a `Stannum::Contract` accesses an object's properties as method calls, using the `.` dot notation. When validating `Array`s and `Hash`es, this approach is less useful. Therefore, Stannum provides special contracts for operating on data structures.
447
-
448
- A `Stannum::Contracts::ArrayContract` is used for validating sequential data, using the `#[]` method to access indexed values.
449
-
450
- ```ruby
451
- class BaseballContract < Stannum::Contracts::ArrayContract
452
- def initialize
453
- super do
454
- item { |actual| actual == 'Who' }
455
- item { |actual| actual == 'What' }
456
- item { |actual| actual == "I Don't Know" }
164
+ property :amount,
165
+ message: 'must be greater than zero',
166
+ type: 'orders.constraints.greater_than_zero' \
167
+ do |value|
168
+ value.is_a?(Numeric) && value > 0
169
+ end
170
+ property :payment, Stannum::Constraints::Types::NilType.new
457
171
  end
458
172
  end
459
173
  end
460
-
461
- contract = BaseballContract.new
462
- contract.matches?(nil)
463
- #=> false
464
- contract.errors_for(nil).map { |err| [err.path, err.type] }
465
- #=> [[[], 'stannum.constraints.is_not_type']]
466
-
467
- array = %w[Who What]
468
- contract.matches?(array)
469
- #=> false
470
- contract.errors_for(array).map { |err| [err.path, err.type] }
471
- #=> [[[2], 'stannum.constraints.invalid']]
472
-
473
- array = ['Who', 'What', "I Don't Know"]
474
- contract.matches?(array)
475
- #=> true
476
-
477
- array = ['Who', 'What', "I Don't Know", 'Tomorrow']
478
- contract.matches?(array)
479
- #=> false
480
- contract.errors_for(array).map { |err| [err.path, err.type] }
481
- #=> [[[3], 'stannum.constraints.tuples.extra_items']]
482
- ```
483
-
484
- Here, we are defining an ArrayContract using the `#item` macro, which defines an item constraint for each successive item in the array. We can also define a property constraint using the `#property` macro, using an Integer as the property to validate. This would allow us to add multiple constraints for the value at a given index, although the recommended approach is to use a nested contract.
485
-
486
- When matching an object, the contract first validates that the object is an instance of `Array`. If not, it will immedidately fail matching and the remaining constraints will not be matched against the object. If the object is an an array, then the contract checks each of the defined constraints against the value of the array at that index.
487
-
488
- Finally, the constraint checks for the highest index expected by an item constraint. If the array contains additional items after this index, those items will fail with a type of `"extra_items"`. To allow additional items instead, pass `allow_extra_items: true` to the `ArrayContract` constructor.
489
-
490
- ```ruby
491
- contract = BaseballContract.new(allow_extra_items: true)
492
- contract.matches?(['Who', 'What', "I Don't Know", 'Tomorrow'])
493
- #=> true
494
174
  ```
495
175
 
496
- An `ArrayContract` will first validate that the object is an instance of `Array`. For validating Array-like objects that access indexed data using the `#[]` method, you can instead use a `Stannum::Contracts::TupleContract`.
497
-
498
- <a id="hash-contracts"></a>
499
-
500
- #### Hash Contracts
501
-
502
- A `Stannum::Contracts::HashContract` is used for validating key-value data, using the `#[]` method to access values by key.
503
-
504
- ```ruby
505
- class ResponseContract < Stannum::Contracts::HashContract
506
- def initialize
507
- super do
508
- key :status, Stannum::Constraints::Types::IntegerType.new
509
-
510
- key :json,
511
- Stannum::Contracts::HashContract.new(allow_extra_keys: true) do
512
- key :ok, Stannum::Constraints::Boolean.new
513
- end
514
-
515
- key :signature, Stannum::Constraints::Presence.new
516
- end
517
- end
518
- end
519
-
520
- contract = ResponseContract.new
521
- contract.matches?(nil)
522
- #=> false
523
- contract.errors_for(nil).map { |err| [err.path, err.type] }
524
- #=> [[[], 'stannum.constraints.is_not_type']]
176
+ Our validator is an instance of `Stannum::Contract`, and our first step is to `concat` the existing `Order::Contract`. This means that all of the constraints in the `Order::Contract` will also be applied when matching an order with the `IS_BILLABLE` contract. This means we don't need to duplicate our existing constraints.
525
177
 
526
- response = { status: 500, json: {} }
527
- contract.matches?(response)
528
- #=> false
529
- contract.errors_for(response).map { |err| [err.path, err.type] }
530
- #=> [
531
- # %i[json ok], 'stannum.constraints.is_not_boolean'],
532
- # %i[signature], 'stannum.constraints.absent'
533
- # ]
534
-
535
- response = { status: 200, json: { ok: true }, signature: '12345' }
536
- contract.matches?(response)
537
- #=> true
178
+ Second, we are adding a custom `constraint` on the `#amount` attribute. Notice that the first check inside the block checks that the value is `Numeric`; otherwise, comparing a `nil` value would raise an exception, rather than failing the validation. We are also defining a custom `message` and `type` for the constraint. The `message` is intended to be a human-readable representation of the error, while the `type` is intended for machines.
538
179
 
539
- response = { status: 200, json: { ok: true }, signature: '12345', role: 'admin' }
540
- #=> false
541
- contract.errors_for(response).map { |err| [err.path, err.type] }
542
- #=> [[%i[role], 'stannum.constraints.hashes.extra_keys']]
543
- ```
180
+ Finally, we validate that the `#payment` association is nil, since we don't want to accidentally bill the same order twice. Here we can see why a validator object is so powerful - we obviously can't add this kind of constraint directly to `Order`, since orders later in the workflow will clearly not match. However, since our `IS_BILLABLE` contract applies only to this specific context, we can make the validation logic as specific as we want.
544
181
 
545
- We define a HashContract using the `#key` macro, which defines a key-value constraint for the specified value in the hash. When validating a Hash, the value at each key must match the given constraint. The contract will also fail if there are additional keys without a corresponding constraint. To allow additional keys instead, pass `allow_extra_keys: true` to the `HashContract` constructor.
182
+ Our final step is to ship the order to the customer. Again, to determine if the order is ready to be shipped, we define a validator object:
546
183
 
547
184
  ```ruby
548
- contract = ResponseContract.new(allow_extra_keys: true)
549
- response = { status: 200, json: { ok: true }, signature: '12345', role: 'admin' }
550
- contract.matches?(response)
551
- #=> true
552
- ```
553
-
554
- A `HashContract` will first validate that the object is an instance of `Hash`. For validating Hash-like objects that access key-value data using the `#[]` method, you can instead use a `Stannum::Contracts::MapContract`.
185
+ module Orders
186
+ module Contracts
187
+ IS_SHIPPABLE = Stannum::Contract.new do
188
+ concat(Order::Contract)
555
189
 
556
- #### Contract Subclasses
190
+ property :payment, Stannum::Constraints::Presence.new
557
191
 
558
- For most use cases, defining a custom contract subclass will involve adding default constraints for the contact. Stannum provides two easy methods for doing so. First, you can leverage the default behavior by passing a block to `super` in the contract constructor. This will allow you to take advantage of the `constraint`, `property`, and other macros.
559
-
560
- ```ruby
561
- class GizmoContract
562
- def initialize(**_options)
563
- super do
564
- constraint Stannum::Constraints::Type.new(Gizmo), sanity: true
565
-
566
- property :complexity, Stannum::Constraints::Presence.new
192
+ property :customer, Stannum::Contract.new {
193
+ property :address, Stannum::Constraints::Presence.new
194
+ }
567
195
  end
568
196
  end
569
197
  end
570
198
  ```
571
199
 
572
- As an alternative, `Stannum::Contract` defines a private `#define_constraints` method that is used to initialize any constraints.
573
-
574
- ```ruby
575
- class WhirlygigContract
576
- private
577
-
578
- def define_constraints
579
- super
580
-
581
- constraint Stannum::Constraints::Type.new(Whirlygig), sanity: true
582
-
583
- property :rotation_speed, Stannum::Constraints::Types::Float.new
584
- end
585
- end
586
- ```
587
-
588
- <a id="errors"></a>
589
-
590
- ### Errors
591
-
592
- ```ruby
593
- require 'stannum/errors'
594
- ```
200
+ Again, we define a custom `Stannum::Contract` and `concat` the existing `Order::Contract`, and we add a constraint that the `#payment` association needs to be populated - we don't want to ship an order that hasn't been paid for yet. Next, we define a nested contract to assert that the `#customer` association has a present `#address` attribute. You can define complex validation logic easily by composing together multiple constraints and contracts.
595
201
 
596
- When a constraint or contract fails to match an object, Stannum can return the reasons for that failure in the form of an errors object. Specifically, a `Stannum::Errors` object is returned by calling `#errors_for` or `#negated_errors-for` with a failing object, or as part of the result of calling `#match` or `#negated_match`.
202
+ Here is how we would use the `IS_BILLABLE` and `IS_SHIPPABLE` contracts in our business logic:
597
203
 
598
204
  ```ruby
599
- contract.matches?(nil)
600
- #=> false
601
- contract.errors_for(nil)
602
- #=> an instance of Stannum::Errors
603
- status, errors = contract.match(nil)
604
- status
605
- #=> false
606
- errors
607
- #=> an instance of Stannum::Errors
608
- ```
609
-
610
- A `Stannum::Errors` object is an `Enumerable` collection, while each error is a `Hash` with the following properties:
611
-
612
- - `#type`: A unique value that defines what kind of error was encountered. Each error's type should be a namespaced `String`, e.g. `"stannum.constraints.invalid"`.
613
- - `#data`: A `Hash` of additional data about the error. For example, a failed type validation will include the expected type for the value; a failed range validation might include the minimum and maximum allowable values.
614
- - `#path`: The path of the error relative to the top-level object that was validated. The path is always an `Array`, and each item in the array is either an `Integer` or a non-empty `Symbol`. Some examples:
615
- - An error on the object itself will have an empty path, `[]`.
616
- - An error on the item at index `3`of an array would have a path of `[3]`.
617
- - An error on the `#name` property of an object would have a path of `[:name]`.
618
- - An error on the address of the first manufacturer would have a path of `[:manufacturers, 3, :address]`.
619
- - `#message`: A human-readable description of the error. Error messages are not generated by default; either specify a message when defining the constraint, or call `#with_messages` to generate the error messages based on the error types and data. See [Generating Messages](errors-generating-messages), below.
620
-
621
- The simplest way to access the errors in a `Stannum::Errors` object is via the `#each` method, which will yield each error in the collection to the given block. Because each `Stannum::Errors` is enumerable, you can use the standard `Enumerable` methods such as `#map`, `#reduce`, `#select`, and so on. You can also use `#count` to return the number of errors, or `#empty?` to check if there are any errors in the collection.
205
+ customer = Customer.new(
206
+ id: 0,
207
+ email: 'user@example.com',
208
+ address: '123 Example St'
209
+ )
210
+ order = Order.new(id: 1, customer:)
622
211
 
623
- ```ruby
624
- errors.count
625
- #=> 3
626
- errors.empty?
212
+ Orders::Contracts::IS_BILLABLE.matches?(order)
627
213
  #=> false
628
- errors.first
629
- #=> {
630
- # data: {},
631
- # message: nil,
632
- # path: [:name],
633
- # type: 'stannum.constraints.invalid'
634
- # }
635
- errors.map(&:type)
636
- #=> [
637
- # 'stannum.constraints.invalid',
638
- # 'stannum.constraints.absent',
639
- # 'stannum.constraints.is_not_type'
640
- # ]
641
- ```
642
-
643
- Usually, an errors object is generated automatically by a constraint or contract with its errors already defined. If you want to add custom errors to an errors object, use the `#add` method, which takes the error `type` as one required argument. You can also specify the `message` keyword, which sets the message of the error. Finally, any additional keywords are added to the error `data`.
214
+ errors = Orders::Contracts::IS_BILLABLE.errors_for(order)
215
+ errors.summary
216
+ #=> "amount: is not a BigDecimal, amount: must be greater than zero"
644
217
 
645
- ```ruby
646
- errors = Stannum::Errors.new
647
- errors.count
648
- #=> 0
649
- errors.empty?
218
+ order.amount = BigDecimal('100.0')
219
+ Orders::Contracts::IS_BILLABLE.matches?(order)
650
220
  #=> true
651
221
 
652
- errors.add('example.constraints.out_of_range', message: 'out of range', min: 0, max: 10)
653
- #=> the errors object
654
- errors.count
655
- #=> 1
656
- errors.empty?
222
+ Orders::Contracts::IS_SHIPPABLE.matches?(order)
657
223
  #=> false
658
- errors.first
659
- #=> {
660
- # data: { min: 0, max: 10 },
661
- # message: 'out of range',
662
- # path: [],
663
- # type: 'example.constraints.out_of_range'
664
- # }
665
- ```
666
-
667
- Conveniently, `#add` returns the errors object itself, so you can chain together multiple `#add` calls.
668
-
669
- #### Nested Errors
670
-
671
- To represent the properties of an object or the values in a data structure, `Stannum::Errors` can be nested together. Nested error objects are accessed using the `#[]` operator.
672
-
673
- ```ruby
674
- errors = Stannum::Errors.new
675
- errors[:manufacturers][0][:address].add('stannum.constraints.invalid')
676
- errors[:manufacturers][0][:address]
677
- #=> an instance of Stannum::Errors
678
- errors[:manufacturers][0][:address].count
679
- #=> 1
680
- errors[:manufacturers][0][:address].first
681
- #=> {
682
- # data: {},
683
- # message: nil,
684
- # path: [],
685
- # type: 'stannum.constraints.invalid'
686
- # }
687
-
688
- errors.count
689
- #=> 1
690
- errors.first
691
- #=> {
692
- # data: {},
693
- # message: nil,
694
- # path: [:manufacturers, 0, :address],
695
- # type: 'stannum.constraints.invalid'
696
- # }
697
- ```
698
-
699
- You can also use the `#dig` method to access nested errors:
700
-
701
- ```ruby
702
- errors.dig(:manufacturers, 0, :address).first
703
- #=> {
704
- # data: {},
705
- # message: nil,
706
- # path: [],
707
- # type: 'stannum.constraints.invalid'
708
- # }
709
- ```
710
-
711
- <a id="errors-generating-messages"></a>
712
-
713
- #### Generating Messages
714
-
715
- By default, errors objects do not generate messages. `Stannum::Errors` defines the `#with_messages` method to generate messages for a given errors object. If the `:force` keyword is set to true, then `#with_messages` will overwrite any messages that are already set on an error, whether from a constraint or generated by a different strategy.
224
+ errors = Orders::Contracts::IS_SHIPPABLE.errors_for(order)
225
+ errors.summary
226
+ #=> "payment: is nil or empty"
716
227
 
717
- ```ruby
718
- errors.first.message
719
- #=> nil
720
-
721
- errors = errors.with_messages.first.message
722
- errors.first.message
723
- #=> 'is invalid'
228
+ order.payment = Payment.new(id: 2, amount: order.amount)
229
+ Orders::Contracts::IS_SHIPPABLE.matches?(order)
230
+ #=> true
724
231
  ```
725
232
 
726
- Stannum uses the strategy pattern to determine how error messages are generated. You can pass the `strategy:` keyword to `#with_messages` to force Stannum to use the specified strategy, or set the `Stannum::Messages.strategy` property to define the default for your application. The default strategy for Stannum uses an I18n-like configuration file to define messages based on the type and optionally the data for each error.
727
-
728
- <a id="entities"></a>
233
+ As we define our `BillOrder` and `ShipOrder` classes, we will be able to use our `IS_BILLABLE` and `IS_SHIPPABLE` contracts to quickly identify orders that are invalid for that context.
729
234
 
730
- ### Entities
235
+ ### Validating Other Data
731
236
 
732
- While constraints and contracts are used to validate data, entities are used to define and structure that data. Each `Stannum::Entity` contains a specific set of attributes, and each attribute has a type definition that is a `Class` or `Module` or the name of a Class or Module.
733
-
734
- Entities are defined by creating a new class and including `Stannum::Entity`:
237
+ In addition to using them with entities, `Stannum` constraints and contracts can be used to validate almost any sort of data. For example, consider our ordering workflow. Perhaps we make a API call to a company that actually ships the order to the customer. We want to ensure that the API response contains the expected data. We can define a contract to validate the returned JSON body:
735
238
 
736
239
  ```ruby
737
- require 'stannum'
240
+ SUCCESS_RESPONSE_CONTRACT = Stannum::Contract.new do
241
+ property :status, Stannum::Constraints::Identity.new(200)
738
242
 
739
- class Gadget
740
- include Stannum::Entity
243
+ property :body, Stannum::Contracts::HashContract.new {
244
+ key 'ok', Stannum::Constraints::Identity.new(true)
741
245
 
742
- attribute :name, String
743
- attribute :description, String, optional: true
744
- attribute :quantity, Integer, default: 0
246
+ key 'shipping_confirmation',
247
+ Stannum::Constraint.new(
248
+ message: 'be a string with length 24',
249
+ type: 'orders.constraints.valid_shipping_confirmation'
250
+ ) { |value|
251
+ value.is_a?(String) && value.size == 24
252
+ }
253
+ }
745
254
  end
746
-
747
- gadget = Gadget.new(name: 'Self-Sealing Stem Bolt')
748
- gadget.name
749
- #=> 'Self-Sealing Stem Bolt'
750
- gadget.description
751
- #=> nil
752
- gadget.attributes
753
- #=> {
754
- # name: 'Self-Sealing Stem Bolt',
755
- # description: nil,
756
- # quantity: 0
757
- # }
758
-
759
- gadget.quantity = 10
760
- gadget.quantity
761
- #=> 10
762
-
763
- gadget[:description] = 'No one is sure what a self-sealing stem bolt is.'
764
- gadget[:description]
765
- #=> 'No one is sure what a self-sealing stem bolt is.'
766
255
  ```
767
256
 
768
- Our `Gadget` class has three attributes: `#name`, `#description`, and `#quantity`, which we are defining using the `.attribute` class method.
769
-
770
- We can initialize a gadget with values by passing the desired attributes to `.new`. We can read or write the attributes using either dot `.` notation or `#[]` notation. Finally, we can access all of a entity's attributes and values using the `#attributes` method.
771
-
772
- `Stannum::Entity` defines a number of helper methods for interacting with a entity's attributes:
773
-
774
- - `#[](attribute)`: Returns the value of the given attribute.
775
- - `#[]=(attribute, value)`: Writes the given value to the given attribute.
776
- - `#assign_attributes(values)`: Updates the entity's attributes using the given values. If an attribute is not given, that value is unchanged.
777
- - `#attributes`: Returns a hash containing the attribute keys and values.
778
- - `#attributes=(values)`: Sets the entity's attributes to the given values. If an attribute is not given, that attribute is set to `nil`.
779
-
780
- For all of the above methods, if a given attribute is invalid or the attribute is not defined on the entity, an `ArgumentError` will be raised.
781
-
782
- #### Attributes
783
-
784
- A entity's attributes are defined using the `.attribute` class method, and can be accessed and enumerated using the `.attributes` class method on the entity class or via the `::Attributes` constant. Internally, each attribute is represented by a `Stannum::Attribute` instance, which stores the attribute's `:name`, `:type`, and `:attributes`.
785
-
786
- ```ruby
787
- Gadget::Attributes
788
- #=> an instance of Stannum::Schema
789
- Gadget.attributes
790
- #=> an instance of Stannum::Schema
791
- Gadget.attributes.count
792
- #=> 3
793
- Gadget.attributes.keys
794
- #=> [:name, :description, :quantity]
795
- Gadget.attributes[:name]
796
- #=> an instance of Stannum::Attribute
797
- Gadget.attributes[:quantity].options
798
- #=> { default: 0, required: true }
799
- ```
800
-
801
- ##### Default Values
802
-
803
- Entities can define default values for attributes by passing a `:default` value to the `.attribute` call.
804
-
805
- ```ruby
806
- class LightsCounter
807
- include Stannum::Entity
808
-
809
- attribute :count, Integer, default: 4
810
- end
811
-
812
- LightsCounter.new.count
813
- #=> 4
814
- ```
815
-
816
- ##### Optional Attributes
817
-
818
- Entity classes can also mark attributes as `optional`. When an entity is validated (see [Validation](#entities-validation), below), optional attributes will pass with a value of `nil`.
819
-
820
- ```ruby
821
- class WhereWeAreGoing
822
- include Stannum::Entity
823
-
824
- attribute :roads, Object, optional: true
825
- end
826
- ```
827
-
828
- `Stannum` supports both `:optional` and `:required` as keys. Passing either `optional: true` or `required: false` will mark the attribute as optional. Attributes are required by default.
829
-
830
- <a id="entities-validation"></a>
831
-
832
- #### Validation
833
-
834
- Each `Stannum::Entity` automatically generates a contract that can be used to validate instances of the entity class. The contract can be accessed using the `.contract` class method or via the `::Contract` constant.
835
-
836
- ```ruby
837
- class Gadget
838
- attribute :name, String
839
- attribute :description, String, optional: true
840
- attribute :quantity, Integer, default: 0
841
- end
842
-
843
- Gadget::Contract
844
- #=> an instance of Stannum::Contract
845
- Gadget.contract
846
- #=> an instance of Stannum::Contract
847
-
848
- gadget = Gadget.new
849
- Gadget.contract.matches?(gadget)
850
- #=> false
851
- Gadget.contract.errors_for(gadget)
852
- #=> [
853
- # {
854
- # data: { type: String },
855
- # message: nil,
856
- # path: [:name],
857
- # type: 'stannum.constraints.is_not_type'
858
- # }
859
- # ]
860
-
861
- gadget = Gadget.new(name: 'Self-Sealing Stem Bolt')
862
- Gadget.contract.matches?(gadget)
863
- #=> true
864
- ```
865
-
866
- You can also define additional constraints using the `.constraint` class method.
867
-
868
- ```ruby
869
- class Gadget
870
- constraint :name, Stannum::Constraints::Presence.new
871
-
872
- constraint :quantity do |qty|
873
- qty >= 0
874
- end
875
- end
876
-
877
- gadget = Gadget.new(name: '')
878
- Gadget.contract.matches?(gadget)
879
- #=> false
880
- Gadget.contract.errors_for(gadget)
881
- #=> [
882
- # {
883
- # data: {},
884
- # message: nil,
885
- # path: [:name],
886
- # type: 'stannum.constraints.absent'
887
- # }
888
- # ]
889
- ```
890
-
891
- The `.constraint` class method takes either an instance of `Stannum::Constraint` or a block. If given an attribute name, the constraint will be matched against the value of that attribute; otherwise, the constraint will be matched against the object itself.
892
-
893
- <a id="builtin-constraints"></a>
894
-
895
- ### Built-In Constraints
896
-
897
- Stannum defines a set of built-in constraints that can be used in any project.
898
-
899
- **Absence Constraint**
900
-
901
- The inverse of a [Presence constraint](#builtin-constraints-presence). Matches `nil`, and objects that both respond to `#empty?` and for whom `#empty?` returns true, such as empty `String`s, `Array`s and `Hash`es.
902
-
903
- ```ruby
904
- constraint = Stannum::Constraints::Absence.new
905
-
906
- constraint.matches?(nil)
907
- #=> true
908
- constraint.matches?('')
909
- #=> true
910
- constraint.matches?('Greetings, programs!')
911
- #=> false
912
- constraint.matches?(Object.new)
913
- #=> false
914
- ```
915
-
916
- **Anything Constraint**
917
-
918
- Matches any object, even `nil`.
919
-
920
- ```ruby
921
- constraint = Stannum::Constraints::Anything.new
922
-
923
- constraint.matches?(nil)
924
- #=> true
925
- constraint.matches?(Object.new)
926
- #=> true
927
- constraint.matches?('Hello, world')
928
- #=> true
929
- ```
930
-
931
- **Boolean Constraint**
932
-
933
- Matches `true` and `false`.
934
-
935
- ```ruby
936
- constraint = Stannum::Constraints::Boolean.new
937
-
938
- constraint.matches?(nil)
939
- #=> false
940
- constraint.matches?(Object.new)
941
- #=> false
942
- constraint.matches?(false)
943
- #=> true
944
- constraint.matches?(true)
945
- #=> true
946
- ```
947
-
948
- **Enum Constraint**
949
-
950
- Matches any the specified values.
951
-
952
- ```ruby
953
- constraint = Stannum::Constraints::Enum.new('red', 'blue', 'green')
954
-
955
- constraint.matches?(nil)
956
- #=> false
957
- constraint.matches?('purple')
958
- #=> false
959
- constraint.matches?('red')
960
- #=> true
961
- constraint.matches?('green')
962
- #=> true
963
- ```
964
-
965
- **Equality Constraint**
966
-
967
- Matches any object equal to the given object.
968
-
969
- ```ruby
970
- value = 'Greetings, programs!'
971
- constraint = Stannum::Constraints::Equality.new(value)
972
-
973
- constraint.matches?(nil)
974
- #=> false
975
- constraint.matches?(value.dup)
976
- #=> true
977
- constraint.matches?(value)
978
- #=> true
979
- ```
980
-
981
- **Identity Constraint**
982
-
983
- Matches the given object.
984
-
985
- ```ruby
986
- value = 'Greetings, starfighter!'
987
- constraint = Stannum::Constraints::Identity.new(value)
988
-
989
- constraint.matches?(nil)
990
- #=> false
991
- constraint.matches?(value.dup)
992
- #=> false
993
- constraint.matches?(value)
994
- #=> true
995
- ```
996
-
997
- **Nothing Constraint**
998
-
999
- Does not match any objects.
1000
-
1001
- ```ruby
1002
- constraint = Stannum::Constraints::Nothing.new
1003
-
1004
- constraint.matches?(nil)
1005
- #=> false
1006
- constraint.matches?(Object.new)
1007
- #=> false
1008
- constraint.matches?('Hello, world')
1009
- #=> false
1010
- ```
1011
-
1012
- <a id="builtin-constraints-presence"></a>
1013
-
1014
- **Presence Constraint**
1015
-
1016
- Matches objects that are not `nil`, and that either do not respond to `#empty?` or for whom `#empty?` returns false.
1017
-
1018
- ```ruby
1019
- constraint = Stannum::Constraints::Presence.new
1020
-
1021
- constraint.matches?(nil)
1022
- #=> false
1023
- constraint.matches?('')
1024
- #=> false
1025
- constraint.matches?('Greetings, programs!')
1026
- #=> true
1027
- constraint.matches?(Object.new)
1028
- #=> true
1029
- ```
1030
-
1031
- **Signature Constraint**
1032
-
1033
- Matches if the object responds to all of the specified methods.
1034
-
1035
- ```ruby
1036
- constraint = Stannum::Constraints::Signature.new(:[], :keys)
1037
-
1038
- constraint.matches?(nil)
1039
- #=> false
1040
- constraint.matches?([])
1041
- #=> false
1042
- constraint.matches?({})
1043
- #=> true
1044
- ```
1045
-
1046
- <a id="builtin-constraints-type"></a>
1047
-
1048
- **Type Constraint**
1049
-
1050
- Matches if the specified type is an ancestor of the object.
1051
-
1052
- ```ruby
1053
- constraint = Stannum::Constraints::Type.new(StandardError)
1054
-
1055
- constraint.matches?(nil)
1056
- #=> false
1057
- constraint.matches?(Object.new)
1058
- #=> false
1059
- constraint.matches?(StandardError.new)
1060
- #=> true
1061
- constraint.matches?(ArgumentError.new)
1062
- #=> true
1063
- ```
1064
-
1065
- Type constraints can be `optional` by passing either `optional: true` or `required: false` to the constructor. An optional type constraint will also accept `nil` as a value.
1066
-
1067
- ```ruby
1068
- constraint = Stannum::Constraints::Type.new(String, optional: true)
1069
-
1070
- constraint.matches?(nil)
1071
- #=> true
1072
- constraint.matches?(Object.new)
1073
- #=> false
1074
- constraint.matches?('a String')
1075
- #=> true
1076
- ```
1077
-
1078
- **Union Constraint**
1079
-
1080
- Matches if the object matches any of the given constraints.
1081
-
1082
- ```ruby
1083
- constraint = Stannum::Constraints::Union.new(
1084
- Stannum::Constraints::Type.new(String),
1085
- Stannum::Constraints::Type.new(Symbol)
1086
- )
1087
-
1088
- constraint.matches?(nil)
1089
- #=> false
1090
- constraint.matches?(Object.new)
1091
- #=> false
1092
- constraint.matches?('a String')
1093
- #=> true
1094
- constraint.matches?(:a_symbol)
1095
- #=> true
1096
- ```
1097
-
1098
- #### Property Constraints
1099
-
1100
- Property constraints match against the properties of the object.
1101
-
1102
- **Do Not Match Property Constraint**
1103
-
1104
- Matches if none of the values of the given properties are equal to the value of the expected property.
1105
-
1106
- ```ruby
1107
- UpdatePassword = Struct.new(:old_password, :new_password)
1108
- constraint = Stannum::Constraints::Properties::DoNotMatchProperty.new(
1109
- :old_password,
1110
- :new_password
1111
- )
1112
-
1113
- params = UpdatePassword.new('tronlives', 'ifightfortheusers')
1114
- constraint.matches?(params)
1115
- #=> true
1116
-
1117
- params = UpdatePassword.new('tronlives', 'tronlives')
1118
- constraint.matches?(params)
1119
- #=> false
1120
- constraint.errors_for(params)
1121
- #=> [
1122
- {
1123
- path: [:confirmation],
1124
- type: 'stannum.constraints.is_equal_to',
1125
- data: { expected: '[FILTERED]', actual: '[FILTERED]' }
1126
- }
1127
- ]
1128
- ```
1129
-
1130
- **Match Property Constraint**
1131
-
1132
- Matches if all the values of the given properties are equal to the value of the expected property.
1133
-
1134
- ```ruby
1135
- ConfirmPassword = Struct.new(:password, :confirmation)
1136
- constraint = Stannum::Constraints::Properties::MatchProperty.new(
1137
- :password,
1138
- :confirmation
1139
- )
1140
-
1141
- params = ConfirmPassword.new('tronlives', 'ifightfortheusers')
1142
- constraint.matches?(params)
1143
- #=> false
1144
- constraint.errors_for(params)
1145
- #=> [
1146
- {
1147
- path: [:confirmation],
1148
- type: 'stannum.constraints.is_not_equal_to',
1149
- data: { expected: '[FILTERED]', actual: '[FILTERED]' }
1150
- }
1151
- ]
1152
-
1153
- params = ConfirmPassword.new('tronlives', 'tronlives')
1154
- constraint.matches?(params)
1155
- #=> true
1156
- ```
1157
-
1158
- #### Type Constraints
1159
-
1160
- Stannum also defines a set of built-in type constraints. Unless otherwise noted, these are identical to a [Type Constraint](#builtin-constraints-type) with the given Class.
1161
-
1162
- ```ruby
1163
- constraint = Stannum::Constraints::Types::StringType.new
1164
-
1165
- constraint.matches?(nil)
1166
- #=> false
1167
- constraint.matches?(Object.new)
1168
- #=> false
1169
- constraint.matches?('a String')
1170
- #=> true
1171
- ```
1172
-
1173
- The following type constraints are defined:
1174
-
1175
- - **ArrayType**
1176
- - **BigDecimalType**
1177
- - **DateTimeType**
1178
- - **DateType**
1179
- - **FloatType**
1180
- - **HashType**
1181
- - **IntegerType**
1182
- - **NilType**
1183
- - **ProcType**
1184
- - **StringType**
1185
- - **SymbolType**
1186
- - **TimeType**
1187
-
1188
- In addition, the following type constraints have additional options or behavior.
1189
-
1190
- **ArrayType Constraint**
1191
-
1192
- You can specify an `item_type` for an `ArrayType` constraint. An object will only match if the object is an `Array` and all of the array's items are of the specified type or match the given constraint.
1193
-
1194
- ```ruby
1195
- constraint = Stannum::Constraints::Types::ArrayType.new(item_type: String)
1196
-
1197
- constraint.matches?(nil)
1198
- #=> false
1199
- constraint.matches?([])
1200
- #=> true
1201
- constraint.matches?([1, 2, 3])
1202
- #=> false
1203
- constraint.matches?(['uno', 'dos', 'tres'])
1204
- #=> true
1205
- ```
1206
-
1207
- You can also specify whether the constraint allows an empty Array by setting the `allow_empty: false` keyword in the constructor. If `false`, the constraint will only match arrays with one or more items. The default value is `true`.
1208
-
1209
- ```ruby
1210
- constraint = Stannum::Constraints::Types::ArrayType.new(allow_empty: false)
1211
-
1212
- constraint.matches?(nil)
1213
- #=> false
1214
- constraint.matches?([])
1215
- #=> false
1216
- constraint.matches?([1, 2, 3])
1217
- #=> true
1218
- ```
1219
-
1220
- **HashType Constraint**
1221
-
1222
- You can specify a `key_type` and/or a `value_type` for a `HashType` constraint. An object will only match if the object is a `Hash`, all of the hash's keys and/or values are of the specified type or match the given constraint.
1223
-
1224
- ```ruby
1225
- constraint = Stannum::Constraints::Types::HashType.new(key_type: String, value_type: Integer)
1226
-
1227
- constraint.matches?(nil)
1228
- #=> false
1229
- constraint.matches?({})
1230
- #=> true
1231
- constraint.matches?({ ichi: 1 })
1232
- #=> false
1233
- constraint.matches?({ 'ichi' => 'one' })
1234
- #=> false
1235
- constraint.matches?({ 'ichi' => 1 })
1236
- ```
1237
-
1238
- You can also specify whether the constraint allows an empty Hash by setting the `allow_empty: false` keyword in the constructor. If `false`, the constraint will only match hashes with one or more keys. The default value is `true`.
1239
-
1240
- ```ruby
1241
- constraint = Stannum::Constraints::Types::HashType.new(allow_empty: false
1242
-
1243
- constraint.matches?(nil)
1244
- #=> false
1245
- constraint.matches?({})
1246
- #=> false
1247
- constraint.matches?({ ichi: 1 })
1248
- #=> true
1249
- ```
1250
-
1251
- There are predefined constraints for matching `Hash`es with common key types:
1252
-
1253
- - **HashWithIndifferentKeys:** Matches keys that are either `String`s or `Symbol`s and not empty.
1254
- - **HashWithStringKeys:** Matches keys that are `String`s.
1255
- - **HashWithSymbolKeys:** Matches keys that are `Symbol`s.
1256
-
1257
- <a id="signature-constraints"></a>
1258
-
1259
- #### Signature Constraints
1260
-
1261
- Stannum provides a small number of built-in signature constraints.
1262
-
1263
- ```ruby
1264
- constraint = Stannum::Constraints::Signatures::Map.new
1265
-
1266
- constraint.matches?(nil)
1267
- #=> false
1268
- constraint.matches?([])
1269
- #=> false
1270
- constraint.matches?({})
1271
- #=> true
1272
- ```
1273
-
1274
- - **Map:** Matches objects that behave like a `Hash`. Specifically, objects responding to `#[]`, `#each`, and `#keys`.
1275
- - **Tuple:** Matches objects that behave like an `Array`. Specifically, objects responding to `#[]`, `#each`, and `#size`.
1276
-
1277
- <a id="builtin-contracts"></a>
1278
-
1279
- ### Built-In Contracts
1280
-
1281
- Stannum defines some pre-defined contracts.
1282
-
1283
- **Array Contract**
1284
-
1285
- Matches an instance of `Array` and defines the `.item` class method to add constraints on the array items. See also [Array Contracts](#array-contracts), above.
1286
-
1287
- ```ruby
1288
- class BaseballContract < Stannum::Contracts::ArrayContract
1289
- def initialize
1290
- super do
1291
- item { |actual| actual == 'Who' }
1292
- item { |actual| actual == 'What' }
1293
- item { |actual| actual == "I Don't Know" }
1294
- end
1295
- end
1296
- end
1297
- ```
1298
-
1299
- **Hash Contract**
1300
-
1301
- Matches an instance of `Hash` and defines the `.key` class method to add constraints on the hash keys and values. See also [Hash Contracts](#hash-contracts), above.
1302
-
1303
- ```ruby
1304
- class ResponseContract < Stannum::Contracts::HashContract
1305
- def initialize
1306
- super do
1307
- key :status, Stannum::Constraints::Types::IntegerType.new
1308
-
1309
- key :json,
1310
- Stannum::Contracts::HashContract.new(allow_extra_keys: true) do
1311
- key :ok, Stannum::Constraints::Boolean.new
1312
- end
1313
-
1314
- key :signature, Stannum::Constraints::Presence.new
1315
- end
1316
- end
1317
- end
1318
- ```
1319
-
1320
- Stannum also defines an `IndifferentHashContract` class, which will match against both string and symbol keys.
1321
-
1322
- **Map Contract**
1323
-
1324
- As a `HashContract`, but matches against any object which uses the `#[]` operator to access key-value data. See [Map Constraint](signature-constraints), above.
1325
-
1326
- **Parameters Contract**
1327
-
1328
- Matches the parameters of a method call.
1329
-
1330
- ```ruby
1331
- class AuthorizationParameters < Stannum::Contracts::ParametersContract
1332
- def initialize
1333
- super do
1334
- argument :action, Symbol
1335
-
1336
- argument :record_class, Class, default: true
1337
-
1338
- keyword :role, String, default: true
1339
-
1340
- keyword :user, Stannum::Constraints::Type.new(User)
1341
- end
1342
- end
1343
- end
1344
-
1345
- contract = AuthorizationParameters.new
1346
- parameters = {
1347
- arguments: [:create, Article],
1348
- keywords: {},
1349
- block: nil
1350
- }
1351
- contract.matches?(parameters)
1352
- #=> false
1353
- errors = contract.errors_for(parameters)
1354
- errors[:arguments].empty?
1355
- #=> true
1356
- errors[:keywords].empty?
1357
- #=> false
1358
- ```
1359
-
1360
- Each `ParametersContract` defines `.argument`, `.keyword`, and `.block` class methods to define the expected method parameters.
1361
-
1362
- - The `.argument` class method defines an expected argument. Like the `.item` class method in an `ArrayContract` (see [Array Contracts](#array-contracts), above), each call to `.argument` will reference the next positional argument.
1363
- - The `.keyword` class method defines an expected keyword.
1364
- - The `.block` class method can accept either a constraint, or `true` or `false`. If given a constraint, the block passed to the method will be matched against the constraint. If given `true`, then the contract will match against any block and will fail if the method is not called with a block; likewise, if given `false`, the contract will match if no block is given and fail if the method is called with a block.
1365
-
1366
- Because of Ruby's semantics around arguments and keywords with default values, the `:default` keyword has a special meaning for parameters contracts. If `.argument` or `.keyword` is called with the `:default` keyword, it indicates that that parameter has a default value in the method definition. If that argument or keyword is *omitted*, the parameters will still match the contract. However, an explicit value of `nil` will still fail unless `nil` is a valid value for the relevant constraint.
1367
-
1368
- `ParametersContract` also has support for variadic arguments and keywords.
1369
-
1370
- ```ruby
1371
- class RecipeParameters < Stannum::Contracts::ParametersContract
1372
- def initialize
1373
- super do
1374
- arguments :tools, String
1375
- keywords :ingredients, Stannum::Contracts::TupleContract.new do
1376
- item Stannum::Constraints::Type.new(String),
1377
- property_name: :amount
1378
- item Stannum::Constraints::Type.new(String, optional: true),
1379
- property_name: :unit
1380
- end
1381
- block true
1382
- end
1383
- end
1384
- end
1385
- ```
1386
-
1387
- The `.arguments` class method creates a constraint that matches against any arguments without an explicit `.argument` expectation. Likewise, the `.keywords` class method creates a constraint that matches against any keywords without an explicit `.keyword` expectation. The contract will automatically convert a Class into the corresponding Type constraint (see [Type Constraint](#builtin-constraints-type), above).
1388
-
1389
- **Tuple Contract**
1390
-
1391
- As an `ArrayContract`, but matches against any object which uses the `#[]` operator to access indexed data. See [Tuple Constraint](signature-constraints), above.
257
+ We can then validate the API response by calling `SUCCESS_RESPONSE_CONTRACT.matches?(response)`.