plumb 0.0.2 → 0.0.4

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.
@@ -15,8 +15,8 @@ module Types
15
15
  # Turn a string into an URI
16
16
  URL = String[/^https?:/].build(::URI, :parse)
17
17
 
18
- # a Struct to holw image data
19
- Image = Data.define(:url, :io)
18
+ # a Struct to hold image data
19
+ Image = ::Data.define(:url, :io)
20
20
 
21
21
  # A (naive) step to download files from the internet
22
22
  # and return an Image struct.
@@ -38,7 +38,7 @@ module Types
38
38
  # Wrap the #reader and #wruter methods into Plumb steps
39
39
  # A step only needs #call(Result) => Result to work in a pipeline,
40
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.
41
+ # as well as all the other helper methods provided by the Composable module.
42
42
  def read = Plumb::Step.new(method(:reader))
43
43
  def write = Plumb::Step.new(method(:writer))
44
44
 
@@ -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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb'
4
+ require 'time'
5
+ require 'uri'
6
+ require 'securerandom'
7
+ require 'debug'
8
+
9
+ # Bring Plumb into our own namespace
10
+ # and define some basic types
11
+ module Types
12
+ include Plumb::Types
13
+
14
+ # Turn an ISO8601 sring into a Time object
15
+ ISOTime = String.build(::Time, :parse)
16
+
17
+ # A type that can be a Time object or an ISO8601 string >> Time
18
+ Time = Any[::Time] | ISOTime
19
+
20
+ # A UUID string
21
+ UUID = String[/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i]
22
+
23
+ # A UUID string, or generate a new one
24
+ AutoUUID = UUID.default { SecureRandom.uuid }
25
+
26
+ Email = String[URI::MailTo::EMAIL_REGEXP]
27
+ end
28
+
29
+ # A superclass and registry to define event types
30
+ # for example for an event-driven or event-sourced system.
31
+ # All events have an "envelope" set of attributes,
32
+ # including unique ID, stream_id, type, timestamp, causation ID,
33
+ # event subclasses have a type string (ex. 'users.name.updated') and an optional payload
34
+ # This class provides a `.define` method to create new event types with a type and optional payload struct,
35
+ # a `.from` method to instantiate the correct subclass from a hash, ex. when deserializing from JSON or a web request.
36
+ # and a `#follow` method to produce new events based on a previous event's envelope, where the #causation_id and #correlation_id
37
+ # are set to the parent event
38
+ # @example
39
+ #
40
+ # # Define event struct with type and payload
41
+ # UserCreated = Event.define('users.created') do
42
+ # attribute :name, Types::String
43
+ # attribute :email, Types::Email
44
+ # end
45
+ #
46
+ # # Instantiate a full event with .new
47
+ # user_created = UserCreated.new(stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
48
+ #
49
+ # # Use the `.from(Hash) => Event` factory to lookup event class by `type` and produce the right instance
50
+ # user_created = Event.from(type: 'users.created', stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
51
+ #
52
+ # # Use #follow(payload Hash) => Event to produce events following a command or parent event
53
+ # create_user = CreateUser.new(...)
54
+ # user_created = create_user.follow(UserCreated, name: 'Joe', email: '...')
55
+ # user_created.causation_id == create_user.id
56
+ # user_created.correlation_id == create_user.correlation_id
57
+ # user_created.stream_id == create_user.stream_id
58
+ #
59
+ # ## JSON Schemas
60
+ # Plumb data structs support `.to_json_schema`, to you can document all events in the registry with something like
61
+ #
62
+ # Event.registry.values.map(&:to_json_schema)
63
+ #
64
+ class Event < Types::Data
65
+ attribute :id, Types::AutoUUID
66
+ attribute :stream_id, Types::String.present
67
+ attribute :type, Types::String
68
+ attribute(:created_at, Types::Time.default { ::Time.now })
69
+ attribute? :causation_id, Types::UUID
70
+ attribute? :correlation_id, Types::UUID
71
+ attribute :payload, Types::Static[nil]
72
+
73
+ def self.registry
74
+ @registry ||= {}
75
+ end
76
+
77
+ def self.define(type_str, &payload_block)
78
+ type_str.freeze unless type_str.frozen?
79
+ registry[type_str] = Class.new(self) do
80
+ attribute :type, Types::Static[type_str]
81
+ attribute :payload, &payload_block if block_given?
82
+ end
83
+ end
84
+
85
+ def self.from(attrs)
86
+ klass = registry[attrs[:type]]
87
+ raise ArgumentError, "Unknown event type: #{attrs[:type]}" unless klass
88
+
89
+ klass.new(attrs)
90
+ end
91
+
92
+ def follow(event_class, payload_attrs = nil)
93
+ attrs = { stream_id:, causation_id: id, correlation_id: }
94
+ attrs[:payload] = payload_attrs if payload_attrs
95
+ event_class.new(attrs)
96
+ end
97
+ end
98
+
99
+ # Example command and events for a simple event-sourced system
100
+ #
101
+ # ## Commands
102
+ # CreateUser = Event.define('users.create') do
103
+ # attribute :name, Types::String.present
104
+ # attribute :email, Types::Email
105
+ # end
106
+ #
107
+ # UpdateUserName = Event.define('users.update_name') do
108
+ # attribute :name, Types::String.present
109
+ # end
110
+ #
111
+ # ## Events
112
+ # UserCreated = Event.define('users.created') do
113
+ # attribute :name, Types::String
114
+ # attribute :email, Types::Email
115
+ # end
116
+ #
117
+ # UserNameUpdated = Event.define('users.name_updated') do
118
+ # attribute :name, Types::String
119
+ # end
120
+ # debugger
data/examples/weekdays.rb CHANGED
@@ -41,7 +41,7 @@ module Types
41
41
  # Ex. [1, 2, 3, 4, 5, 6, 7], [1, 2, 4], ['monday', 'tuesday', 'wednesday', 7]
42
42
  # Turn day names into numbers, and sort the array.
43
43
  Week = Array[DayNameOrNumber]
44
- .rule(size: 1..7)
44
+ .policy(size: 1..7)
45
45
  .check('repeated days') { |days| days.uniq.size == days.size }
46
46
  .transform(::Array, &:sort)
47
47
  end
data/lib/plumb/and.rb CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class And
7
- include Steppable
7
+ include Composable
8
8
 
9
- attr_reader :left, :right
9
+ attr_reader :children
10
10
 
11
11
  def initialize(left, right)
12
12
  @left = left
13
13
  @right = right
14
+ @children = [left, right].freeze
14
15
  freeze
15
16
  end
16
17
 
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class AnyClass
7
- include Steppable
7
+ include Composable
8
8
 
9
- def |(other) = Steppable.wrap(other)
10
- def >>(other) = Steppable.wrap(other)
9
+ def |(other) = Composable.wrap(other)
10
+ def >>(other) = Composable.wrap(other)
11
11
 
12
12
  # Any.default(value) must trigger default when value is Undefined
13
13
  def default(...)
@@ -1,18 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'concurrent'
4
- require 'plumb/steppable'
4
+ require 'plumb/composable'
5
5
  require 'plumb/result'
6
6
  require 'plumb/stream_class'
7
7
 
8
8
  module Plumb
9
9
  class ArrayClass
10
- include Steppable
10
+ include Composable
11
11
 
12
- attr_reader :element_type
12
+ attr_reader :children
13
13
 
14
14
  def initialize(element_type: Types::Any)
15
- @element_type = Steppable.wrap(element_type)
15
+ @element_type = Composable.wrap(element_type)
16
+ @children = [@element_type].freeze
16
17
 
17
18
  freeze
18
19
  end
@@ -47,11 +48,13 @@ module Plumb
47
48
  values, errors = map_array_elements(result.value)
48
49
  return result.valid(values) unless errors.any?
49
50
 
50
- result.invalid(errors:)
51
+ result.invalid(values, errors:)
51
52
  end
52
53
 
53
54
  private
54
55
 
56
+ attr_reader :element_type
57
+
55
58
  def _inspect
56
59
  %(Array[#{element_type}])
57
60
  end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ module Attributes
5
+ # A module that provides a simple way to define a struct-like class with
6
+ # attributes that are type-checked on initialization.
7
+ #
8
+ # @example
9
+ # class Person
10
+ # include Plumb::Attributes
11
+ #
12
+ # attribute :name, Types::String
13
+ # attribute :age, Types::Integer[18..]
14
+ # end
15
+ #
16
+ # person = Person.new(name: 'Jane', age: 20)
17
+ # person.valid? # => true
18
+ # person.errors # => {}
19
+ # person.name # => 'Jane'
20
+ #
21
+ # It supports nested attributes:
22
+ #
23
+ # @example
24
+ # class Person
25
+ # include Plumb::Attributes
26
+ #
27
+ # attribute :friend do
28
+ # attribute :name, String
29
+ # end
30
+ # end
31
+ #
32
+ # person = Person.new(friend: { name: 'John' })
33
+ #
34
+ # Or arrays of nested attributes:
35
+ #
36
+ # @example
37
+ # class Person
38
+ # include Plumb::Attributes
39
+ #
40
+ # attribute :friends, Types::Array do
41
+ # atrribute :name, String
42
+ # end
43
+ # end
44
+ #
45
+ # person = Person.new(friends: [{ name: 'John' }])
46
+ #
47
+ # Or use struct classes defined separately:
48
+ #
49
+ # @example
50
+ # class Company
51
+ # include Plumb::Attributes
52
+ # attribute :name, String
53
+ # end
54
+ #
55
+ # class Person
56
+ # include Plumb::Attributes
57
+ #
58
+ # # Single nested struct
59
+ # attribute :company, Company
60
+ #
61
+ # # Array of nested structs
62
+ # attribute :companies, Types::Array[Company]
63
+ # end
64
+ #
65
+ # Arrays and other types support composition and helpers. Ex. `#default`.
66
+ #
67
+ # attribute :companies, Types::Array[Company].default([].freeze)
68
+ #
69
+ # Passing a named struct class AND a block will subclass the struct and extend it with new attributes:
70
+ #
71
+ # attribute :company, Company do
72
+ # attribute :address, String
73
+ # end
74
+ #
75
+ # The same works with arrays:
76
+ #
77
+ # attribute :companies, Types::Array[Company] do
78
+ # attribute :address, String
79
+ # end
80
+ #
81
+ # Note that this does NOT work with union'd or piped structs.
82
+ #
83
+ # attribute :company, Company | Person do
84
+ #
85
+ # ## Optional Attributes
86
+ # Using `attribute?` allows for optional attributes. If the attribute is not present, it will be set to `Undefined`.
87
+ #
88
+ # attribute? :company, Company
89
+ #
90
+ # ## Struct Inheritance
91
+ # Structs can inherit from other structs. This is useful for defining a base struct with common attributes.
92
+ #
93
+ # class BasePerson
94
+ # include Plumb::Attributes
95
+ #
96
+ # attribute :name, String
97
+ # end
98
+ #
99
+ # class Person < BasePerson
100
+ # attribute :age, Integer
101
+ # end
102
+ #
103
+ # ## [] Syntax
104
+ #
105
+ # The `[]` syntax can be used to define a struct in a single line.
106
+ # Like Plumb::Types::Hash, suffixing a key with `?` makes it optional.
107
+ #
108
+ # Person = Data[name: String, age?: Integer]
109
+ # person = Person.new(name: 'Jane')
110
+ #
111
+ def self.included(base)
112
+ base.send(:extend, ClassMethods)
113
+ end
114
+
115
+ attr_reader :errors, :attributes
116
+
117
+ def initialize(attrs = {})
118
+ assign_attributes(attrs)
119
+ end
120
+
121
+ def ==(other)
122
+ other.is_a?(self.class) && other.attributes == attributes
123
+ end
124
+
125
+ # @return [Boolean]
126
+ def valid? = !errors || errors.none?
127
+
128
+ # @param attrs [Hash]
129
+ # @return [Plumb::Attributes]
130
+ def with(attrs = BLANK_HASH)
131
+ self.class.new(attributes.merge(attrs))
132
+ end
133
+
134
+ def inspect
135
+ %(#<#{self.class}:#{object_id} [#{valid? ? 'valid' : 'invalid'}] #{attributes.map do |k, v|
136
+ [k, v.inspect].join(':')
137
+ end.join(' ')}>)
138
+ end
139
+
140
+ # @return [Hash]
141
+ def to_h
142
+ attributes.transform_values do |value|
143
+ case value
144
+ when ::Array
145
+ value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
146
+ else
147
+ value.respond_to?(:to_h) ? value.to_h : value
148
+ end
149
+ end
150
+ end
151
+
152
+ def deconstruct(...) = to_h.values.deconstruct(...)
153
+ def deconstruct_keys(...) = to_h.deconstruct_keys(...)
154
+
155
+ private
156
+
157
+ def assign_attributes(attrs = BLANK_HASH)
158
+ raise ArgumentError, 'Must be a Hash of attributes' unless attrs.is_a?(::Hash)
159
+
160
+ @errors = BLANK_HASH
161
+ result = self.class._schema.resolve(attrs)
162
+ @attributes = result.value
163
+ @errors = result.errors unless result.valid?
164
+ end
165
+
166
+ module ClassMethods
167
+ def _schema
168
+ @_schema ||= HashClass.new
169
+ end
170
+
171
+ def inherited(subclass)
172
+ _schema._schema.each do |key, type|
173
+ subclass.attribute(key, type)
174
+ end
175
+ super
176
+ end
177
+
178
+ # The Plumb::Step interface
179
+ # @param result [Plumb::Result::Valid]
180
+ # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
181
+ def call(result)
182
+ return result if result.value.is_a?(self)
183
+ return result.invalid(errors: ['Must be a Hash of attributes']) unless result.value.is_a?(Hash)
184
+
185
+ instance = new(result.value)
186
+ instance.valid? ? result.valid(instance) : result.invalid(instance, errors: instance.errors)
187
+ end
188
+
189
+ # Person = Data[:name => String, :age => Integer, title?: String]
190
+ def [](type_specs)
191
+ klass = Class.new(self)
192
+ type_specs.each do |key, type|
193
+ klass.attribute(key, type)
194
+ end
195
+ klass
196
+ end
197
+
198
+ # node name for visitors
199
+ def node_name = :data
200
+
201
+ # attribute(:friend) { attribute(:name, String) }
202
+ # attribute(:friend, MyStruct) { attribute(:name, String) }
203
+ # attribute(:name, String)
204
+ # attribute(:friends, Types::Array) { attribute(:name, String) }
205
+ # attribute(:friends, Types::Array) # same as Types::Array[Types::Any]
206
+ # attribute(:friends, Types::Array[Person])
207
+ #
208
+ def attribute(name, type = Types::Any, &block)
209
+ key = Key.wrap(name)
210
+ name = key.to_sym
211
+ type = Composable.wrap(type)
212
+ if block_given? # :foo, Array[Data] or :foo, Struct
213
+ type = Types::Data if type == Types::Any
214
+ type = Plumb.decorate(type) do |node|
215
+ if node.is_a?(Plumb::ArrayClass)
216
+ child = node.children.first
217
+ child = Types::Data if child == Types::Any
218
+ Types::Array[build_nested(name, child, &block)]
219
+ elsif node.is_a?(Plumb::Step)
220
+ build_nested(name, node, &block)
221
+ elsif node.is_a?(Class) && node <= Plumb::Attributes
222
+ build_nested(name, node, &block)
223
+ else
224
+ node
225
+ end
226
+ end
227
+ end
228
+
229
+ @_schema = _schema + { key => type }
230
+ define_method(name) { @attributes[name] }
231
+ end
232
+
233
+ def attribute?(name, *args, &block)
234
+ attribute(Key.new(name, optional: true), *args, &block)
235
+ end
236
+
237
+ def build_nested(name, node, &block)
238
+ if node.is_a?(Class) && node <= Plumb::Attributes
239
+ sub = Class.new(node)
240
+ sub.instance_exec(&block)
241
+ __set_nested_class__(name, sub)
242
+ return Composable.wrap(sub)
243
+ end
244
+
245
+ return node unless node.is_a?(Plumb::Step)
246
+
247
+ child = node.children.first
248
+ return node unless child <= Plumb::Attributes
249
+
250
+ sub = Class.new(child)
251
+ sub.instance_exec(&block)
252
+ __set_nested_class__(name, sub)
253
+ Composable.wrap(sub)
254
+ end
255
+
256
+ def __set_nested_class__(name, klass)
257
+ name = name.to_s.split('_').map(&:capitalize).join.sub(/s$/, '')
258
+ const_set(name, klass) unless const_defined?(name)
259
+ end
260
+ end
261
+ end
262
+ end
data/lib/plumb/build.rb CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class Build
7
- include Steppable
7
+ include Composable
8
8
 
9
- attr_reader :type
9
+ attr_reader :children
10
10
 
11
11
  def initialize(type, factory_method: :new, &block)
12
12
  @type = type
13
13
  @block = block || ->(value) { type.send(factory_method, value) }
14
+ @children = [type].freeze
14
15
  freeze
15
16
  end
16
17