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