plumb 0.0.1 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ # )
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:examples)
5
+ require 'plumb'
6
+ require 'open-uri'
7
+ require 'fileutils'
8
+ require 'digest/md5'
9
+
10
+ # Mixin built-in Plumb types, and provide a namespace for core types and
11
+ # pipelines in this example.
12
+ module Types
13
+ include Plumb::Types
14
+
15
+ # Turn a string into an URI
16
+ URL = String[/^https?:/].build(::URI, :parse)
17
+
18
+ # a Struct to holw image data
19
+ Image = Data.define(:url, :io)
20
+
21
+ # A (naive) step to download files from the internet
22
+ # and return an Image struct.
23
+ # It implements the #call(Result) => Result interface.
24
+ # required by all Plumb steps.
25
+ # URI => Image
26
+ Download = Plumb::Step.new do |result|
27
+ io = URI.open(result.value)
28
+ result.valid(Image.new(result.value.to_s, io))
29
+ end
30
+
31
+ # A configurable file-system cache to read and write files from.
32
+ class Cache
33
+ def initialize(dir = '.')
34
+ @dir = dir
35
+ FileUtils.mkdir_p(dir)
36
+ end
37
+
38
+ # Wrap the #reader and #wruter methods into Plumb steps
39
+ # A step only needs #call(Result) => Result to work in a pipeline,
40
+ # but wrapping it in Plumb::Step provides the #>> and #| methods for composability,
41
+ # as well as all the other helper methods provided by the Steppable module.
42
+ def read = Plumb::Step.new(method(:reader))
43
+ def write = Plumb::Step.new(method(:writer))
44
+
45
+ private
46
+
47
+ # URL => Image
48
+ def reader(result)
49
+ path = path_for(result.value)
50
+ return result.invalid(errors: "file #{path} does not exist") unless File.exist?(path)
51
+
52
+ result.valid Types::Image.new(url: path, io: File.new(path))
53
+ end
54
+
55
+ # Image => Image
56
+ def writer(result)
57
+ image = result.value
58
+ path = path_for(image.url)
59
+ File.open(path, 'wb') { |f| f.write(image.io.read) }
60
+ result.valid image.with(url: path, io: File.new(path))
61
+ end
62
+
63
+ def path_for(url)
64
+ url = url.to_s
65
+ ext = File.extname(url)
66
+ name = [Digest::MD5.hexdigest(url), ext].compact.join
67
+ File.join(@dir, name)
68
+ end
69
+ end
70
+ end
71
+
72
+ ###################################
73
+ # Program 1: idempoent download of images from the internet
74
+ # If not present in the cache, images are downloaded and written to the cache.
75
+ # Otherwise images are listed directly from the cache (files on disk).
76
+ ###################################
77
+
78
+ cache = Types::Cache.new('./examples/data/downloads')
79
+
80
+ # A pipeline representing a single image download.
81
+ # 1). Take a valid URL string.
82
+ # 2). Attempt reading the file from the cache. Return that if it exists.
83
+ # 3). Otherwise, download the file from the internet and write it to the cache.
84
+ IdempotentDownload = Types::URL >> (cache.read | (Types::Download >> cache.write))
85
+
86
+ # An array of downloadable images,
87
+ # marked as concurrent so that all IO operations are run in threads.
88
+ Images = Types::Array[IdempotentDownload].concurrent
89
+
90
+ urls = [
91
+ 'https://as1.ftcdn.net/v2/jpg/07/67/24/52/1000_F_767245234_NdiDr9LOkypOEKtXiDDoM1m42zBQ0hZe.jpg',
92
+ 'https://as1.ftcdn.net/v2/jpg/07/83/02/00/1000_F_783020069_HaP9UCZs2UXUnKxpGHDoddt0vuX4vU9U.jpg',
93
+ 'https://as2.ftcdn.net/v2/jpg/07/32/27/53/1000_F_732275398_r2t1cnxSXGUkZSgxtqhg40UupKiqcywJ.jpg',
94
+ 'https://as1.ftcdn.net/v2/jpg/07/46/41/18/1000_F_746411866_WwQBojO7xMeVFTua2BuEZdKGDI2vsgAH.jpg',
95
+ 'https://as2.ftcdn.net/v2/jpg/07/43/50/53/1000_F_743505311_MJ3zo09rH7rUvHrCKlBotojm6GLw3SCT.jpg',
96
+ 'https://images.pexels.com/photos/346529/pexels-photo-346529.jpeg'
97
+ ]
98
+
99
+ # raise CachedDownload.parse(url).inspect
100
+ # raise CachedDownload.parse(urls.first).inspect
101
+ # Run the program. The images are downloaded and written to the ./downloads directory.
102
+ # Running this multiple times will only download the images once, and list them from the cache.
103
+ # You can try deleting all or some of the files and re-running.
104
+ Images.resolve(urls).tap do |result|
105
+ puts result.valid? ? 'valid!' : result.errors
106
+ result.value.each { |img| p img }
107
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'plumb'
5
+ require 'csv'
6
+
7
+ # Defines types and pipelines for opening and working with CSV streams.
8
+ # Run with `bundle exec ruby examples/csv_stream.rb`
9
+ module Types
10
+ include Plumb::Types
11
+
12
+ # Open a File
13
+ # ex. file = FileStep.parse('./files/data.csv') # => File
14
+ OpenFile = String
15
+ .check('no file for that path') { |s| ::File.exist?(s) }
16
+ .build(::File)
17
+
18
+ # Turn a File into a CSV stream
19
+ # ex. csv_enum = FileToCSV.parse(file) #=> Enumerator
20
+ FileToCSV = Types::Any[::File]
21
+ .build(CSV)
22
+ .transform(::Enumerator, &:each)
23
+
24
+ # Turn a string file path into a CSV stream
25
+ # ex. csv_enum = StrinToCSV.parse('./files/data.csv') #=> Enumerator
26
+ StringToCSV = OpenFile >> FileToCSV
27
+ end
28
+
29
+ #################################################
30
+ # Program 1: stream a CSV list of programmers and filter them by age.
31
+ #################################################
32
+
33
+ # This is a CSV row for a programmer over the age of 18.
34
+ AdultProgrammer = Types::Tuple[
35
+ # Name
36
+ String,
37
+ # Age. Coerce to Integer and constrain to 18 or older.
38
+ Types::Lax::Integer[18..],
39
+ # Programming language
40
+ String
41
+ ]
42
+
43
+ # An Array of AdultProgrammer.
44
+ AdultProgrammerArray = Types::Array[AdultProgrammer]
45
+
46
+ # A pipeline to open a file, parse CSV and stream rows of AdultProgrammer.
47
+ AdultProgrammerStream = Types::StringToCSV >> AdultProgrammerArray.stream
48
+
49
+ # List adult programmers from file.
50
+ puts 'Adult programmers:'
51
+ AdultProgrammerStream.parse('./examples/programmers.csv').each do |row|
52
+ puts row.value.inspect if row.valid?
53
+ end
54
+
55
+ # The filtering can also be achieved with Stream#filter
56
+ #  AdultProgrammerStream = Types::StringToCSV >> AdultProgrammerArray.stream.filtered
57
+
58
+ #################################################
59
+ # Program 2: list Ruby programmers from a CSV file.
60
+ #################################################
61
+
62
+ RubyProgrammer = Types::Tuple[
63
+ String, # Name
64
+ Types::Lax::Integer, # Age
65
+ Types::String[/^ruby$/i] # Programming language
66
+ ]
67
+
68
+ # A pipeline to open a file, parse CSV and stream rows of AdultProgrammer.
69
+ # This time we use Types::Stream directly.
70
+ RubyProgrammerStream = Types::StringToCSV >> Types::Stream[RubyProgrammer].filtered
71
+
72
+ # List Ruby programmers from file.
73
+ puts
74
+ puts '----------------------------------------'
75
+ puts 'Ruby programmers:'
76
+ RubyProgrammerStream.parse('./examples/programmers.csv').each do |person|
77
+ puts person.inspect
78
+ end
79
+
80
+ # We can filter Ruby OR Elixir programmers with a union type.
81
+ # Lang = Types::String['ruby'] | Types::String['elixir']
82
+ # Or with allowe values:
83
+ # Lang = Types::String.options(%w[ruby elixir])
84
+
85
+ #################################################
86
+ # Program 3: negate the stream above to list non-Ruby programmers.
87
+ #################################################
88
+
89
+ # See the `.not` which negates the type.
90
+ NonRubyProgrammerStream = Types::StringToCSV >> Types::Stream[RubyProgrammer.not].filtered
91
+
92
+ puts
93
+ puts '----------------------------------------'
94
+ puts 'NON Ruby programmers:'
95
+ NonRubyProgrammerStream.parse('./examples/programmers.csv').each do |person|
96
+ puts person.inspect
97
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'plumb'
5
+ require 'debug'
6
+
7
+ # Types and pipelines for defining and parsing ENV configuration
8
+ # Run with `bundle exec ruby examples/env_config.rb`
9
+ #
10
+ # Given an ENV with variables to configure one of three types of network/IO clients,
11
+ # parse, validate and coerce the configuration into the appropriate client object.
12
+ # ENV vars are expected to be prefixed with `FILE_UPLOAD_`, followed by the client type.
13
+ # See example usage at the bottom of this file.
14
+ module Types
15
+ include Plumb::Types
16
+
17
+ # Define a custom policy to extract a string using a regular expression.
18
+ # Policies are factories for custom type compositions.
19
+ #
20
+ # Usage:
21
+ # type = Types::String.extract(/^FOO_(\w+)$/).invoke(:[], 1)
22
+ # type.parse('FOO_BAR') # => 'BAR'
23
+ #
24
+ Plumb.policy :extract, for_type: ::String, helper: true do |type, regex|
25
+ type >> lambda do |result|
26
+ match = result.value.match(regex)
27
+ return result.invalid(errors: "does not match #{regex.source}") if match.nil?
28
+ return result.invalid(errors: 'no captures') if match.captures.none?
29
+
30
+ result.valid(match)
31
+ end
32
+ end
33
+
34
+ # A dummy S3 client
35
+ S3Client = Data.define(:bucket, :region)
36
+
37
+ # A dummy SFTP client
38
+ SFTPClient = Data.define(:host, :username, :password)
39
+
40
+ # Map these fields to an S3 client
41
+ S3Config = Types::Hash[
42
+ transport: 's3',
43
+ bucket: String.present,
44
+ region: String.options(%w[us-west-1 us-west-2 us-east-1])
45
+ ].invoke(:except, :transport).build(S3Client) { |h| S3Client.new(**h) }
46
+
47
+ # Map these fields to an SFTP client
48
+ SFTPConfig = Types::Hash[
49
+ transport: 'sftp',
50
+ host: String.present,
51
+ username: String.present,
52
+ password: String.present,
53
+ ].invoke(:except, :transport).build(SFTPClient) { |h| SFTPClient.new(**h) }
54
+
55
+ # Map these fields to a File client
56
+ FileConfig = Types::Hash[
57
+ transport: 'file',
58
+ path: String.present,
59
+ ].invoke(:[], :path).build(::File)
60
+
61
+ # Take a string such as 'FILE_UPLOAD_BUCKET', extract the `BUCKET` bit,
62
+ # downcase and symbolize it.
63
+ FileUploadKey = String.extract(/^FILE_UPLOAD_(\w+)$/).invoke(:[], 1).invoke(%i[downcase to_sym])
64
+
65
+ # Filter a Hash (or ENV) to keys that match the FILE_UPLOAD_* pattern
66
+ FileUploadHash = Types::Hash[FileUploadKey, Any].filtered
67
+
68
+ # Pipeline syntax to put the program together
69
+ FileUploadClientFromENV = Any.pipeline do |pl|
70
+ # 1. Accept any Hash-like object (e.g. ENV)
71
+ pl.step Types::Interface[:[], :key?, :each, :to_h]
72
+
73
+ # 2. Transform it to a Hash
74
+ pl.step Any.transform(::Hash, &:to_h)
75
+
76
+ # 3. Filter keys with FILE_UPLOAD_* prefix
77
+ pl.step FileUploadHash
78
+
79
+ # 4. Parse the configuration for a particular client object
80
+ pl.step(S3Config | SFTPConfig | FileConfig)
81
+ end
82
+
83
+ # Ex.
84
+ # client = FileUploadClientFromENV.parse(ENV) # SFTP, S3 or File client
85
+
86
+ # The above is the same as:
87
+ #
88
+ # FileUploadClientFromENV = Types::Interface[:[], :key?, :each, :to_h] \
89
+ # .transform(::Hash, &:to_h) >> \
90
+ # Types::Hash[FileUploadKey, Any].filtered >> \
91
+ # (S3Config | SFTPConfig | FileConfig)
92
+ end
93
+
94
+ # Simulated ENV hashes. Just use ::ENV for the real thing.
95
+ ENV_S3 = {
96
+ 'FILE_UPLOAD_TRANSPORT' => 's3',
97
+ 'FILE_UPLOAD_BUCKET' => 'my-bucket',
98
+ 'FILE_UPLOAD_REGION' => 'us-west-2',
99
+ 'SOMETHING_ELSE' => 'ignored'
100
+ }.freeze
101
+ # => S3Client.new(bucket: 'my-bucket', region: 'us-west-2')
102
+
103
+ ENV_SFTP = {
104
+ 'FILE_UPLOAD_TRANSPORT' => 'sftp',
105
+ 'FILE_UPLOAD_HOST' => 'sftp.example.com',
106
+ 'FILE_UPLOAD_USERNAME' => 'username',
107
+ 'FILE_UPLOAD_PASSWORD' => 'password',
108
+ 'SOMETHING_ELSE' => 'ignored'
109
+ }.freeze
110
+ # => SFTPClient.new(host: 'sftp.example.com', username: 'username', password: 'password')
111
+
112
+ ENV_FILE = {
113
+ 'FILE_UPLOAD_TRANSPORT' => 'file',
114
+ 'FILE_UPLOAD_PATH' => File.join('examples', 'programmers.csv')
115
+ }.freeze
116
+
117
+ p Types::FileUploadClientFromENV.parse(ENV_S3) # #<data Types::S3Client bucket="my-bucket", region="us-west-2">
118
+ p Types::FileUploadClientFromENV.parse(ENV_SFTP) # #<data Types::SFTPClient host="sftp.example.com", username="username", password="password">
119
+ p Types::FileUploadClientFromENV.parse(ENV_FILE) # #<File path="examples/programmers.csv">
120
+
121
+ # Or with invalid or missing configuration
122
+ # p Types::FileUploadClientFromENV.parse({}) # raises error