plumb 0.0.1 → 0.0.3

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.
@@ -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