plumb 0.0.4 → 0.0.5

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,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parametric'
4
+ require 'money'
5
+ require 'monetize'
6
+
7
+ PARAMETRIC_MONEY_EXP = /(\W{1}|\w{3})?[\d+,.]/
8
+
9
+ Parametric.policy(:nullable_money) do
10
+ coerce do |value, _key, _context|
11
+ money = case value
12
+ when String
13
+ value = value.strip
14
+ if value.blank?
15
+ nil
16
+ elsif value =~ PARAMETRIC_MONEY_EXP
17
+ Monetize.parse!(value.gsub(',', ''))
18
+ else
19
+ raise ArgumentError, "#{value} is not a monetary amount"
20
+ end
21
+ when Numeric
22
+ Monetize.parse!(value.to_s)
23
+ when Money
24
+ value
25
+ when NilClass
26
+ nil
27
+ else
28
+ raise ArgumentError, "don't know how to coerce #{value.inspect} into Money"
29
+ end
30
+
31
+ if money && money.currency != Money.default_currency
32
+ raise ArgumentError,
33
+ "expected #{Money.default_currency.name} as currency, but got #{money.currency.name}"
34
+ end
35
+
36
+ money
37
+ end
38
+
39
+ meta_data do
40
+ { type: :number, config_type: :nullable_money }
41
+ end
42
+ end
43
+
44
+ Parametric.policy(:nullable_date) do
45
+ # rubocop:disable Style/RedundantBegin
46
+ coerce do |value, _key, _context|
47
+ begin
48
+ value = value.to_s.strip
49
+ value == '' ? nil : Date.parse(value)
50
+ rescue Date::Error
51
+ nil
52
+ end
53
+ end
54
+ # rubocop:enable Style/RedundantBegin
55
+
56
+ meta_data do
57
+ { type: :date, config_type: :nullable_date }
58
+ end
59
+ end
60
+
61
+ PARAMETRIC_DATE_EXP = /\A\d{4}-\d{2}-\d{2}\z/
62
+ PARAMETRIC_DATE_INFINITY = 'infinity'
63
+ PARAMETRIC_IS_DATE = ->(value) { value.is_a?(::Date) || (value.is_a?(::String) && value =~ PARAMETRIC_DATE_EXP) }
64
+ PARAMETRIC_PARSE_DATE = ->(value) { value.is_a?(::Date) ? value : ::Date.parse(value) }
65
+
66
+ Parametric.policy :nullable_date_range do
67
+ validate do |value, _key, _context|
68
+ if value.blank? || value.is_a?(Switcher::DateRange)
69
+ true
70
+ else
71
+ value.is_a?(Hash) && !!(PARAMETRIC_IS_DATE.call(value[:min]) && (value[:max].nil? || PARAMETRIC_IS_DATE.call(value[:max] || value[:max] == PARAMETRIC_DATE_INFINITY)))
72
+ end
73
+ end
74
+
75
+ coerce do |value, _key, _context|
76
+ if value.is_a?(Switcher::DateRange)
77
+ value
78
+ elsif value.blank? || (value[:min].blank? && value[:max].blank?)
79
+ nil
80
+ else
81
+ min = value[:min].present? ? PARAMETRIC_PARSE_DATE.call(value[:min]) : nil
82
+ max = value[:max].present? && value[:max] != PARAMETRIC_DATE_INFINITY ? PARAMETRIC_PARSE_DATE.call(value[:max]) : nil
83
+ Switcher::DateRange.new(min, max)
84
+ end
85
+ end
86
+
87
+ meta_data do
88
+ { type: :object, config_type: :nullable_date_range }
89
+ end
90
+ end
91
+
92
+ PARAMETRIC_INT_EXP = /^\d+$/
93
+
94
+ Parametric.policy(:nullable_integer) do
95
+ coerce do |value, _key, _context|
96
+ if value.to_s =~ PARAMETRIC_INT_EXP
97
+ value.to_i
98
+ else
99
+ nil
100
+ end
101
+ end
102
+
103
+ meta_data do
104
+ { type: :integer, config_type: :nullable_integer }
105
+ end
106
+ end
107
+
108
+ PARAMETRIC_FLOAT_EXP = /^\d+(\.\d+)?$/
109
+
110
+ Parametric.policy(:nullable_number) do
111
+ coerce do |value, _key, _context|
112
+ if value.to_s =~ PARAMETRIC_FLOAT_EXP
113
+ value.to_f
114
+ else
115
+ nil
116
+ end
117
+ end
118
+
119
+ meta_data do
120
+ { type: :number, config_type: :nullable_number }
121
+ end
122
+ end
123
+
124
+ Parametric.policy :size do
125
+ message do |opts, object|
126
+ "must have size of #{opts}, but got #{object.size}"
127
+ end
128
+
129
+ validate do |opts, object, _key, _payload|
130
+ size = object.size
131
+ (opts[:min].nil? || size >= opts[:min]) && (opts[:max].nil? || size <= opts[:max])
132
+ end
133
+ end
134
+
135
+ Parametric.policy(:nullable_string) do
136
+ coerce do |value, _key, _context|
137
+ if value.to_s.strip != ''
138
+ value.to_s
139
+ else
140
+ nil
141
+ end
142
+ end
143
+
144
+ meta_data do
145
+ { type: :string, config_type: :nullable_string }
146
+ end
147
+ end
148
+
149
+ module ParametricSchema
150
+ TERM = Parametric::Schema.new do
151
+ field(:name).type(:string).default('')
152
+ field(:url).type(:string).default('')
153
+ field(:terms_text).type(:string).default('')
154
+ field(:start_date).type(:nullable_date)
155
+ field(:end_date).type(:nullable_date)
156
+ end
157
+ TV_COMPONENT = Parametric::Schema.new do
158
+ field(:slug).type(:string) # .policy(:parameterize).present
159
+ field(:name).type(:string).present
160
+ field(:search_tags).type(:array).default([])
161
+ field(:description).type(:string)
162
+ field(:channels).type(:integer).default(0)
163
+ field(:discount_price).type(:nullable_money).default(Money.zero)
164
+ field(:discount_period).type(:nullable_integer)
165
+ field(:ongoing_price).type(:nullable_money)
166
+ field(:contract_length).type(:nullable_integer)
167
+ field(:upfront_cost).type(:nullable_money)
168
+ field(:commission).type(:nullable_money)
169
+ end
170
+ RECORD = Parametric::Schema.new do
171
+ field(:supplier_name).type(:string).present
172
+ field(:start_date).type(:nullable_date).meta(example: nil, admin_ui: true)
173
+ field(:end_date).type(:nullable_date).meta(example: nil, admin_ui: true)
174
+ field(:countdown_date).type(:nullable_date).meta(example: nil)
175
+ field(:name).type(:string).present.meta(example: 'Visa Platinum', admin_ui: true)
176
+ field(:upfront_cost_description).type(:string).default('')
177
+ field(:tv_channels_count).type(:integer).default(0)
178
+ field(:terms).type(:array).policy(:size, min: 1).default([]).schema TERM
179
+ field(:tv_included).type(:boolean)
180
+ field(:additional_info).type(:string)
181
+ field(:product_type).type(:nullable_string) # computed on ingestion
182
+ field(:annual_price_increase_applies).type(:boolean).default(false)
183
+ field(:annual_price_increase_description).type(:string).default('')
184
+ field(:broadband_components).type(:array).default([]).schema do
185
+ field(:name).type(:string)
186
+ field(:technology).type(:string)
187
+ field(:technology_tags).type(:array).default([])
188
+ field(:is_mobile).type(:boolean).default(false) # computed on ingestion based on technology
189
+ field(:description).type(:string)
190
+ field(:download_speed_measurement).type(:string).default('')
191
+ field(:download_speed).type(:nullable_number).default(0)
192
+ field(:upload_speed_measurement).type(:string)
193
+ field(:upload_speed).type(:nullable_number).default(0)
194
+ field(:download_usage_limit).type(:nullable_integer).default(nil)
195
+ field(:discount_price).type(:nullable_money)
196
+ field(:discount_period).type(:nullable_integer)
197
+ field(:speed_description).type(:string).default('')
198
+ field(:ongoing_price).type(:nullable_money)
199
+ field(:contract_length).type(:nullable_integer)
200
+ field(:upfront_cost).type(:nullable_money)
201
+ field(:commission).type(:nullable_money)
202
+ end
203
+ field(:tv_components).type(:array).default([]).schema TV_COMPONENT
204
+ field(:call_package_types).type(:array).default([]).meta(example: ['Everything']) # computed on ingestion
205
+ field(:phone_components).type(:array).default([]).schema do
206
+ field(:name).type(:string)
207
+ field(:description).type(:string)
208
+ field(:discount_price).type(:nullable_money)
209
+ field(:discount_period).type(:nullable_integer)
210
+ field(:ongoing_price).type(:nullable_money)
211
+ field(:contract_length).type(:nullable_integer)
212
+ field(:upfront_cost).type(:nullable_money)
213
+ field(:commission).type(:nullable_money)
214
+ field(:call_package_type).type(:array).default([])
215
+ end
216
+ field(:payment_methods).type(:array).default([])
217
+ field(:discounts).type(:array).default([]).schema do
218
+ field(:period).type(:integer)
219
+ field(:price).type(:nullable_money)
220
+ end
221
+ field(:ongoing_price).type(:nullable_money).meta(admin_ui: true)
222
+ field(:contract_length).type(:nullable_integer)
223
+ field(:upfront_cost).type(:nullable_money)
224
+ field(:year_1_price).type(:nullable_money).meta(admin_ui: true)
225
+ field(:savings).type(:nullable_money).meta(admin_ui: true)
226
+ field(:commission).type(:nullable_money)
227
+ field(:max_broadband_download_speed).type(:integer).default(0)
228
+ end
229
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb'
4
+ require 'date'
5
+ require 'money'
6
+ require 'monetize'
7
+
8
+ module PlumbHash
9
+ include Plumb::Types
10
+
11
+ BLANK_ARRAY = [].freeze
12
+ BLANK_STRING = ''
13
+ MONEY_EXP = /(\W{1}|\w{3})?[\d+,.]/
14
+
15
+ PARSE_DATE = proc do |result|
16
+ date = ::Date.parse(result.value)
17
+ result.valid(date)
18
+ rescue ::Date::Error
19
+ result.invalid(errors: 'invalid date')
20
+ end
21
+
22
+ PARSE_MONEY = proc do |result|
23
+ value = Monetize.parse!(result.value.to_s.gsub(',', ''))
24
+ result.valid(value)
25
+ end
26
+
27
+ Date = Any[::Date] \
28
+ | (String[MONEY_EXP] >> PARSE_DATE)
29
+
30
+ BlankStringOrDate = Forms::Nil | Date
31
+
32
+ Money = Any[::Money] \
33
+ | (String.present >> PARSE_MONEY) \
34
+ | (Numeric >> PARSE_MONEY)
35
+
36
+ Term = Hash[
37
+ name: String.default(BLANK_STRING),
38
+ url: String.default(BLANK_STRING),
39
+ terms_text: String.default(BLANK_STRING),
40
+ start_date?: BlankStringOrDate.nullable,
41
+ end_date?: BlankStringOrDate.nullable
42
+ ]
43
+
44
+ TvComponent = Hash[
45
+ slug: String,
46
+ name: String.present,
47
+ search_tags: Array[String].default(BLANK_ARRAY),
48
+ description: String.default(BLANK_STRING),
49
+ channels: Integer.default(0),
50
+ discount_price: Money.default(::Money.zero.freeze)
51
+ ]
52
+
53
+ Record = Hash[
54
+ supplier_name: String.present,
55
+ start_date: BlankStringOrDate.nullable.metadata(admin_ui: true),
56
+ end_date: BlankStringOrDate.nullable.metadata(admin_ui: true),
57
+ countdown_date: BlankStringOrDate.nullable,
58
+ name: String.present,
59
+ upfront_cost_description: String.default(BLANK_STRING),
60
+ tv_channels_count: Integer.default(0),
61
+ terms: Array[Term].policy(size: 1..).default(BLANK_ARRAY),
62
+ tv_included: Boolean,
63
+ additional_info: String,
64
+ product_type: String.nullable,
65
+ annual_price_increase_applies: Boolean.default(false),
66
+ annual_price_increase_description: String.default(BLANK_STRING),
67
+ broadband_components: Array[
68
+ name: String,
69
+ technology: String,
70
+ is_mobile: Boolean.default(false),
71
+ desciption: String,
72
+ technology_tags: Array[String].default(BLANK_ARRAY),
73
+ download_speed_measurement: String.default(BLANK_STRING),
74
+ download_speed: Numeric.default(0),
75
+ upload_speed_measurement: String.default(BLANK_STRING),
76
+ upload_speed: Numeric.default(0),
77
+ download_usage_limit: Integer.nullable,
78
+ discount_price: Money.nullable,
79
+ discount_period: Integer.nullable,
80
+ speed_description: String.default(BLANK_STRING),
81
+ ongoing_price: Money.nullable,
82
+ contract_length: Integer.nullable,
83
+ upfront_cost: Money.nullable,
84
+ commission: Money.nullable
85
+ ],
86
+ tv_components: Array[TvComponent].default(BLANK_ARRAY),
87
+ call_package_types: Array[String].default(BLANK_ARRAY).metadata(example: ['Everything']),
88
+ phone_components: Array[
89
+ name: String,
90
+ description: String,
91
+ discount_price: Money.nullable,
92
+ discount_period: Integer.nullable,
93
+ ongoing_price: Money.nullable,
94
+ contract_length: Integer.nullable,
95
+ upfront_cost: Money.nullable,
96
+ commission: Money.nullable,
97
+ call_package_type: Array[String].default(BLANK_ARRAY)
98
+ ].default(BLANK_ARRAY),
99
+ payment_methods: Array[String].default(BLANK_ARRAY),
100
+ discounts: Array[period: Integer, price: Money.nullable],
101
+ ongoing_price: Money.nullable.metadata(admin_ui: true),
102
+ contract_length: Integer.nullable,
103
+ upfront_cost: Money.nullable,
104
+ year_1_price: Money.nullable.metadata(admin_ui: true),
105
+ savings: Money.nullable.metadata(admin_ui: true),
106
+ commission: Money.nullable,
107
+ max_broadband_download_speed: Integer.default(0)
108
+ ]
109
+ end
@@ -11,19 +11,14 @@ require 'debug'
11
11
  module Types
12
12
  include Plumb::Types
13
13
 
14
- # Turn an ISO8601 sring into a Time object
15
- ISOTime = String.build(::Time, :parse)
14
+ # Turn an ISO8601 string into a Time object
15
+ ISOTime = String.build(::Time, :parse).policy(:rescue, ArgumentError)
16
16
 
17
17
  # A type that can be a Time object or an ISO8601 string >> Time
18
18
  Time = Any[::Time] | ISOTime
19
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
20
  # A UUID string, or generate a new one
24
- AutoUUID = UUID.default { SecureRandom.uuid }
25
-
26
- Email = String[URI::MailTo::EMAIL_REGEXP]
21
+ AutoUUID = UUID::V4.default { SecureRandom.uuid }
27
22
  end
28
23
 
29
24
  # A superclass and registry to define event types
@@ -57,7 +52,7 @@ end
57
52
  # user_created.stream_id == create_user.stream_id
58
53
  #
59
54
  # ## JSON Schemas
60
- # Plumb data structs support `.to_json_schema`, to you can document all events in the registry with something like
55
+ # Plumb data structs support `.to_json_schema`, so you can document all events in the registry with something like
61
56
  #
62
57
  # Event.registry.values.map(&:to_json_schema)
63
58
  #
@@ -66,17 +61,22 @@ class Event < Types::Data
66
61
  attribute :stream_id, Types::String.present
67
62
  attribute :type, Types::String
68
63
  attribute(:created_at, Types::Time.default { ::Time.now })
69
- attribute? :causation_id, Types::UUID
70
- attribute? :correlation_id, Types::UUID
64
+ attribute? :causation_id, Types::UUID::V4
65
+ attribute? :correlation_id, Types::UUID::V4
71
66
  attribute :payload, Types::Static[nil]
72
67
 
73
68
  def self.registry
74
69
  @registry ||= {}
75
70
  end
76
71
 
72
+ # Custom node_name to trigger specialiesed JSON Schema visitor handler.
73
+ def self.node_name = :event
74
+
77
75
  def self.define(type_str, &payload_block)
78
76
  type_str.freeze unless type_str.frozen?
79
77
  registry[type_str] = Class.new(self) do
78
+ def self.node_name = :data
79
+
80
80
  attribute :type, Types::Static[type_str]
81
81
  attribute :payload, &payload_block if block_given?
82
82
  end
@@ -99,22 +99,29 @@ end
99
99
  # Example command and events for a simple event-sourced system
100
100
  #
101
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
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
110
  #
111
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
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
+
121
+ # Register a JSON Schema visitor handlers to render Event.registry as a "AnyOf" list of event types
122
+ Plumb::JSONSchemaVisitor.on(:event) do |node, props|
123
+ props.merge('type' => 'object', 'anyOf' => node.registry.values.map { |v| visit(v) })
124
+ end
125
+
126
+ p Plumb::JSONSchemaVisitor.call(Event)
120
127
  # debugger
data/examples/weekdays.rb CHANGED
@@ -52,7 +52,7 @@ p Types::DayName.parse('TueSday') # => "tuesday
52
52
  p Types::Week.parse([3, 2, 1, 4, 5, 6, 7]) # => [1, 2, 3, 4, 5, 6, 7]
53
53
  p Types::Week.parse([1, 'Tuesday', 3, 4, 5, 'saturday', 7]) # => [1, 2, 3, 4, 5, 6, 7]
54
54
 
55
- # p Types::Week[[1, 1, 3, 4, 5, 6, 7]] # raises Plumb::TypeError: repeated days
55
+ # p Types::Week[[1, 1, 3, 4, 5, 6, 7]] # raises Plumb::ParseError: repeated days
56
56
  #
57
57
  # Or use these types as part of other composite types, ex.
58
58
  #
@@ -110,6 +110,7 @@ module Plumb
110
110
  #
111
111
  def self.included(base)
112
112
  base.send(:extend, ClassMethods)
113
+ base.define_singleton_method(:__plumb_struct_class__) { base }
113
114
  end
114
115
 
115
116
  attr_reader :errors, :attributes
@@ -155,10 +156,10 @@ module Plumb
155
156
  private
156
157
 
157
158
  def assign_attributes(attrs = BLANK_HASH)
158
- raise ArgumentError, 'Must be a Hash of attributes' unless attrs.is_a?(::Hash)
159
+ raise ArgumentError, 'Must be a Hash of attributes' unless attrs.respond_to?(:to_h)
159
160
 
160
161
  @errors = BLANK_HASH
161
- result = self.class._schema.resolve(attrs)
162
+ result = self.class._schema.resolve(attrs.to_h)
162
163
  @attributes = result.value
163
164
  @errors = result.errors unless result.valid?
164
165
  end
@@ -180,14 +181,15 @@ module Plumb
180
181
  # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
181
182
  def call(result)
182
183
  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
+ return result.invalid(errors: ['Must be a Hash of attributes']) unless result.value.respond_to?(:to_h)
184
185
 
185
- instance = new(result.value)
186
- instance.valid? ? result.valid(instance) : result.invalid(instance, errors: instance.errors)
186
+ instance = new(result.value.to_h)
187
+ instance.valid? ? result.valid(instance) : result.invalid(instance, errors: instance.errors.to_h)
187
188
  end
188
189
 
189
190
  # Person = Data[:name => String, :age => Integer, title?: String]
190
191
  def [](type_specs)
192
+ type_specs = type_specs._schema if type_specs.is_a?(Plumb::HashClass)
191
193
  klass = Class.new(self)
192
194
  type_specs.each do |key, type|
193
195
  klass.attribute(key, type)
@@ -210,11 +212,11 @@ module Plumb
210
212
  name = key.to_sym
211
213
  type = Composable.wrap(type)
212
214
  if block_given? # :foo, Array[Data] or :foo, Struct
213
- type = Types::Data if type == Types::Any
215
+ type = __plumb_struct_class__ if type == Types::Any
214
216
  type = Plumb.decorate(type) do |node|
215
217
  if node.is_a?(Plumb::ArrayClass)
216
218
  child = node.children.first
217
- child = Types::Data if child == Types::Any
219
+ child = __plumb_struct_class__ if child == Types::Any
218
220
  Types::Array[build_nested(name, child, &block)]
219
221
  elsif node.is_a?(Plumb::Step)
220
222
  build_nested(name, node, &block)
@@ -227,6 +229,10 @@ module Plumb
227
229
  end
228
230
 
229
231
  @_schema = _schema + { key => type }
232
+ __plumb_define_attribute_method__(name)
233
+ end
234
+
235
+ def __plumb_define_attribute_method__(name)
230
236
  define_method(name) { @attributes[name] }
231
237
  end
232
238