plumb 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/README.md +558 -118
- data/examples/command_objects.rb +207 -0
- data/examples/concurrent_downloads.rb +107 -0
- data/examples/csv_stream.rb +97 -0
- data/examples/env_config.rb +122 -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 +42 -13
- data/lib/plumb/hash_map.rb +34 -0
- data/lib/plumb/interface_class.rb +6 -4
- data/lib/plumb/json_schema_visitor.rb +157 -71
- data/lib/plumb/match_class.rb +8 -6
- data/lib/plumb/metadata.rb +3 -0
- data/lib/plumb/metadata_visitor.rb +54 -40
- data/lib/plumb/policies.rb +81 -0
- data/lib/plumb/policy.rb +31 -0
- data/lib/plumb/schema.rb +39 -43
- data/lib/plumb/static_class.rb +4 -4
- data/lib/plumb/step.rb +6 -1
- data/lib/plumb/steppable.rb +47 -60
- 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 +119 -69
- data/lib/plumb/value_class.rb +5 -2
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +19 -10
- data/lib/plumb.rb +53 -1
- metadata +14 -6
- data/lib/plumb/rules.rb +0 -103
@@ -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
|