plumb 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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