plumb 0.0.1 → 0.0.2
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.
- checksums.yaml +4 -4
 - data/.rubocop.yml +2 -0
 - data/README.md +291 -19
 - data/examples/command_objects.rb +207 -0
 - data/examples/concurrent_downloads.rb +107 -0
 - data/examples/csv_stream.rb +97 -0
 - data/examples/programmers.csv +201 -0
 - data/examples/weekdays.rb +66 -0
 - data/lib/plumb/array_class.rb +25 -19
 - data/lib/plumb/build.rb +3 -0
 - data/lib/plumb/hash_class.rb +44 -13
 - data/lib/plumb/hash_map.rb +34 -0
 - data/lib/plumb/interface_class.rb +6 -4
 - data/lib/plumb/json_schema_visitor.rb +117 -74
 - data/lib/plumb/match_class.rb +8 -5
 - data/lib/plumb/metadata.rb +3 -0
 - data/lib/plumb/metadata_visitor.rb +45 -40
 - data/lib/plumb/rules.rb +6 -7
 - data/lib/plumb/schema.rb +37 -41
 - data/lib/plumb/static_class.rb +4 -4
 - data/lib/plumb/step.rb +6 -1
 - data/lib/plumb/steppable.rb +36 -34
 - data/lib/plumb/stream_class.rb +61 -0
 - data/lib/plumb/tagged_hash.rb +12 -3
 - data/lib/plumb/transform.rb +6 -1
 - data/lib/plumb/tuple_class.rb +8 -5
 - data/lib/plumb/types.rb +19 -60
 - data/lib/plumb/value_class.rb +5 -2
 - data/lib/plumb/version.rb +1 -1
 - data/lib/plumb/visitor_handlers.rb +13 -9
 - data/lib/plumb.rb +1 -0
 - metadata +8 -2
 
    
        checksums.yaml
    CHANGED
    
    | 
         @@ -1,7 +1,7 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            ---
         
     | 
| 
       2 
2 
     | 
    
         
             
            SHA256:
         
     | 
| 
       3 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       4 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 3 
     | 
    
         
            +
              metadata.gz: db1a6e5f70bf36e91d053ff465e9f566cd4371e4620dac88aea6f028918311a8
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: 13e986c5a7815c3ecbdf6f1f6cabb4d9341b88421d8048a7e7182d9b638e1632
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 6 
     | 
    
         
            +
              metadata.gz: 540cb16d4ab114931dad7b278578428357341e5318f5371a40d22b6d53714fe71290cb66892725316faf2060e5a1ca8e4c318951e9dc8eeb7839eac2a38d4800
         
     | 
| 
      
 7 
     | 
    
         
            +
              data.tar.gz: 6404ab512cb061af57be9d7b3e41dfb967e3e651819f4fb540a6e018f55a5ab18a976649fe891338181f7e769b9efb0a29df1e8dd0586471b14ea0b7b04a511d
         
     | 
    
        data/.rubocop.yml
    CHANGED
    
    
    
        data/README.md
    CHANGED
    
    | 
         @@ -1,6 +1,14 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            # Plumb
         
     | 
| 
       2 
2 
     | 
    
         | 
| 
       3 
     | 
    
         
            -
             
     | 
| 
      
 3 
     | 
    
         
            +
            **This library is work in progress!**
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            Composable data validation, coercion and processing in Ruby. Takes over from https://github.com/ismasan/parametric
         
     | 
| 
      
 6 
     | 
    
         
            +
             
     | 
| 
      
 7 
     | 
    
         
            +
            This library takes ideas from the excellent https://dry-rb.org ecosystem, with some of the features offered by Dry-Types, Dry-Schema, Dry-Struct. However, I'm aiming at a subset of the functionality with a (hopefully) smaller API surface and fewer concepts, focusing on lessons learned after using Parametric in production for many years.
         
     | 
| 
      
 8 
     | 
    
         
            +
             
     | 
| 
      
 9 
     | 
    
         
            +
            If you're after raw performance and versatility I strongly recommend you use the Dry gems.
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
            For a description of the core architecture you can read [this article](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/).
         
     | 
| 
       4 
12 
     | 
    
         | 
| 
       5 
13 
     | 
    
         
             
            ## Installation
         
     | 
| 
       6 
14 
     | 
    
         | 
| 
         @@ -18,7 +26,7 @@ module Types 
     | 
|
| 
       18 
26 
     | 
    
         
             
              include Plumb::Types
         
     | 
| 
       19 
27 
     | 
    
         | 
| 
       20 
28 
     | 
    
         
             
              # Define your own types
         
     | 
| 
       21 
     | 
    
         
            -
              Email = String[ 
     | 
| 
      
 29 
     | 
    
         
            +
              Email = String[/@/]
         
     | 
| 
       22 
30 
     | 
    
         
             
            end
         
     | 
| 
       23 
31 
     | 
    
         | 
| 
       24 
32 
     | 
    
         
             
            # Use them
         
     | 
| 
         @@ -32,7 +40,6 @@ result.errors # "" 
     | 
|
| 
       32 
40 
     | 
    
         
             
            ```
         
     | 
| 
       33 
41 
     | 
    
         | 
| 
       34 
42 
     | 
    
         | 
| 
       35 
     | 
    
         
            -
             
     | 
| 
       36 
43 
     | 
    
         
             
            ### `#resolve(value) => Result`
         
     | 
| 
       37 
44 
     | 
    
         | 
| 
       38 
45 
     | 
    
         
             
            `#resolve` takes an input value and returns a `Result::Valid` or `Result::Invalid`
         
     | 
| 
         @@ -162,7 +169,42 @@ StringToInt = Types::String.transform(Integer, &:to_i) 
     | 
|
| 
       162 
169 
     | 
    
         
             
            StringToInteger.parse('10') # => 10
         
     | 
| 
       163 
170 
     | 
    
         
             
            ```
         
     | 
| 
       164 
171 
     | 
    
         | 
| 
      
 172 
     | 
    
         
            +
            ### `#invoke`
         
     | 
| 
       165 
173 
     | 
    
         | 
| 
      
 174 
     | 
    
         
            +
            `#invoke` builds a Step that will invoke one or more methods on the value.
         
     | 
| 
      
 175 
     | 
    
         
            +
             
     | 
| 
      
 176 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 177 
     | 
    
         
            +
            StringToInt = Types::String.invoke(:to_i)
         
     | 
| 
      
 178 
     | 
    
         
            +
            StringToInt.parse('100') # 100
         
     | 
| 
      
 179 
     | 
    
         
            +
             
     | 
| 
      
 180 
     | 
    
         
            +
            FilteredHash = Types::Hash.invoke(:except, :foo, :bar)
         
     | 
| 
      
 181 
     | 
    
         
            +
            FilteredHash.parse(foo: 1, bar: 2, name: 'Joe') # { name: 'Joe' }
         
     | 
| 
      
 182 
     | 
    
         
            +
             
     | 
| 
      
 183 
     | 
    
         
            +
            # It works with blocks
         
     | 
| 
      
 184 
     | 
    
         
            +
            Evens = Types::Array[Integer].invoke(:filter, &:even?)
         
     | 
| 
      
 185 
     | 
    
         
            +
            Evens.parse([1,2,3,4,5]) # [2, 4]
         
     | 
| 
      
 186 
     | 
    
         
            +
             
     | 
| 
      
 187 
     | 
    
         
            +
            # Same as
         
     | 
| 
      
 188 
     | 
    
         
            +
            Evens = Types::Array[Integer].transform(Array) {|arr| arr.filter(&:even?) }
         
     | 
| 
      
 189 
     | 
    
         
            +
            ```
         
     | 
| 
      
 190 
     | 
    
         
            +
             
     | 
| 
      
 191 
     | 
    
         
            +
            Passing an array of Symbol method names will build a chain of invocations.
         
     | 
| 
      
 192 
     | 
    
         
            +
             
     | 
| 
      
 193 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 194 
     | 
    
         
            +
            UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
         
     | 
| 
      
 195 
     | 
    
         
            +
            UpcaseToSym.parse('FOO_BAR') # :foo_bar
         
     | 
| 
      
 196 
     | 
    
         
            +
            ```
         
     | 
| 
      
 197 
     | 
    
         
            +
             
     | 
| 
      
 198 
     | 
    
         
            +
            That that, as opposed to `#transform`, this modified does not register a type in `#metadata[:type]`, which can be valuable for introspection or documentation (ex. JSON Schema).
         
     | 
| 
      
 199 
     | 
    
         
            +
             
     | 
| 
      
 200 
     | 
    
         
            +
            Also, there's no definition-time checks that the method names are actually supported by the input values.
         
     | 
| 
      
 201 
     | 
    
         
            +
             
     | 
| 
      
 202 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 203 
     | 
    
         
            +
            type = Types::Array.invoke(:strip) # This is fine here
         
     | 
| 
      
 204 
     | 
    
         
            +
            type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #strip
         
     | 
| 
      
 205 
     | 
    
         
            +
            ```
         
     | 
| 
      
 206 
     | 
    
         
            +
             
     | 
| 
      
 207 
     | 
    
         
            +
            Use with caution.
         
     | 
| 
       166 
208 
     | 
    
         | 
| 
       167 
209 
     | 
    
         
             
            ### `#default`
         
     | 
| 
       168 
210 
     | 
    
         | 
| 
         @@ -178,7 +220,7 @@ Note that this is syntax sugar for: 
     | 
|
| 
       178 
220 
     | 
    
         | 
| 
       179 
221 
     | 
    
         
             
            ```ruby
         
     | 
| 
       180 
222 
     | 
    
         
             
            # A String, or if it's Undefined pipe to a static string value.
         
     | 
| 
       181 
     | 
    
         
            -
            str = Types::String | (Types::Undefined >> 'nope'.freeze)
         
     | 
| 
      
 223 
     | 
    
         
            +
            str = Types::String | (Types::Undefined >> Types::Static['nope'.freeze])
         
     | 
| 
       182 
224 
     | 
    
         
             
            ```
         
     | 
| 
       183 
225 
     | 
    
         | 
| 
       184 
226 
     | 
    
         
             
            Meaning that you can compose your own semantics for a "default" value.
         
     | 
| 
         @@ -186,7 +228,7 @@ Meaning that you can compose your own semantics for a "default" value. 
     | 
|
| 
       186 
228 
     | 
    
         
             
            Example when you want to apply a default when the given value is `nil`.
         
     | 
| 
       187 
229 
     | 
    
         | 
| 
       188 
230 
     | 
    
         
             
            ```ruby
         
     | 
| 
       189 
     | 
    
         
            -
            str = Types::String | (Types::Nil >> 'nope'.freeze)
         
     | 
| 
      
 231 
     | 
    
         
            +
            str = Types::String | (Types::Nil >> Types::Static['nope'.freeze])
         
     | 
| 
       190 
232 
     | 
    
         | 
| 
       191 
233 
     | 
    
         
             
            str.parse(nil) # 'nope'
         
     | 
| 
       192 
234 
     | 
    
         
             
            str.parse('yup') # 'yup'
         
     | 
| 
         @@ -195,7 +237,7 @@ str.parse('yup') # 'yup' 
     | 
|
| 
       195 
237 
     | 
    
         
             
            Same if you want to apply a default to several cases.
         
     | 
| 
       196 
238 
     | 
    
         | 
| 
       197 
239 
     | 
    
         
             
            ```ruby
         
     | 
| 
       198 
     | 
    
         
            -
            str = Types::String | ((Types::Nil | Types::Undefined) >> 'nope'.freeze)
         
     | 
| 
      
 240 
     | 
    
         
            +
            str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
         
     | 
| 
       199 
241 
     | 
    
         
             
            ```
         
     | 
| 
       200 
242 
     | 
    
         | 
| 
       201 
243 
     | 
    
         | 
| 
         @@ -251,25 +293,23 @@ UserType.parse('Joe') # #<data User name="Joe"> 
     | 
|
| 
       251 
293 
     | 
    
         
             
            It takes an argument for a custom factory method on the object constructor.
         
     | 
| 
       252 
294 
     | 
    
         | 
| 
       253 
295 
     | 
    
         
             
            ```ruby
         
     | 
| 
       254 
     | 
    
         
            -
             
     | 
| 
       255 
     | 
    
         
            -
             
     | 
| 
       256 
     | 
    
         
            -
                new(attrs)
         
     | 
| 
       257 
     | 
    
         
            -
              end
         
     | 
| 
       258 
     | 
    
         
            -
            end
         
     | 
| 
      
 296 
     | 
    
         
            +
            # https://github.com/RubyMoney/monetize
         
     | 
| 
      
 297 
     | 
    
         
            +
            require 'monetize'
         
     | 
| 
       259 
298 
     | 
    
         | 
| 
       260 
     | 
    
         
            -
             
     | 
| 
      
 299 
     | 
    
         
            +
            StringToMoney = Types::String.build(Monetize, :parse)
         
     | 
| 
      
 300 
     | 
    
         
            +
            money = StringToMoney.parse('£10,300.00') # #<Money fractional:1030000 currency:GBP>
         
     | 
| 
       261 
301 
     | 
    
         
             
            ```
         
     | 
| 
       262 
302 
     | 
    
         | 
| 
       263 
303 
     | 
    
         
             
            You can also pass a block
         
     | 
| 
       264 
304 
     | 
    
         | 
| 
       265 
305 
     | 
    
         
             
            ```ruby
         
     | 
| 
       266 
     | 
    
         
            -
             
     | 
| 
      
 306 
     | 
    
         
            +
            StringToMoney = Types::String.build(Money) { |value| Monetize.parse(value) }
         
     | 
| 
       267 
307 
     | 
    
         
             
            ```
         
     | 
| 
       268 
308 
     | 
    
         | 
| 
       269 
309 
     | 
    
         
             
            Note that this case is identical to `#transform` with a block.
         
     | 
| 
       270 
310 
     | 
    
         | 
| 
       271 
311 
     | 
    
         
             
            ```ruby
         
     | 
| 
       272 
     | 
    
         
            -
             
     | 
| 
      
 312 
     | 
    
         
            +
            StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
         
     | 
| 
       273 
313 
     | 
    
         
             
            ```
         
     | 
| 
       274 
314 
     | 
    
         | 
| 
       275 
315 
     | 
    
         | 
| 
         @@ -350,7 +390,7 @@ Company = Types::Hash[ 
     | 
|
| 
       350 
390 
     | 
    
         
             
            result = Company.resolve(
         
     | 
| 
       351 
391 
     | 
    
         
             
              name: 'ACME',
         
     | 
| 
       352 
392 
     | 
    
         
             
              employees: [
         
     | 
| 
       353 
     | 
    
         
            -
             
     | 
| 
      
 393 
     | 
    
         
            +
                { name: 'Joe', age: 40, role: 'product' },
         
     | 
| 
       354 
394 
     | 
    
         
             
                { name: 'Joan', age: 38, role: 'engineer' }
         
     | 
| 
       355 
395 
     | 
    
         
             
              ]
         
     | 
| 
       356 
396 
     | 
    
         
             
            )
         
     | 
| 
         @@ -366,6 +406,66 @@ result.valid? # false 
     | 
|
| 
       366 
406 
     | 
    
         
             
            result.errors[:employees][0][:age] # ["must be a Numeric"]
         
     | 
| 
       367 
407 
     | 
    
         
             
            ```
         
     | 
| 
       368 
408 
     | 
    
         | 
| 
      
 409 
     | 
    
         
            +
            Note that you can use primitives as hash field definitions.
         
     | 
| 
      
 410 
     | 
    
         
            +
             
     | 
| 
      
 411 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 412 
     | 
    
         
            +
            User = Types::Hash[name: String, age: Integer]
         
     | 
| 
      
 413 
     | 
    
         
            +
            ```
         
     | 
| 
      
 414 
     | 
    
         
            +
             
     | 
| 
      
 415 
     | 
    
         
            +
            Or to validate specific values:
         
     | 
| 
      
 416 
     | 
    
         
            +
             
     | 
| 
      
 417 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 418 
     | 
    
         
            +
            Joe = Types::Hash[name: 'Joe', age: Integer]
         
     | 
| 
      
 419 
     | 
    
         
            +
            ```
         
     | 
| 
      
 420 
     | 
    
         
            +
             
     | 
| 
      
 421 
     | 
    
         
            +
            Or to validate against any `#===` interface:
         
     | 
| 
      
 422 
     | 
    
         
            +
             
     | 
| 
      
 423 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 424 
     | 
    
         
            +
            Adult = Types::Hash[name: String, age: (18..)]
         
     | 
| 
      
 425 
     | 
    
         
            +
            # Same as
         
     | 
| 
      
 426 
     | 
    
         
            +
            Adult = Types::Hash[name: Types::String, age: Types::Integer[18..]]
         
     | 
| 
      
 427 
     | 
    
         
            +
            ```
         
     | 
| 
      
 428 
     | 
    
         
            +
             
     | 
| 
      
 429 
     | 
    
         
            +
            If you want to validate literal values, pass a `Types::Value`
         
     | 
| 
      
 430 
     | 
    
         
            +
             
     | 
| 
      
 431 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 432 
     | 
    
         
            +
            Settings = Types::Hash[age_range: Types::Value[18..]]
         
     | 
| 
      
 433 
     | 
    
         
            +
             
     | 
| 
      
 434 
     | 
    
         
            +
            Settings.parse(age_range: (18..)) # Valid
         
     | 
| 
      
 435 
     | 
    
         
            +
            Settings.parse(age_range: (20..30)) # Invalid
         
     | 
| 
      
 436 
     | 
    
         
            +
            ```
         
     | 
| 
      
 437 
     | 
    
         
            +
             
     | 
| 
      
 438 
     | 
    
         
            +
            A `Types::Static` value will always resolve successfully to that value, regardless of the original payload.
         
     | 
| 
      
 439 
     | 
    
         
            +
             
     | 
| 
      
 440 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 441 
     | 
    
         
            +
            User = Types::Hash[name: Types::Static['Joe'], age: Integer]
         
     | 
| 
      
 442 
     | 
    
         
            +
            User.parse(name: 'Rufus', age: 34) # Valid {name: 'Joe', age: 34}
         
     | 
| 
      
 443 
     | 
    
         
            +
            ```
         
     | 
| 
      
 444 
     | 
    
         
            +
             
     | 
| 
      
 445 
     | 
    
         
            +
            #### Optional keys
         
     | 
| 
      
 446 
     | 
    
         
            +
             
     | 
| 
      
 447 
     | 
    
         
            +
            Keys suffixed with `?` are marked as optional and its values will only be validated and coerced if the key is present in the input hash.
         
     | 
| 
      
 448 
     | 
    
         
            +
             
     | 
| 
      
 449 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 450 
     | 
    
         
            +
            User = Types::Hash[
         
     | 
| 
      
 451 
     | 
    
         
            +
              age?: Integer,
         
     | 
| 
      
 452 
     | 
    
         
            +
              name: String
         
     | 
| 
      
 453 
     | 
    
         
            +
            ]
         
     | 
| 
      
 454 
     | 
    
         
            +
             
     | 
| 
      
 455 
     | 
    
         
            +
            User.parse(age: 20, name: 'Joe') # => Valid { age: 20, name: 'Joe' }
         
     | 
| 
      
 456 
     | 
    
         
            +
            User.parse(age: '20', name: 'Joe') # => Invalid, :age is not an Integer
         
     | 
| 
      
 457 
     | 
    
         
            +
            User.parse(name: 'Joe') #=> Valid { name: 'Joe' }
         
     | 
| 
      
 458 
     | 
    
         
            +
            ```
         
     | 
| 
      
 459 
     | 
    
         
            +
             
     | 
| 
      
 460 
     | 
    
         
            +
            Note that defaults are not applied to optional keys that are missing.
         
     | 
| 
      
 461 
     | 
    
         
            +
             
     | 
| 
      
 462 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 463 
     | 
    
         
            +
            Types::Hash[
         
     | 
| 
      
 464 
     | 
    
         
            +
              age?: Types::Integer.default(10), # does not apply default if key is missing  
         
     | 
| 
      
 465 
     | 
    
         
            +
              name: Types::String.default('Joe') # does apply default if key is missing.
         
     | 
| 
      
 466 
     | 
    
         
            +
            ]
         
     | 
| 
      
 467 
     | 
    
         
            +
            ```
         
     | 
| 
      
 468 
     | 
    
         
            +
             
     | 
| 
       369 
469 
     | 
    
         | 
| 
       370 
470 
     | 
    
         | 
| 
       371 
471 
     | 
    
         
             
            #### Merging hash definitions
         
     | 
| 
         @@ -394,9 +494,11 @@ intersection = User & Employee # Hash[:name] 
     | 
|
| 
       394 
494 
     | 
    
         | 
| 
       395 
495 
     | 
    
         
             
            Use `#tagged_by` to resolve what definition to use based on the value of a common key.
         
     | 
| 
       396 
496 
     | 
    
         | 
| 
      
 497 
     | 
    
         
            +
            Key used as index must be a `Types::Static`
         
     | 
| 
      
 498 
     | 
    
         
            +
             
     | 
| 
       397 
499 
     | 
    
         
             
            ```ruby
         
     | 
| 
       398 
     | 
    
         
            -
            NameUpdatedEvent = Types::Hash[type: 'name_updated', name: Types::String]
         
     | 
| 
       399 
     | 
    
         
            -
            AgeUpdatedEvent = Types::Hash[type: 'age_updated', age: Types::Integer]
         
     | 
| 
      
 500 
     | 
    
         
            +
            NameUpdatedEvent = Types::Hash[type: Types::Static['name_updated'], name: Types::String]
         
     | 
| 
      
 501 
     | 
    
         
            +
            AgeUpdatedEvent = Types::Hash[type: Types::Static['age_updated'], age: Types::Integer]
         
     | 
| 
       400 
502 
     | 
    
         | 
| 
       401 
503 
     | 
    
         
             
            Events = Types::Hash.tagged_by(
         
     | 
| 
       402 
504 
     | 
    
         
             
              :type,
         
     | 
| 
         @@ -409,6 +511,48 @@ Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definiti 
     | 
|
| 
       409 
511 
     | 
    
         | 
| 
       410 
512 
     | 
    
         | 
| 
       411 
513 
     | 
    
         | 
| 
      
 514 
     | 
    
         
            +
            #### `Types::Hash#inclusive`
         
     | 
| 
      
 515 
     | 
    
         
            +
             
     | 
| 
      
 516 
     | 
    
         
            +
            Use `#inclusive` to preserve input keys not defined in the hash schema.
         
     | 
| 
      
 517 
     | 
    
         
            +
             
     | 
| 
      
 518 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 519 
     | 
    
         
            +
            hash = Types::Hash[age: Types::Lax::Integer].inclusive
         
     | 
| 
      
 520 
     | 
    
         
            +
             
     | 
| 
      
 521 
     | 
    
         
            +
            # Only :age, is coerced and validated, all other keys are preserved as-is
         
     | 
| 
      
 522 
     | 
    
         
            +
            hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs') # { age: 30, name: 'Joe', last_name: 'Bloggs' }
         
     | 
| 
      
 523 
     | 
    
         
            +
            ```
         
     | 
| 
      
 524 
     | 
    
         
            +
             
     | 
| 
      
 525 
     | 
    
         
            +
            This can be useful if you only care about validating some fields, or to assemble different front and back hashes. For example a client-facing one that validates JSON or form data, and a backend one that runs further coercions or domain validations on some keys.
         
     | 
| 
      
 526 
     | 
    
         
            +
             
     | 
| 
      
 527 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 528 
     | 
    
         
            +
            # Front-end definition does structural validation
         
     | 
| 
      
 529 
     | 
    
         
            +
            Front = Types::Hash[price: Integer, name: String, category: String]
         
     | 
| 
      
 530 
     | 
    
         
            +
             
     | 
| 
      
 531 
     | 
    
         
            +
            # Turn an Integer into a Money instance
         
     | 
| 
      
 532 
     | 
    
         
            +
            IntToMoney = Types::Integer.build(Money)
         
     | 
| 
      
 533 
     | 
    
         
            +
             
     | 
| 
      
 534 
     | 
    
         
            +
            # Backend definition turns :price into a Money object, leaves other keys as-is
         
     | 
| 
      
 535 
     | 
    
         
            +
            Back = Types::Hash[price: IntToMoney].inclusive
         
     | 
| 
      
 536 
     | 
    
         
            +
             
     | 
| 
      
 537 
     | 
    
         
            +
            # Compose the pipeline
         
     | 
| 
      
 538 
     | 
    
         
            +
            InputHandler = Front >> Back
         
     | 
| 
      
 539 
     | 
    
         
            +
             
     | 
| 
      
 540 
     | 
    
         
            +
            InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
         
     | 
| 
      
 541 
     | 
    
         
            +
            # => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphone' }
         
     | 
| 
      
 542 
     | 
    
         
            +
            ```
         
     | 
| 
      
 543 
     | 
    
         
            +
             
     | 
| 
      
 544 
     | 
    
         
            +
            #### `Types::Hash#filtered`
         
     | 
| 
      
 545 
     | 
    
         
            +
             
     | 
| 
      
 546 
     | 
    
         
            +
            The `#filtered` modifier returns a valid Hash with the subset of values that were valid, instead of failing the entire result if one or more values are invalid.
         
     | 
| 
      
 547 
     | 
    
         
            +
             
     | 
| 
      
 548 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 549 
     | 
    
         
            +
            User = Types::Hash[name: String, age: Integer]
         
     | 
| 
      
 550 
     | 
    
         
            +
            User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
         
     | 
| 
      
 551 
     | 
    
         
            +
            User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
         
     | 
| 
      
 552 
     | 
    
         
            +
            ```
         
     | 
| 
      
 553 
     | 
    
         
            +
             
     | 
| 
      
 554 
     | 
    
         
            +
             
     | 
| 
      
 555 
     | 
    
         
            +
             
     | 
| 
       412 
556 
     | 
    
         
             
            ### Hash maps
         
     | 
| 
       413 
557 
     | 
    
         | 
| 
       414 
558 
     | 
    
         
             
            You can also use Hash syntax to define a hash map with specific types for all keys and values:
         
     | 
| 
         @@ -420,6 +564,37 @@ currencies.parse(usd: 'USD', gbp: 'GBP') # Ok 
     | 
|
| 
       420 
564 
     | 
    
         
             
            currencies.parse('usd' => 'USD') # Error. Keys must be Symbols
         
     | 
| 
       421 
565 
     | 
    
         
             
            ```
         
     | 
| 
       422 
566 
     | 
    
         | 
| 
      
 567 
     | 
    
         
            +
            Like other types, hash maps accept primitive types as keys and values:
         
     | 
| 
      
 568 
     | 
    
         
            +
             
     | 
| 
      
 569 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 570 
     | 
    
         
            +
            currencies = Types::Hash[Symbol, String]
         
     | 
| 
      
 571 
     | 
    
         
            +
            ```
         
     | 
| 
      
 572 
     | 
    
         
            +
             
     | 
| 
      
 573 
     | 
    
         
            +
            And any `#===` interface as values, too:
         
     | 
| 
      
 574 
     | 
    
         
            +
             
     | 
| 
      
 575 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 576 
     | 
    
         
            +
            names_and_emails = Types::Hash[String, /\w+@\w+/]
         
     | 
| 
      
 577 
     | 
    
         
            +
             
     | 
| 
      
 578 
     | 
    
         
            +
            names_and_emails.parse('Joe' => 'joe@server.com', 'Rufus' => 'rufus')
         
     | 
| 
      
 579 
     | 
    
         
            +
            ```
         
     | 
| 
      
 580 
     | 
    
         
            +
             
     | 
| 
      
 581 
     | 
    
         
            +
            Use `Types::Value` to validate specific values (using `#==`)
         
     | 
| 
      
 582 
     | 
    
         
            +
             
     | 
| 
      
 583 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 584 
     | 
    
         
            +
            names_and_ones = Types::Hash[String, Types::Integer.value(1)]
         
     | 
| 
      
 585 
     | 
    
         
            +
            ```
         
     | 
| 
      
 586 
     | 
    
         
            +
             
     | 
| 
      
 587 
     | 
    
         
            +
            #### `#filtered`
         
     | 
| 
      
 588 
     | 
    
         
            +
             
     | 
| 
      
 589 
     | 
    
         
            +
            Calling the `#filtered` modifier on a Hash Map makes it return a sub set of the keys and values that are valid as per the key and value type definitions.
         
     | 
| 
      
 590 
     | 
    
         
            +
             
     | 
| 
      
 591 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 592 
     | 
    
         
            +
            # Filter the ENV for all keys starting with S3_*
         
     | 
| 
      
 593 
     | 
    
         
            +
            S3Config = Types::Hash[/^S3_\w+/, Types::Any].filtered
         
     | 
| 
      
 594 
     | 
    
         
            +
             
     | 
| 
      
 595 
     | 
    
         
            +
            S3Config.parse(ENV.to_h) # { 'S3_BUCKET' => 'foo', 'S3_REGION' => 'us-east-1' }
         
     | 
| 
      
 596 
     | 
    
         
            +
            ```
         
     | 
| 
      
 597 
     | 
    
         
            +
             
     | 
| 
       423 
598 
     | 
    
         | 
| 
       424 
599 
     | 
    
         | 
| 
       425 
600 
     | 
    
         
             
            ### `Types::Array`
         
     | 
| 
         @@ -429,19 +604,54 @@ names = Types::Array[Types::String.present] 
     | 
|
| 
       429 
604 
     | 
    
         
             
            names_or_ages = Types::Array[Types::String.present | Types::Integer[21..]]
         
     | 
| 
       430 
605 
     | 
    
         
             
            ```
         
     | 
| 
       431 
606 
     | 
    
         | 
| 
      
 607 
     | 
    
         
            +
            Arrays support primitive classes, or any `#===` interface:
         
     | 
| 
      
 608 
     | 
    
         
            +
             
     | 
| 
      
 609 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 610 
     | 
    
         
            +
            strings = Types::Array[String]
         
     | 
| 
      
 611 
     | 
    
         
            +
            emails = Types::Array[/@/]
         
     | 
| 
      
 612 
     | 
    
         
            +
            # Similar to 
         
     | 
| 
      
 613 
     | 
    
         
            +
            emails = Types::Array[Types::String[/@/]]
         
     | 
| 
      
 614 
     | 
    
         
            +
            ```
         
     | 
| 
      
 615 
     | 
    
         
            +
             
     | 
| 
      
 616 
     | 
    
         
            +
            Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching agains the regular expression.
         
     | 
| 
      
 617 
     | 
    
         
            +
             
     | 
| 
       432 
618 
     | 
    
         
             
            #### Concurrent arrays
         
     | 
| 
       433 
619 
     | 
    
         | 
| 
       434 
620 
     | 
    
         
             
            Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
         
     | 
| 
       435 
621 
     | 
    
         | 
| 
       436 
622 
     | 
    
         
             
            ```ruby
         
     | 
| 
       437 
     | 
    
         
            -
            ImageDownload = Types::URL >> ->(result) {  
     | 
| 
      
 623 
     | 
    
         
            +
            ImageDownload = Types::URL >> ->(result) { 
         
     | 
| 
      
 624 
     | 
    
         
            +
              resp = HTTP.get(result.value)
         
     | 
| 
      
 625 
     | 
    
         
            +
              if (200...300).include?(resp.status)
         
     | 
| 
      
 626 
     | 
    
         
            +
                result.valid(resp.body)
         
     | 
| 
      
 627 
     | 
    
         
            +
              else
         
     | 
| 
      
 628 
     | 
    
         
            +
                result.invalid(error: resp.status)
         
     | 
| 
      
 629 
     | 
    
         
            +
              end
         
     | 
| 
      
 630 
     | 
    
         
            +
            }
         
     | 
| 
       438 
631 
     | 
    
         
             
            Images = Types::Array[ImageDownload].concurrent
         
     | 
| 
       439 
632 
     | 
    
         | 
| 
       440 
633 
     | 
    
         
             
            # Images are downloaded concurrently and returned in order.
         
     | 
| 
       441 
634 
     | 
    
         
             
            Images.parse(['https://images.com/1.png', 'https://images.com/2.png'])
         
     | 
| 
       442 
635 
     | 
    
         
             
            ```
         
     | 
| 
       443 
636 
     | 
    
         | 
| 
       444 
     | 
    
         
            -
            TODO: pluggable  
     | 
| 
      
 637 
     | 
    
         
            +
            TODO: pluggable concurrency engines (Async?)
         
     | 
| 
      
 638 
     | 
    
         
            +
             
     | 
| 
      
 639 
     | 
    
         
            +
            #### `#stream`
         
     | 
| 
      
 640 
     | 
    
         
            +
             
     | 
| 
      
 641 
     | 
    
         
            +
            Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
         
     | 
| 
      
 642 
     | 
    
         
            +
             
     | 
| 
      
 643 
     | 
    
         
            +
            See `Types::Stream` below for more.
         
     | 
| 
      
 644 
     | 
    
         
            +
             
     | 
| 
      
 645 
     | 
    
         
            +
            #### `#filtered`
         
     | 
| 
      
 646 
     | 
    
         
            +
             
     | 
| 
      
 647 
     | 
    
         
            +
            The `#filtered` modifier makes an array definition return a subset of the input array where the values are valid, as per the array's element type.
         
     | 
| 
      
 648 
     | 
    
         
            +
             
     | 
| 
      
 649 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 650 
     | 
    
         
            +
            j_names = Types::Array[Types::String[/^j/]].filtered
         
     | 
| 
      
 651 
     | 
    
         
            +
            j_names.parse(%w[james ismael joe toby joan isabel]) # ["james", "joe", "joan"]
         
     | 
| 
      
 652 
     | 
    
         
            +
            ```
         
     | 
| 
      
 653 
     | 
    
         
            +
             
     | 
| 
      
 654 
     | 
    
         
            +
             
     | 
| 
       445 
655 
     | 
    
         | 
| 
       446 
656 
     | 
    
         
             
            ### `Types::Tuple`
         
     | 
| 
       447 
657 
     | 
    
         | 
| 
         @@ -461,7 +671,69 @@ Error = Types::Tuple[:error, Types::String.present] 
     | 
|
| 
       461 
671 
     | 
    
         
             
            Status = Ok | Error
         
     | 
| 
       462 
672 
     | 
    
         
             
            ```
         
     | 
| 
       463 
673 
     | 
    
         | 
| 
      
 674 
     | 
    
         
            +
            ... Or any `#===` interface
         
     | 
| 
      
 675 
     | 
    
         
            +
             
     | 
| 
      
 676 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 677 
     | 
    
         
            +
            NameAndEmail = Types::Tuple[String, /@/]
         
     | 
| 
      
 678 
     | 
    
         
            +
            ```
         
     | 
| 
      
 679 
     | 
    
         
            +
             
     | 
| 
      
 680 
     | 
    
         
            +
            As before, use `Types::Value` to check against literal values using `#==`
         
     | 
| 
       464 
681 
     | 
    
         | 
| 
      
 682 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 683 
     | 
    
         
            +
            NameAndRegex = Types::Tuple[String, Types::Value[/@/]]
         
     | 
| 
      
 684 
     | 
    
         
            +
            ```
         
     | 
| 
      
 685 
     | 
    
         
            +
             
     | 
| 
      
 686 
     | 
    
         
            +
             
     | 
| 
      
 687 
     | 
    
         
            +
             
     | 
| 
      
 688 
     | 
    
         
            +
            ### `Types::Stream`
         
     | 
| 
      
 689 
     | 
    
         
            +
             
     | 
| 
      
 690 
     | 
    
         
            +
            `Types::Stream` defines an enumerator that validates/coerces each element as it iterates.
         
     | 
| 
      
 691 
     | 
    
         
            +
             
     | 
| 
      
 692 
     | 
    
         
            +
            This example streams a CSV file and validates rows as they are consumed.
         
     | 
| 
      
 693 
     | 
    
         
            +
             
     | 
| 
      
 694 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 695 
     | 
    
         
            +
            require 'csv'
         
     | 
| 
      
 696 
     | 
    
         
            +
             
     | 
| 
      
 697 
     | 
    
         
            +
            Row = Types::Tuple[Types::String.present, Types:Lax::Integer]
         
     | 
| 
      
 698 
     | 
    
         
            +
            Stream = Types::Stream[Row]
         
     | 
| 
      
 699 
     | 
    
         
            +
             
     | 
| 
      
 700 
     | 
    
         
            +
            data = CSV.new(File.new('./big-file.csv')).each # An Enumerator
         
     | 
| 
      
 701 
     | 
    
         
            +
            # stream is an Enumerator that yields rows wrapped in[Result::Valid] or [Result::Invalid]
         
     | 
| 
      
 702 
     | 
    
         
            +
            stream = Stream.parse(data)
         
     | 
| 
      
 703 
     | 
    
         
            +
            stream.each.with_index(1) do |result, line|
         
     | 
| 
      
 704 
     | 
    
         
            +
              if result.valid?
         
     | 
| 
      
 705 
     | 
    
         
            +
                p result.value
         
     | 
| 
      
 706 
     | 
    
         
            +
              else
         
     | 
| 
      
 707 
     | 
    
         
            +
                p ["row at line #{line} is invalid: ", result.errors]
         
     | 
| 
      
 708 
     | 
    
         
            +
              end
         
     | 
| 
      
 709 
     | 
    
         
            +
            end
         
     | 
| 
      
 710 
     | 
    
         
            +
            ```
         
     | 
| 
      
 711 
     | 
    
         
            +
             
     | 
| 
      
 712 
     | 
    
         
            +
            #### `Types::Stream#filtered`
         
     | 
| 
      
 713 
     | 
    
         
            +
             
     | 
| 
      
 714 
     | 
    
         
            +
            Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
         
     | 
| 
      
 715 
     | 
    
         
            +
             
     | 
| 
      
 716 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 717 
     | 
    
         
            +
            ValidElements = Types::Stream[Row].filtered
         
     | 
| 
      
 718 
     | 
    
         
            +
            ValidElements.parse(data).each do |valid_row|
         
     | 
| 
      
 719 
     | 
    
         
            +
              p valid_row
         
     | 
| 
      
 720 
     | 
    
         
            +
            end
         
     | 
| 
      
 721 
     | 
    
         
            +
            ```
         
     | 
| 
      
 722 
     | 
    
         
            +
             
     | 
| 
      
 723 
     | 
    
         
            +
            #### `Types::Array#stream`
         
     | 
| 
      
 724 
     | 
    
         
            +
             
     | 
| 
      
 725 
     | 
    
         
            +
            A `Types::Array` definition can be turned into a stream.
         
     | 
| 
      
 726 
     | 
    
         
            +
             
     | 
| 
      
 727 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 728 
     | 
    
         
            +
            Arr = Types::Array[Integer]
         
     | 
| 
      
 729 
     | 
    
         
            +
            Str = Arr.stream
         
     | 
| 
      
 730 
     | 
    
         
            +
             
     | 
| 
      
 731 
     | 
    
         
            +
            Str.parse(data).each do |row|
         
     | 
| 
      
 732 
     | 
    
         
            +
              row.valid?
         
     | 
| 
      
 733 
     | 
    
         
            +
              row.errors
         
     | 
| 
      
 734 
     | 
    
         
            +
              row.value
         
     | 
| 
      
 735 
     | 
    
         
            +
            end
         
     | 
| 
      
 736 
     | 
    
         
            +
            ```
         
     | 
| 
       465 
737 
     | 
    
         | 
| 
       466 
738 
     | 
    
         
             
            ### Plumb::Schema
         
     | 
| 
       467 
739 
     | 
    
         | 
| 
         @@ -0,0 +1,207 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require 'bundler'
         
     | 
| 
      
 4 
     | 
    
         
            +
            Bundler.setup(:examples)
         
     | 
| 
      
 5 
     | 
    
         
            +
            require 'plumb'
         
     | 
| 
      
 6 
     | 
    
         
            +
            require 'json'
         
     | 
| 
      
 7 
     | 
    
         
            +
            require 'fileutils'
         
     | 
| 
      
 8 
     | 
    
         
            +
            require 'money'
         
     | 
| 
      
 9 
     | 
    
         
            +
             
     | 
| 
      
 10 
     | 
    
         
            +
            Money.default_currency = Money::Currency.new('GBP')
         
     | 
| 
      
 11 
     | 
    
         
            +
            Money.locale_backend = nil
         
     | 
| 
      
 12 
     | 
    
         
            +
            Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
         
     | 
| 
      
 13 
     | 
    
         
            +
             
     | 
| 
      
 14 
     | 
    
         
            +
            # Different approaches to the Command Object pattern using composable Plumb types.
         
     | 
| 
      
 15 
     | 
    
         
            +
            module Types
         
     | 
| 
      
 16 
     | 
    
         
            +
              include Plumb::Types
         
     | 
| 
      
 17 
     | 
    
         
            +
             
     | 
| 
      
 18 
     | 
    
         
            +
              # Note that within this `Types` module, when we say String, Integer etc, we mean Types::String, Types::Integer etc.
         
     | 
| 
      
 19 
     | 
    
         
            +
              # Use ::String to refer to Ruby's String class.
         
     | 
| 
      
 20 
     | 
    
         
            +
              #
         
     | 
| 
      
 21 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 22 
     | 
    
         
            +
              # Define core types in the domain
         
     | 
| 
      
 23 
     | 
    
         
            +
              # The task is to process, validate and store mortgage applications.
         
     | 
| 
      
 24 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
              # Turn integers into Money objects (requires the money gem)
         
     | 
| 
      
 27 
     | 
    
         
            +
              Amount = Integer.build(Money)
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
              # A naive email check
         
     | 
| 
      
 30 
     | 
    
         
            +
              Email = String[/\w+@\w+\.\w+/]
         
     | 
| 
      
 31 
     | 
    
         
            +
             
     | 
| 
      
 32 
     | 
    
         
            +
              # A valid customer type
         
     | 
| 
      
 33 
     | 
    
         
            +
              Customer = Hash[
         
     | 
| 
      
 34 
     | 
    
         
            +
                name: String.present,
         
     | 
| 
      
 35 
     | 
    
         
            +
                age?: Integer[18..],
         
     | 
| 
      
 36 
     | 
    
         
            +
                email: Email
         
     | 
| 
      
 37 
     | 
    
         
            +
              ]
         
     | 
| 
      
 38 
     | 
    
         
            +
             
     | 
| 
      
 39 
     | 
    
         
            +
              # A step to validate a Mortgage application payload
         
     | 
| 
      
 40 
     | 
    
         
            +
              # including valid customer, mortgage type and minimum property value.
         
     | 
| 
      
 41 
     | 
    
         
            +
              MortgagePayload = Hash[
         
     | 
| 
      
 42 
     | 
    
         
            +
                customer: Customer,
         
     | 
| 
      
 43 
     | 
    
         
            +
                type: String.options(%w[first-time switcher remortgage]).default('first-time'),
         
     | 
| 
      
 44 
     | 
    
         
            +
                property_value: Integer[100_000..] >> Amount,
         
     | 
| 
      
 45 
     | 
    
         
            +
                mortgage_amount: Integer[50_000..] >> Amount,
         
     | 
| 
      
 46 
     | 
    
         
            +
                term: Integer[5..30],
         
     | 
| 
      
 47 
     | 
    
         
            +
              ]
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
              # A domain validation step: the mortgage amount must be less than the property value.
         
     | 
| 
      
 50 
     | 
    
         
            +
              # This is just a Proc that implements the `#call(Result::Valid) => Result::Valid | Result::Invalid` interface.
         
     | 
| 
      
 51 
     | 
    
         
            +
              # # Note that this can be anything that supports that interface, like a lambda, a method, a class etc.
         
     | 
| 
      
 52 
     | 
    
         
            +
              ValidateMortgageAmount = proc do |result|
         
     | 
| 
      
 53 
     | 
    
         
            +
                if result.value[:mortgage_amount] > result.value[:property_value]
         
     | 
| 
      
 54 
     | 
    
         
            +
                  result.invalid(errors: { mortgage_amount: 'Cannot exceed property value' })
         
     | 
| 
      
 55 
     | 
    
         
            +
                else
         
     | 
| 
      
 56 
     | 
    
         
            +
                  result
         
     | 
| 
      
 57 
     | 
    
         
            +
                end
         
     | 
| 
      
 58 
     | 
    
         
            +
              end
         
     | 
| 
      
 59 
     | 
    
         
            +
             
     | 
| 
      
 60 
     | 
    
         
            +
              # A step to create a mortgage application
         
     | 
| 
      
 61 
     | 
    
         
            +
              # This could be backed by a database (ex. ActiveRecord), a service (ex. HTTP API), etc.
         
     | 
| 
      
 62 
     | 
    
         
            +
              # For this example I just save JSON files to disk.
         
     | 
| 
      
 63 
     | 
    
         
            +
              class MortgageApplicationsStore
         
     | 
| 
      
 64 
     | 
    
         
            +
                def self.call(result) = new.call(result)
         
     | 
| 
      
 65 
     | 
    
         
            +
             
     | 
| 
      
 66 
     | 
    
         
            +
                def initialize(dir = './examples/data/applications')
         
     | 
| 
      
 67 
     | 
    
         
            +
                  @dir = dir
         
     | 
| 
      
 68 
     | 
    
         
            +
                  FileUtils.mkdir_p(dir)
         
     | 
| 
      
 69 
     | 
    
         
            +
                end
         
     | 
| 
      
 70 
     | 
    
         
            +
             
     | 
| 
      
 71 
     | 
    
         
            +
                # The Plumb::Step interface to make these objects composable.
         
     | 
| 
      
 72 
     | 
    
         
            +
                # @param result [Plumb::Result::Valid]
         
     | 
| 
      
 73 
     | 
    
         
            +
                # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
         
     | 
| 
      
 74 
     | 
    
         
            +
                def call(result)
         
     | 
| 
      
 75 
     | 
    
         
            +
                  if save(result.value)
         
     | 
| 
      
 76 
     | 
    
         
            +
                    result
         
     | 
| 
      
 77 
     | 
    
         
            +
                  else
         
     | 
| 
      
 78 
     | 
    
         
            +
                    result.invalid(errors: 'Could not save application')
         
     | 
| 
      
 79 
     | 
    
         
            +
                  end
         
     | 
| 
      
 80 
     | 
    
         
            +
                end
         
     | 
| 
      
 81 
     | 
    
         
            +
             
     | 
| 
      
 82 
     | 
    
         
            +
                def save(payload)
         
     | 
| 
      
 83 
     | 
    
         
            +
                  file_name = File.join(@dir, "#{Time.now.to_i}.json")
         
     | 
| 
      
 84 
     | 
    
         
            +
                  File.write(file_name, JSON.pretty_generate(payload))
         
     | 
| 
      
 85 
     | 
    
         
            +
                end
         
     | 
| 
      
 86 
     | 
    
         
            +
              end
         
     | 
| 
      
 87 
     | 
    
         
            +
             
     | 
| 
      
 88 
     | 
    
         
            +
              # Finally, a step to send a notificiation to the customer.
         
     | 
| 
      
 89 
     | 
    
         
            +
              # This should only run if the previous steps were successful.
         
     | 
| 
      
 90 
     | 
    
         
            +
              NotifyCustomer = proc do |result|
         
     | 
| 
      
 91 
     | 
    
         
            +
                # Send an email here.
         
     | 
| 
      
 92 
     | 
    
         
            +
                puts "Sending notification to #{result.value[:customer][:email]}"
         
     | 
| 
      
 93 
     | 
    
         
            +
                result
         
     | 
| 
      
 94 
     | 
    
         
            +
              end
         
     | 
| 
      
 95 
     | 
    
         
            +
             
     | 
| 
      
 96 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 97 
     | 
    
         
            +
              # Option 1: define standalone steps and then pipe them together
         
     | 
| 
      
 98 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 99 
     | 
    
         
            +
              CreateMortgageApplication1 = MortgagePayload \
         
     | 
| 
      
 100 
     | 
    
         
            +
                >> ValidateMortgageAmount \
         
     | 
| 
      
 101 
     | 
    
         
            +
                >> MortgageApplicationsStore \
         
     | 
| 
      
 102 
     | 
    
         
            +
                >> NotifyCustomer
         
     | 
| 
      
 103 
     | 
    
         
            +
             
     | 
| 
      
 104 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 105 
     | 
    
         
            +
              # Option 2: compose steps into a Plumb::Pipeline
         
     | 
| 
      
 106 
     | 
    
         
            +
              # This is just a wrapper around step1 >> step2 >> step3 ...
         
     | 
| 
      
 107 
     | 
    
         
            +
              # But the procedural style can make sequential steps easier to read and manage.
         
     | 
| 
      
 108 
     | 
    
         
            +
              # Also to add/remove debugging and tracing steps.
         
     | 
| 
      
 109 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 110 
     | 
    
         
            +
              CreateMortgageApplication2 = Any.pipeline do |pl|
         
     | 
| 
      
 111 
     | 
    
         
            +
                # The input payload
         
     | 
| 
      
 112 
     | 
    
         
            +
                pl.step MortgagePayload
         
     | 
| 
      
 113 
     | 
    
         
            +
             
     | 
| 
      
 114 
     | 
    
         
            +
                # Some inline logging to demostrate inline steps
         
     | 
| 
      
 115 
     | 
    
         
            +
                # This is also useful for debugging and tracing.
         
     | 
| 
      
 116 
     | 
    
         
            +
                pl.step do |result|
         
     | 
| 
      
 117 
     | 
    
         
            +
                  p [:after_payload, result.value]
         
     | 
| 
      
 118 
     | 
    
         
            +
                  result
         
     | 
| 
      
 119 
     | 
    
         
            +
                end
         
     | 
| 
      
 120 
     | 
    
         
            +
             
     | 
| 
      
 121 
     | 
    
         
            +
                # Domain validation
         
     | 
| 
      
 122 
     | 
    
         
            +
                pl.step ValidateMortgageAmount
         
     | 
| 
      
 123 
     | 
    
         
            +
             
     | 
| 
      
 124 
     | 
    
         
            +
                # Save the application
         
     | 
| 
      
 125 
     | 
    
         
            +
                pl.step MortgageApplicationsStore
         
     | 
| 
      
 126 
     | 
    
         
            +
             
     | 
| 
      
 127 
     | 
    
         
            +
                # Notifications
         
     | 
| 
      
 128 
     | 
    
         
            +
                pl.step NotifyCustomer
         
     | 
| 
      
 129 
     | 
    
         
            +
              end
         
     | 
| 
      
 130 
     | 
    
         
            +
             
     | 
| 
      
 131 
     | 
    
         
            +
              # Note that I could have also started the pipeline directly off the MortgagePayload type.
         
     | 
| 
      
 132 
     | 
    
         
            +
              # ex. CreateMortageApplication2 = MortgagePayload.pipeline do |pl
         
     | 
| 
      
 133 
     | 
    
         
            +
              # For super-tiny command objects you can do it all inline:
         
     | 
| 
      
 134 
     | 
    
         
            +
              #
         
     | 
| 
      
 135 
     | 
    
         
            +
              #   Types::Hash[
         
     | 
| 
      
 136 
     | 
    
         
            +
              #     name: String,
         
     | 
| 
      
 137 
     | 
    
         
            +
              #     age: Integer
         
     | 
| 
      
 138 
     | 
    
         
            +
              #   ].pipeline do |pl|
         
     | 
| 
      
 139 
     | 
    
         
            +
              #     pl.step do |result|
         
     | 
| 
      
 140 
     | 
    
         
            +
              #       .. some validations
         
     | 
| 
      
 141 
     | 
    
         
            +
              #       result
         
     | 
| 
      
 142 
     | 
    
         
            +
              #     end
         
     | 
| 
      
 143 
     | 
    
         
            +
              #   end
         
     | 
| 
      
 144 
     | 
    
         
            +
              #
         
     | 
| 
      
 145 
     | 
    
         
            +
              # Or you can use Method objects as steps
         
     | 
| 
      
 146 
     | 
    
         
            +
              #
         
     | 
| 
      
 147 
     | 
    
         
            +
              #   pl.step SomeObject.method(:create)
         
     | 
| 
      
 148 
     | 
    
         
            +
             
     | 
| 
      
 149 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 150 
     | 
    
         
            +
              # Option 3: use your own class
         
     | 
| 
      
 151 
     | 
    
         
            +
              # Use Plumb internally for validation and composition of shared steps or method objects.
         
     | 
| 
      
 152 
     | 
    
         
            +
              ###############################################################
         
     | 
| 
      
 153 
     | 
    
         
            +
              class CreateMortgageApplication3
         
     | 
| 
      
 154 
     | 
    
         
            +
                def initialize
         
     | 
| 
      
 155 
     | 
    
         
            +
                  @pipeline = Types::Any.pipeline do |pl|
         
     | 
| 
      
 156 
     | 
    
         
            +
                    pl.step MortgagePayload
         
     | 
| 
      
 157 
     | 
    
         
            +
                    pl.step method(:validate)
         
     | 
| 
      
 158 
     | 
    
         
            +
                    pl.step method(:save)
         
     | 
| 
      
 159 
     | 
    
         
            +
                    pl.step method(:notify)
         
     | 
| 
      
 160 
     | 
    
         
            +
                  end
         
     | 
| 
      
 161 
     | 
    
         
            +
                end
         
     | 
| 
      
 162 
     | 
    
         
            +
             
     | 
| 
      
 163 
     | 
    
         
            +
                def run(payload)
         
     | 
| 
      
 164 
     | 
    
         
            +
                  @pipeline.resolve(payload)
         
     | 
| 
      
 165 
     | 
    
         
            +
                end
         
     | 
| 
      
 166 
     | 
    
         
            +
             
     | 
| 
      
 167 
     | 
    
         
            +
                private
         
     | 
| 
      
 168 
     | 
    
         
            +
             
     | 
| 
      
 169 
     | 
    
         
            +
                def validate(result)
         
     | 
| 
      
 170 
     | 
    
         
            +
                  # etc
         
     | 
| 
      
 171 
     | 
    
         
            +
                  result
         
     | 
| 
      
 172 
     | 
    
         
            +
                end
         
     | 
| 
      
 173 
     | 
    
         
            +
             
     | 
| 
      
 174 
     | 
    
         
            +
                def save(result)
         
     | 
| 
      
 175 
     | 
    
         
            +
                  # etc
         
     | 
| 
      
 176 
     | 
    
         
            +
                  result
         
     | 
| 
      
 177 
     | 
    
         
            +
                end
         
     | 
| 
      
 178 
     | 
    
         
            +
             
     | 
| 
      
 179 
     | 
    
         
            +
                def notify(result)
         
     | 
| 
      
 180 
     | 
    
         
            +
                  # etc
         
     | 
| 
      
 181 
     | 
    
         
            +
                  result
         
     | 
| 
      
 182 
     | 
    
         
            +
                end
         
     | 
| 
      
 183 
     | 
    
         
            +
              end
         
     | 
| 
      
 184 
     | 
    
         
            +
            end
         
     | 
| 
      
 185 
     | 
    
         
            +
             
     | 
| 
      
 186 
     | 
    
         
            +
            # Uncomment each case to run
         
     | 
| 
      
 187 
     | 
    
         
            +
            # p Types::CreateMortgageApplication1.resolve(
         
     | 
| 
      
 188 
     | 
    
         
            +
            #   customer: { name: 'John Doe', age: 30, email: 'john@doe.com' },
         
     | 
| 
      
 189 
     | 
    
         
            +
            #   property_value: 200_000,
         
     | 
| 
      
 190 
     | 
    
         
            +
            #   mortgage_amount: 150_000,
         
     | 
| 
      
 191 
     | 
    
         
            +
            #   term: 25
         
     | 
| 
      
 192 
     | 
    
         
            +
            # )
         
     | 
| 
      
 193 
     | 
    
         
            +
             
     | 
| 
      
 194 
     | 
    
         
            +
            # p Types::CreateMortgageApplication2.resolve(
         
     | 
| 
      
 195 
     | 
    
         
            +
            #   customer: { name: 'John Doe', age: 30, email: 'john@doe.com' },
         
     | 
| 
      
 196 
     | 
    
         
            +
            #   property_value: 200_000,
         
     | 
| 
      
 197 
     | 
    
         
            +
            #   mortgage_amount: 150_000,
         
     | 
| 
      
 198 
     | 
    
         
            +
            #   term: 25
         
     | 
| 
      
 199 
     | 
    
         
            +
            # )
         
     | 
| 
      
 200 
     | 
    
         
            +
             
     | 
| 
      
 201 
     | 
    
         
            +
            # Or, with invalid data
         
     | 
| 
      
 202 
     | 
    
         
            +
            # p Types::CreateMortgageApplication2.resolve(
         
     | 
| 
      
 203 
     | 
    
         
            +
            #   customer: { name: 'John Doe', age: 30, email: 'john@doe.com' },
         
     | 
| 
      
 204 
     | 
    
         
            +
            #   property_value: 200_000,
         
     | 
| 
      
 205 
     | 
    
         
            +
            #   mortgage_amount: 201_000,
         
     | 
| 
      
 206 
     | 
    
         
            +
            #   term: 25
         
     | 
| 
      
 207 
     | 
    
         
            +
            # )
         
     |