metaractor 2.0.0 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43fa8164083ceda9dca36dc6584da4bf3ac511e67d0f7a2232468ae841f8bd56
4
- data.tar.gz: 1ed5b56c2524f9f73048a8f9f79c2ab4515bafe0749a60122b92841224b5194c
3
+ metadata.gz: 285b4aecd4d684716db6d574e443464ba4fecaca03f49417153d4eb2d49994e4
4
+ data.tar.gz: bea5769c3cb76692d1f1a1218fae8937500ae2cdec53dfda5d17066170a723b8
5
5
  SHA512:
6
- metadata.gz: 535d587dde182fd7c298f050eefb6a560a35f27ffba8e90afd117a0395f760374b88794c09e2cd84821ba760b26ee0322bd1fd95d8d9b0b2c22dceac8b7cac14
7
- data.tar.gz: 8571061ff4a4fcd37d3592a82273db4bf308f394b92bb7f36b2241fbbb07cbca4c2e1b05882af339c4a99891efc0688e23d4f7643e12842e200e18379ac78ec4
6
+ metadata.gz: 78226f45df08e38363d19cf03e3ec7867d0788700abf11daa08d5eca31ff62f3e9c43460d39be9d9422a686ee1a0da35de323df54d49ea06962876a809f3ad06
7
+ data.tar.gz: 76dee36d5fd30560f4bb7ebee50cf1e59d984e70bc219107e2d439024523ed2992056baacb17227e294466f4cff75b58cd96c7bb8fc0b680438ee8bf367e057c
@@ -0,0 +1,92 @@
1
+ env:
2
+ BUILDKITE_PLUGIN_DOCKER_CACHE_S3_BUCKET: "outstand-buildkite-cache"
3
+ # BUILDKITE_PLUGIN_DOCKER_CACHE_VOLUME_DEBUG: "true"
4
+ BUILDKITE_PLUGIN_DOCKER_COMPOSE_SHELL: "false"
5
+ # BUILDKITE_PLUGIN_DOCKER_COMPOSE_UPLOAD_CONTAINER_LOGS: "always"
6
+ BUILDKITE_PLUGIN_DOCKER_COMPOSE_PULL_RETRIES: 5
7
+ BUILDKITE_PLUGIN_DOCKER_COMPOSE_PUSH_RETRIES: 5
8
+ PLUGIN_DOCKER_COMPOSE_VERSION: "17bac3aaee1360e39381b89bb45513b11838238e"
9
+ PLUGIN_DOCKER_CACHE_VERSION: "f3d8feb52a25c69c75565e9f0b80375eae51850a"
10
+
11
+ steps:
12
+ - label: ":docker: Build"
13
+ command: ./docker/ci-prep.sh
14
+ key: build
15
+ plugins:
16
+ - seek-oss/aws-sm#v2.2.1:
17
+ env:
18
+ DOCKER_LOGIN_PASSWORD: "/buildkite/docker_password"
19
+
20
+ - docker-login#v2.0.1:
21
+ username: outstandci
22
+
23
+ - ecr#v2.1.1:
24
+ login: true
25
+ region: "us-east-1"
26
+
27
+ - https://github.com/outstand/docker-compose-buildkite-plugin.git#${PLUGIN_DOCKER_COMPOSE_VERSION}:
28
+ build: metaractor
29
+ image-repository: 786715713882.dkr.ecr.us-east-1.amazonaws.com/ci-images
30
+ config:
31
+ - docker-compose.yml
32
+
33
+ - label: ":bundler: :rubygems:"
34
+ key: bundle_install
35
+ command: bundle install
36
+ depends_on: build
37
+ plugins:
38
+ - seek-oss/aws-sm#v2.2.1:
39
+ env:
40
+ DOCKER_LOGIN_PASSWORD: "/buildkite/docker_password"
41
+
42
+ - docker-login#v2.0.1:
43
+ username: outstandci
44
+
45
+ - ecr#v2.1.1:
46
+ login: true
47
+ region: "us-east-1"
48
+
49
+ - https://github.com/outstand/docker-compose-buildkite-plugin.git#${PLUGIN_DOCKER_COMPOSE_VERSION}:
50
+ run: metaractor
51
+ dependencies: false
52
+ config:
53
+ - docker-compose.yml
54
+
55
+ - https://github.com/outstand/docker-cache-buildkite-plugin.git#${PLUGIN_DOCKER_CACHE_VERSION}:
56
+ name: bundler-cache
57
+ keys:
58
+ - v1-bundler-cache-{{ arch }}-{{ checksum "metaractor.gemspec" }}-{{ checksum "Gemfile" }}
59
+ - v1-bundler-cache-{{ arch }}-
60
+ save: true
61
+ volumes:
62
+ - bundler-data
63
+
64
+ - label: ":ruby: Specs"
65
+ command: rspec spec
66
+ depends_on: bundle_install
67
+ plugins:
68
+ - seek-oss/aws-sm#v2.2.1:
69
+ env:
70
+ DOCKER_LOGIN_PASSWORD: "/buildkite/docker_password"
71
+ json-to-env:
72
+ - secret-id: "/buildkite/rails/env_vars"
73
+
74
+ - docker-login#v2.0.1:
75
+ username: outstandci
76
+
77
+ - ecr#v2.1.1:
78
+ login: true
79
+ region: "us-east-1"
80
+
81
+ - https://github.com/outstand/docker-compose-buildkite-plugin.git#${PLUGIN_DOCKER_COMPOSE_VERSION}:
82
+ run: metaractor
83
+ config:
84
+ - docker-compose.yml
85
+
86
+ - https://github.com/outstand/docker-cache-buildkite-plugin.git#${PLUGIN_DOCKER_CACHE_VERSION}:
87
+ name: bundler-cache
88
+ keys:
89
+ - v1-bundler-cache-{{ arch }}-{{ checksum "metaractor.gemspec" }}-{{ checksum "Gemfile" }}
90
+ - v1-bundler-cache-{{ arch }}-
91
+ volumes:
92
+ - bundler-data
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ /Deskfile
data/.rspec CHANGED
@@ -1,2 +1,4 @@
1
1
  --color
2
2
  --require spec_helper
3
+ --format progress
4
+ --format RSpec::Buildkite::AnnotationFormatter
data/Dockerfile CHANGED
@@ -1,4 +1,6 @@
1
- FROM ruby:2.6.5-alpine
1
+ FROM outstand/fixuid as fixuid
2
+
3
+ FROM ruby:2.7.3-alpine
2
4
  LABEL maintainer="Ryan Schlesinger <ryan@outstand.com>"
3
5
 
4
6
  RUN addgroup -g 1000 -S metaractor && \
@@ -11,7 +13,14 @@ RUN addgroup -g 1000 -S metaractor && \
11
13
  git \
12
14
  openssh
13
15
 
14
- ENV BUNDLER_VERSION 2.1.4
16
+ COPY --from=fixuid /usr/local/bin/fixuid /usr/local/bin/fixuid
17
+ RUN chmod 4755 /usr/local/bin/fixuid && \
18
+ USER=metaractor && \
19
+ GROUP=metaractor && \
20
+ mkdir -p /etc/fixuid && \
21
+ printf "user: $USER\ngroup: $GROUP\n" > /etc/fixuid/config.yml
22
+
23
+ ENV BUNDLER_VERSION 2.2.16
15
24
  RUN gem install bundler -v ${BUNDLER_VERSION} -i /usr/local/lib/ruby/gems/$(ls /usr/local/lib/ruby/gems) --force
16
25
 
17
26
  WORKDIR /metaractor
data/Gemfile CHANGED
@@ -1,4 +1,4 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
- gem 'pry-byebug'
4
+ gem 'rspec-buildkite', github: 'outstand/rspec-buildkite', branch: 'error-output'
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Metaractor
1
+ # Metaractor [![Build status](https://badge.buildkite.com/70063a5154eb7366b8b7fd65a875c5f64301bc60f6d29a2ad7.svg)](https://buildkite.com/outstand/metaractor)
2
2
  Adds parameter validation and error control to [interactor](https://github.com/collectiveidea/interactor).
3
3
 
4
4
  ## Installation
@@ -44,7 +44,7 @@ result.failure?
44
44
  # => true
45
45
  result.valid?
46
46
  # => false
47
- result.errors
47
+ result.error_messages
48
48
  # => ["Required parameters: (user_id or user)"]
49
49
  ```
50
50
 
@@ -68,20 +68,59 @@ Metaractor supports complex required parameter statements and you can chain thes
68
68
  required and: [:token, or: [:recipient_id, :recipient] ]
69
69
  ```
70
70
 
71
+ You can also mark a parameter as required with the `required` option:
72
+ ```ruby
73
+ parameter :user, required: true
74
+ ```
75
+
71
76
  ### Optional Parameters
72
77
  As optional parameters have no enforcement, they are merely advisory.
73
78
  ```ruby
74
79
  optional :enable_logging
75
80
  ```
76
81
 
77
- ### Skipping Blank Parameter Removal
82
+ ### Parameter Options
83
+ Metaractor supports arbitrary parameter options. The following are currently built in.
84
+ Note that you can specify a block of `required` or `optional` parameters and then use
85
+ `parameter` or `parameters` to add options to one or more of them.
86
+
87
+ #### Skipping Blank Parameter Removal
78
88
  By default Metaractor removes blank values that are passed in. You may skip this behavior on a per-parameter basis:
79
89
  ```ruby
80
- allow_blank :name
90
+ parameter :name, allow_blank: true
81
91
  ```
82
92
 
83
93
  You may check to see if a parameter exists via `context.has_key?`.
84
94
 
95
+ #### Default Values
96
+ You can specify a default value for a parameter:
97
+ ```ruby
98
+ optional :role, default: :user
99
+ ```
100
+
101
+ This works with `allow_blank` and can also be anything that responds to `#call`.
102
+ ```ruby
103
+ parameter :role, allow_blank: true, default: -> { context.default_role }
104
+ ```
105
+
106
+ #### Typecasting/Coersion
107
+ You can supply Metaractor with a callable that will typecast incoming parameters:
108
+ ```ruby
109
+ optional :needs_to_be_a_string, type: ->(value) { value.to_s }
110
+ ```
111
+
112
+ You can also configure Metaractor with named types and use them:
113
+ ```ruby
114
+ Metaractor.configure do |config|
115
+ config.register_type(:boolean, ->(value) { ActiveModel::Type::Boolean.new.cast(value) })
116
+ end
117
+ ```
118
+ ```ruby
119
+ required :is_awesome, type: :boolean
120
+ ```
121
+
122
+ **Note**: Typecasters will _not_ be called on `nil` values.
123
+
85
124
  ### Custom Validation
86
125
  Metaractor supports doing custom validation before any user supplied before_hooks run.
87
126
  ```ruby
@@ -104,6 +143,203 @@ before do
104
143
  end
105
144
  ```
106
145
 
146
+ ### Structured Errors
147
+ As of v2.0.0, metaractor supports structured errors.
148
+ ```ruby
149
+ class UpdateUser
150
+ include Metaractor
151
+
152
+ optional :is_admin
153
+ optional :user
154
+
155
+ def call
156
+ fail_with_error!(
157
+ errors: {
158
+ base: 'Invalid configuration',
159
+ is_admin: 'must be true or false',
160
+ user: [ title: 'cannot be blank', username: ['must be unique', 'must not be blank'] ]
161
+ }
162
+ )
163
+ end
164
+ end
165
+
166
+ result = UpdateUser.call
167
+ result.error_messages
168
+ # => [
169
+ # 'Invalid configuration',
170
+ # 'is_admin must be true or false',
171
+ # 'user.title cannot be blank',
172
+ # 'user.username must be unique',
173
+ # 'user.username must not be blank'
174
+ # ]
175
+
176
+ result.errors.full_messages_for(:user)
177
+ # => [
178
+ # 'title cannot be blank',
179
+ # 'username must be unique',
180
+ # 'username must not be blank'
181
+ # ]
182
+
183
+ # The arguments to `slice` are a list of paths.
184
+ # In this case we're asking for the errors under `base` and also
185
+ # the errors found under user _and_ title.
186
+ result.errors.slice(:base, [:user, :title])
187
+ # => {
188
+ # base: 'Invalid configuration',
189
+ # user: { title: 'cannot be blank' }
190
+ # }
191
+
192
+ result.errors.to_h
193
+ # => {
194
+ # base: 'Invalid configuration',
195
+ # is_admin: 'must be true or false',
196
+ # user: {
197
+ # title: 'cannot be blank',
198
+ # username: ['must be unique', 'must not be blank']
199
+ # }
200
+ # }
201
+ ```
202
+
203
+ ### I18n
204
+ As of v3.0.0, metaractor supports i18n along with structured errors.
205
+ ```ruby
206
+ module Users
207
+ class UpdateUser
208
+ include Metaractor
209
+
210
+ optional :is_admin
211
+ optional :user
212
+
213
+ def call
214
+ fail_with_error!(
215
+ errors: {
216
+ base: :invalid_configuration,
217
+ is_admin: :true_or_false,
218
+ user: [ title: :blank, username: [:unique, :blank] ]
219
+ }
220
+ )
221
+ end
222
+ end
223
+ end
224
+ ```
225
+
226
+ Locale:
227
+ ```yaml
228
+ en:
229
+ errors:
230
+ parameters:
231
+ invalid_configuration: 'Invalid configuration'
232
+ blank: '%{parameter} cannot be blank'
233
+ unique: '%{parameter} must be unique'
234
+
235
+ users:
236
+ is_admin:
237
+ true_or_false: 'must be true or false'
238
+ user:
239
+ username:
240
+ unique: 'Username has already been taken'
241
+ ```
242
+
243
+ Metaractor will attempt to use the namespace of the code that reported the error.
244
+ You can see that above with the `users` key in the locale.
245
+
246
+ The i18n integration will walk its way from the most specific message to the least specific one, stopping at the first one it can find.
247
+ We currently expose the following variables for use in the message:
248
+ - `error_key`: the error we added (ex: `blank` or `invalid_configuration`)
249
+ - `parameter`: the name of the parameter
250
+
251
+ You can also use this feature to work with machine readable keys:
252
+ ```ruby
253
+ result = Users::UpdateUser.call
254
+ if result.failure? &&
255
+ result.errors[:is_admin].include?(:true_or_false)
256
+
257
+ # handle this specific case
258
+ end
259
+ ```
260
+
261
+ ### Spec Helpers
262
+ Enable the helpers and/or matchers:
263
+ ```ruby
264
+ RSpec.configure do |config|
265
+ config.include Metaractor::Spec::Helpers
266
+ config.include Metaractor::Spec::Matchers
267
+ end
268
+ ```
269
+
270
+ #### Helpers
271
+ - `context_creator`
272
+ ```ruby
273
+ # context_creator(error_message: nil, error_messages: [], errors: [], valid: nil, invalid: nil, success: nil, failure: nil, **attributes)
274
+
275
+ # Create a blank context:
276
+ context_creator
277
+
278
+ # Create a context with some data:
279
+ context_creator(message: message, user: user)
280
+
281
+ # Create an invalid context:
282
+ context_creator(error_message: "invalid context", invalid: true)
283
+
284
+ # Create a context with string errors:
285
+ context_creator(error_messages: ["That didn't work", "Neither did this"])
286
+
287
+ # Create a context with structured errors:
288
+ context_creator(
289
+ user: user,
290
+ errors: {
291
+ user: {
292
+ email: 'must be unique'
293
+ },
294
+ profile: {
295
+ first_name: 'cannot be blank'
296
+ }
297
+ }
298
+ )
299
+ ```
300
+
301
+ #### Matchers
302
+ - `include_errors`
303
+ ```ruby
304
+ result = context_creator(
305
+ errors: {
306
+ user: [
307
+ title: 'cannot be blank',
308
+ username: ['must be unique', 'must not be blank']
309
+ ]
310
+ }
311
+ )
312
+
313
+ expect(result).to include_errors(
314
+ 'username must be unique',
315
+ 'username must not be blank'
316
+ ).at_path(:user, :username)
317
+
318
+ expect(result).to include_errors('user.title cannot be blank')
319
+ ```
320
+
321
+ ### Hash Formatting
322
+ Metaractor customizes the output for `Metaractor::Errors#inspect` and `Interactor::Failure`:
323
+ ```
324
+ Interactor::Failure:
325
+ Errors:
326
+ {:base=>"NOPE"}
327
+
328
+ Previously Called:
329
+ Chained
330
+
331
+ Context:
332
+ {:parent=>true, :chained=>true}
333
+ ```
334
+
335
+ You can further customize the hash formatting:
336
+ ```ruby
337
+ Metaractor.configure do |config|
338
+ # Configure Metaractor to use awesome_print
339
+ config.hash_formatter = ->(hash) { hash.ai }
340
+ end
341
+ ```
342
+
107
343
  ### Further Reading
108
344
  For more examples of all of the above approaches, please see the specs.
109
345
 
data/docker-compose.yml CHANGED
@@ -5,6 +5,9 @@ services:
5
5
  image: outstand/metaractor:dev
6
6
  stdin_open: true
7
7
  tty: true
8
+ environment:
9
+ FIXUID:
10
+ FIXGID:
8
11
  volumes:
9
12
  - bundler-data:/usr/local/bundle
10
13
  - .:/metaractor
@@ -13,6 +16,9 @@ services:
13
16
  stdin_open: true
14
17
  tty: true
15
18
  command: rake release
19
+ environment:
20
+ FIXUID:
21
+ FIXGID:
16
22
  volumes:
17
23
  - bundler-data:/usr/local/bundle
18
24
  - ~/.gitconfig:/home/metaractor/.gitconfig
data/docker-entrypoint.sh CHANGED
@@ -1,6 +1,19 @@
1
1
  #!/bin/sh
2
+
2
3
  set -e
3
4
 
5
+ su-exec ${FIXUID:?Missing FIXUID var}:${FIXGID:?Missing FIXGID var} fixuid
6
+
7
+ chown_dir() {
8
+ dir=$1
9
+ if [[ -d ${dir} ]] && [[ "$(stat -c %u:%g ${dir})" != "${FIXUID}:${FIXGID}" ]]; then
10
+ echo chown $dir
11
+ chown metaractor:metaractor $dir
12
+ fi
13
+ }
14
+
15
+ chown_dir /usr/local/bundle
16
+
4
17
  if [ "$(which "$1")" = '' ]; then
5
18
  if [ "$(ls -A /usr/local/bundle/bin)" = '' ]; then
6
19
  echo 'command not in path and bundler not initialized'
data/lib/metaractor.rb CHANGED
@@ -9,6 +9,9 @@ require 'metaractor/context_validity'
9
9
  require 'metaractor/chain_failures'
10
10
  require 'metaractor/fail_from_context'
11
11
  require 'metaractor/context_has_key'
12
+ require 'metaractor/failure_output'
13
+ require 'i18n'
14
+ require 'metaractor/namespace'
12
15
 
13
16
  module Metaractor
14
17
  def self.included(base)
@@ -42,7 +45,8 @@ module Metaractor
42
45
  { module: Metaractor::HandleErrors, method: :include },
43
46
  { module: Metaractor::Parameters, method: :include },
44
47
  { module: Metaractor::RunWithContext, method: :include },
45
- { module: Metaractor::ChainFailures, method: :include }
48
+ { module: Metaractor::ChainFailures, method: :include },
49
+ { module: Metaractor::Namespace, method: :include }
46
50
  ]
47
51
  end
48
52
 
@@ -53,4 +57,36 @@ module Metaractor
53
57
  def self.prepend_module(mod)
54
58
  modules << { module: mod, method: :prepend }
55
59
  end
60
+
61
+ def self.format_hash(hash)
62
+ if @hash_formatter.nil?
63
+ @hash_formatter = default_hash_formatter
64
+ end
65
+
66
+ @hash_formatter.call(hash)
67
+ end
68
+
69
+ def self.default_hash_formatter
70
+ ->(hash){ hash.inspect }
71
+ end
72
+
73
+ def self.hash_formatter
74
+ @hash_formatter
75
+ end
76
+
77
+ def self.hash_formatter=(callable)
78
+ @hash_formatter = callable
79
+ end
80
+
81
+ def self.types
82
+ @types ||= {}
83
+ end
84
+
85
+ def self.register_type(type, callable)
86
+ types[type] = callable
87
+ end
88
+
89
+ def self.clear_types!
90
+ @types = {}
91
+ end
56
92
  end
@@ -8,29 +8,29 @@ module Metaractor
8
8
  super
9
9
  end
10
10
 
11
- def fail_with_error!(message: nil, errors: nil)
12
- add_error(message: message, errors: errors)
11
+ def fail_with_error!(message: nil, errors: nil, **args)
12
+ add_error(message: message, errors: errors, **args)
13
13
  fail!
14
14
  end
15
15
 
16
- def fail_with_errors!(messages: [], errors: {})
17
- add_errors(messages: messages, errors: errors)
16
+ def fail_with_errors!(messages: [], errors: {}, **args)
17
+ add_errors(messages: messages, errors: errors, **args)
18
18
  fail!
19
19
  end
20
20
 
21
- def add_error(message: nil, errors: nil)
21
+ def add_error(message: nil, errors: nil, **args)
22
22
  if message.nil?
23
- add_errors(errors: errors)
23
+ add_errors(errors: errors, **args)
24
24
  else
25
- add_errors(messages: Array(message))
25
+ add_errors(messages: Array(message), **args)
26
26
  end
27
27
  end
28
28
 
29
- def add_errors(messages: [], errors: {})
29
+ def add_errors(messages: [], errors: {}, **args)
30
30
  if !messages.empty?
31
- self.errors.add(errors: { base: messages })
31
+ self.errors.add(errors: { base: messages }, **args)
32
32
  else
33
- self.errors.add(errors: errors)
33
+ self.errors.add(errors: errors, **args)
34
34
  end
35
35
  end
36
36
 
@@ -4,13 +4,72 @@ module Metaractor
4
4
  class Errors
5
5
  extend Forwardable
6
6
 
7
+ class Error
8
+ attr_reader :value, :object
9
+
10
+ def initialize(value:, object: nil)
11
+ @value = value
12
+ @object = object
13
+ end
14
+
15
+ def generate_message(path_elements:)
16
+ if @value.is_a? Symbol
17
+ defaults = []
18
+
19
+ if object.class.respond_to?(:i18n_parent_names) &&
20
+ !object.class.i18n_parent_names.empty?
21
+
22
+ names = object.class.i18n_parent_names
23
+ until names.empty?
24
+ defaults << ['errors', names.join('.'), 'parameters', path_elements.join('.'), @value.to_s].reject do |item|
25
+ item.nil? || item == ''
26
+ end.join('.').to_sym
27
+ names.pop
28
+ end
29
+ end
30
+
31
+ unless path_elements.empty?
32
+ defaults << :"errors.parameters.#{path_elements.join('.')}.#{@value}"
33
+ end
34
+ defaults << :"errors.parameters.#{@value}"
35
+
36
+ key = defaults.shift
37
+ I18n.translate(
38
+ key,
39
+ default: defaults,
40
+ error_key: @value,
41
+ parameter: path_elements.last
42
+ )
43
+ else
44
+ "#{path_elements.join('.')} #{@value}".lstrip
45
+ end
46
+ end
47
+
48
+ def ==(other)
49
+ if other.is_a?(self.class)
50
+ @value == other.value
51
+ else
52
+ @value == other
53
+ end
54
+ end
55
+ alias eql? ==
56
+
57
+ def hash
58
+ @value.hash
59
+ end
60
+
61
+ def inspect
62
+ "(Error) #{@value.inspect}"
63
+ end
64
+ end
65
+
7
66
  def initialize
8
67
  @tree = Sycamore::Tree.new
9
68
  end
10
69
 
11
- def_delegators :@tree, :to_h, :empty?
70
+ def_delegators :@tree, :empty?
12
71
 
13
- def add(error: {}, errors: {})
72
+ def add(error: {}, errors: {}, object: nil)
14
73
  trees = []
15
74
  [error, errors].each do |h|
16
75
  tree = nil
@@ -29,7 +88,17 @@ module Metaractor
29
88
  end
30
89
 
31
90
  trees.each do |tree|
32
- @tree.add(tree)
91
+ tree.each_path do |path|
92
+ node = path.node
93
+ unless node.is_a?(Error)
94
+ node = Error.new(
95
+ value: path.node,
96
+ object: object
97
+ )
98
+ end
99
+
100
+ @tree[path.parent] << node
101
+ end
33
102
  end
34
103
  @tree.compact
35
104
  end
@@ -58,9 +127,9 @@ module Metaractor
58
127
  result = @tree.dig(*path)
59
128
 
60
129
  if result.strict_leaves?
61
- result.nodes
130
+ unwrapped_enum(result.nodes)
62
131
  else
63
- result.to_h
132
+ unwrapped_tree(result).to_h
64
133
  end
65
134
  end
66
135
  alias [] dig
@@ -68,9 +137,15 @@ module Metaractor
68
137
  def include?(*elements)
69
138
  if elements.size == 1 &&
70
139
  elements.first.is_a?(Hash)
71
- @tree.include?(*elements)
140
+ unwrapped_tree.include?(*elements)
72
141
  else
73
- full_messages.include?(*elements)
142
+ if elements.all? {|e| e.is_a? String }
143
+ full_messages.include?(*elements)
144
+ else
145
+ elements.all? do |element|
146
+ @tree.include_path?(element)
147
+ end
148
+ end
74
149
  end
75
150
  end
76
151
 
@@ -83,7 +158,29 @@ module Metaractor
83
158
  end
84
159
  end
85
160
 
86
- new_tree.to_h
161
+ unwrapped_tree(new_tree).to_h
162
+ end
163
+
164
+ def to_h(unwrap: true)
165
+ if unwrap
166
+ unwrapped_tree.to_h
167
+ else
168
+ @tree.to_h
169
+ end
170
+ end
171
+ alias to_hash to_h
172
+
173
+ def inspect
174
+ str = "<##{self.class.name}: "
175
+
176
+ if !self.empty?
177
+ str << "Errors:\n"
178
+ str << Metaractor.format_hash(to_h(unwrap: false))
179
+ str << "\n"
180
+ end
181
+
182
+ str << ">"
183
+ str
87
184
  end
88
185
 
89
186
  private
@@ -96,7 +193,32 @@ module Metaractor
96
193
  end
97
194
  end
98
195
 
99
- "#{path_elements.join('.')} #{path.node.to_s}".lstrip
196
+ path.node.generate_message(path_elements: path_elements)
100
197
  end
198
+
199
+ def unwrapped_tree(orig_tree = @tree)
200
+ tree = Sycamore::Tree.new
201
+ orig_tree.each_path do |path|
202
+ node = path.node
203
+ if node.is_a? Error
204
+ node = node.value
205
+ end
206
+
207
+ tree[path.parent] << node
208
+ end
209
+
210
+ tree
211
+ end
212
+
213
+ def unwrapped_enum(orig)
214
+ orig.map do |element|
215
+ if element.is_a? Error
216
+ element.value
217
+ else
218
+ element
219
+ end
220
+ end
221
+ end
222
+
101
223
  end
102
224
  end
@@ -0,0 +1,28 @@
1
+ module Metaractor
2
+ module FailureOutput
3
+ def to_s
4
+ str = ''
5
+
6
+ if !context.errors.empty?
7
+ str << "Errors:\n"
8
+ str << Metaractor.format_hash(context.errors.to_h)
9
+ str << "\n\n"
10
+ end
11
+
12
+ if !context._called.empty?
13
+ str << "Previously Called:\n"
14
+ context._called.each do |interactor|
15
+ str << interactor.class.name.to_s
16
+ str << "\n"
17
+ end
18
+ str << "\n"
19
+ end
20
+
21
+ str << "Context:\n"
22
+ str << Metaractor.format_hash(context.to_h.reject{|k,_| k == :errors})
23
+ str
24
+ end
25
+ end
26
+ end
27
+
28
+ Interactor::Failure.send(:include, Metaractor::FailureOutput)
@@ -3,20 +3,20 @@ module Metaractor
3
3
  class InvalidError < Error; end
4
4
 
5
5
  module HandleErrors
6
- def fail_with_error!(*args)
7
- context.fail_with_error!(*args)
6
+ def fail_with_error!(**args)
7
+ context.fail_with_error!(object: self, **args)
8
8
  end
9
9
 
10
- def fail_with_errors!(*args)
11
- context.fail_with_errors!(*args)
10
+ def fail_with_errors!(**args)
11
+ context.fail_with_errors!(object: self, **args)
12
12
  end
13
13
 
14
- def add_error(*args)
15
- context.add_error(*args)
14
+ def add_error(**args)
15
+ context.add_error(object: self, **args)
16
16
  end
17
17
 
18
- def add_errors(*args)
19
- context.add_errors(*args)
18
+ def add_errors(**args)
19
+ context.add_errors(object: self, **args)
20
20
  end
21
21
 
22
22
  def error_messages
@@ -0,0 +1,46 @@
1
+ module Metaractor
2
+ module Namespace
3
+ # The following code is adapted from rails.
4
+
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def module_parent_name
11
+ if defined?(@parent_name)
12
+ @parent_name
13
+ else
14
+ parent_name = name =~ /::[^:]+\z/ ? -$` : nil
15
+ @parent_name = parent_name unless frozen?
16
+ parent_name
17
+ end
18
+ end
19
+
20
+ def module_parent_names
21
+ parents = []
22
+ if module_parent_name
23
+ parents = module_parent_name.split("::")
24
+ end
25
+ parents
26
+ end
27
+
28
+ def i18n_parent_names
29
+ module_parent_names.map {|name| underscore_module_name(name).to_sym }
30
+ end
31
+
32
+ private
33
+
34
+ def underscore_module_name(camel_cased_word)
35
+ return camel_cased_word unless /[A-Z-]|::/.match?(camel_cased_word)
36
+ word = camel_cased_word.to_s.gsub("::", "/")
37
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
38
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
39
+ word.tr!("-", "_")
40
+ word.downcase!
41
+ word
42
+ end
43
+
44
+ end
45
+ end
46
+ end
@@ -1,3 +1,5 @@
1
+ # Special thanks to the `hashie` and `active_attr` gems for code and inspiration.
2
+
1
3
  module Metaractor
2
4
  module Parameters
3
5
  def self.included(base)
@@ -6,40 +8,93 @@ module Metaractor
6
8
  include Metaractor::HandleErrors
7
9
 
8
10
  class << self
9
- attr_writer :_required_parameters
10
- attr_writer :_optional_parameters
11
- attr_writer :_allow_blank
11
+ attr_writer :requirement_trees
12
12
  end
13
13
 
14
14
  before :remove_blank_values
15
+ before :apply_defaults
15
16
  before :validate_required_parameters
17
+ before :apply_types
16
18
  end
17
19
  end
18
20
 
21
+ class Parameter
22
+ include Comparable
23
+
24
+ attr_reader :name
25
+
26
+ def initialize(name, **options)
27
+ @name = name.to_sym
28
+ @options = options
29
+ end
30
+
31
+ def <=>(other)
32
+ return nil unless other.instance_of? self.class
33
+ return nil if name == other.name && options != other.options
34
+ self.name.to_s <=> other.name.to_s
35
+ end
36
+
37
+ def [](key)
38
+ @options[key]
39
+ end
40
+
41
+ def dig(name, *names)
42
+ @options.dig(name, *names)
43
+ end
44
+
45
+ def merge!(**options)
46
+ @options.merge!(**options)
47
+ end
48
+
49
+ def to_s
50
+ name.to_s
51
+ end
52
+
53
+ def to_sym
54
+ name
55
+ end
56
+
57
+ protected
58
+ attr_reader :options
59
+ end
60
+
19
61
  module ClassMethods
20
- def _required_parameters
21
- @_required_parameters ||= []
62
+ def parameter(name, **options)
63
+ if param = self.parameter_hash[name.to_sym]
64
+ param.merge!(**options)
65
+ else
66
+ Parameter.new(name, **options).tap do |parameter|
67
+ self.parameter_hash[parameter.name] = parameter
68
+ end
69
+ end
22
70
  end
23
71
 
24
- def required(*params)
25
- self._required_parameters += params
72
+ def parameters(*names, **options)
73
+ names.each do |name|
74
+ parameter(name, **options)
75
+ end
26
76
  end
27
- alias_method :required_parameters, :required
28
77
 
29
- def _optional_parameters
30
- @_optional_parameters ||= []
78
+ def parameter_hash
79
+ @parameters ||= {}
31
80
  end
32
81
 
33
- def optional(*params)
34
- self._optional_parameters += params
82
+ def requirement_trees
83
+ @requirement_trees ||= []
35
84
  end
36
85
 
37
- def _allow_blank
38
- @_allow_blank ||= []
86
+ def required(*params, **options)
87
+ if params.empty?
88
+ tree = options
89
+ self.requirement_trees << tree
90
+ parameters(*parameters_in_tree(tree), required: tree)
91
+ else
92
+ parameters(*params, required: true, **options)
93
+ end
39
94
  end
40
95
 
41
- def allow_blank(*params)
42
- self._allow_blank += params
96
+ def optional(*params, **options)
97
+ parameters(*params, **options)
43
98
  end
44
99
 
45
100
  def validate_parameters(*hooks, &block)
@@ -50,26 +105,91 @@ module Metaractor
50
105
  def validate_hooks
51
106
  @validate_hooks ||= []
52
107
  end
108
+
109
+ def parameters_in_tree(tree)
110
+ if tree.respond_to?(:to_h)
111
+ tree.to_h.values.first.to_a.flat_map {|t| parameters_in_tree(t)}
112
+ else
113
+ [tree]
114
+ end
115
+ end
116
+ end
117
+
118
+ def parameters
119
+ self.class.parameter_hash
120
+ end
121
+
122
+ def requirement_trees
123
+ self.class.requirement_trees
124
+ end
125
+
126
+ def requirement_trees=(trees)
127
+ self.class.requirement_trees=(trees)
53
128
  end
54
129
 
55
130
  def remove_blank_values
56
131
  to_delete = []
57
- context.each_pair do |k,v|
58
- next if self.class._allow_blank.include?(k)
132
+ context.each_pair do |name, value|
133
+ next if parameters.dig(name, :allow_blank)
59
134
 
60
135
  # The following regex is borrowed from Rails' String#blank?
61
- to_delete << k if (v.is_a?(String) && /\A[[:space:]]*\z/ === v) || v.nil?
136
+ to_delete << name if (value.is_a?(String) && /\A[[:space:]]*\z/ === value) || value.nil?
62
137
  end
63
- to_delete.each do |k|
64
- context.delete_field k
138
+
139
+ to_delete.each do |name|
140
+ context.delete_field name
141
+ end
142
+ end
143
+
144
+ def apply_defaults
145
+ parameters.each do |name, parameter|
146
+ next unless parameter[:default]
147
+
148
+ unless context.has_key?(name)
149
+ context[name] = _parameter_default(name)
150
+ end
151
+ end
152
+ end
153
+
154
+ def _parameter_default(name)
155
+ default = self.parameters[name][:default]
156
+
157
+ case
158
+ when default.respond_to?(:call) then instance_exec(&default)
159
+ when default.respond_to?(:dup) then default.dup
160
+ else default
161
+ end
162
+ end
163
+
164
+ def apply_types
165
+ parameters.each do |name, parameter|
166
+ next unless parameter[:type]
167
+
168
+ if context.has_key?(name) && context[name] != nil
169
+ callable = parameter[:type]
170
+
171
+ if callable.is_a?(Symbol)
172
+ callable = Metaractor.types[callable]
173
+ raise ArgumentError, "No such type: #{parameter[:type]}" if callable.nil?
174
+ end
175
+
176
+ context[name] = callable.call(context[name])
177
+ end
65
178
  end
66
179
  end
67
180
 
68
181
  def validate_required_parameters
69
182
  context.errors ||= []
70
183
 
71
- self.class._required_parameters.each do |param|
72
- require_parameter param
184
+ parameters.each do |name, parameter|
185
+ next if !parameter[:required] ||
186
+ parameter[:required].is_a?(Hash)
187
+
188
+ require_parameter name
189
+ end
190
+
191
+ requirement_trees.each do |tree|
192
+ require_parameter tree
73
193
  end
74
194
 
75
195
  run_validate_hooks
@@ -1,3 +1,3 @@
1
1
  module Metaractor
2
- VERSION = "2.0.0"
2
+ VERSION = "3.1.0"
3
3
  end
data/metaractor.gemspec CHANGED
@@ -11,7 +11,10 @@ Gem::Specification.new do |spec|
11
11
  spec.email = ['ryan@outstand.com']
12
12
 
13
13
  spec.summary = %q{Adds parameter validation and error control to interactor}
14
- spec.homepage = 'https://github.com/outstand/metaractor'
14
+ spec.metadata = {
15
+ "homepage_uri" => "https://github.com/outstand/metaractor",
16
+ "source_code_uri" => "https://github.com/outstand/metaractor"
17
+ }
15
18
 
16
19
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
20
  spec.bindir = 'exe'
@@ -20,8 +23,12 @@ Gem::Specification.new do |spec|
20
23
 
21
24
  spec.add_runtime_dependency 'interactor', '~> 3.1'
22
25
  spec.add_runtime_dependency 'sycamore', '~> 0.3'
26
+ spec.add_runtime_dependency 'i18n', '~> 1.8'
23
27
 
24
28
  spec.add_development_dependency 'bundler', '~> 2'
25
29
  spec.add_development_dependency 'rake', '~> 13.0'
26
30
  spec.add_development_dependency 'rspec', '~> 3.9'
31
+ spec.add_development_dependency 'awesome_print', '~> 1.8'
32
+ spec.add_development_dependency 'pry-byebug', '~> 3.9'
33
+ spec.add_development_dependency 'activemodel', '~> 6.1'
27
34
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metaractor
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Schlesinger
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-03-17 00:00:00.000000000 Z
11
+ date: 2021-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: interactor
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: i18n
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.8'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: bundler
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -80,13 +94,56 @@ dependencies:
80
94
  - - "~>"
81
95
  - !ruby/object:Gem::Version
82
96
  version: '3.9'
83
- description:
97
+ - !ruby/object:Gem::Dependency
98
+ name: awesome_print
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.8'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.8'
111
+ - !ruby/object:Gem::Dependency
112
+ name: pry-byebug
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.9'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: activemodel
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '6.1'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '6.1'
139
+ description:
84
140
  email:
85
141
  - ryan@outstand.com
86
142
  executables: []
87
143
  extensions: []
88
144
  extra_rdoc_files: []
89
145
  files:
146
+ - ".buildkite/pipeline.yml"
90
147
  - ".gitignore"
91
148
  - ".rspec"
92
149
  - Dockerfile
@@ -103,17 +160,21 @@ files:
103
160
  - lib/metaractor/context_validity.rb
104
161
  - lib/metaractor/errors.rb
105
162
  - lib/metaractor/fail_from_context.rb
163
+ - lib/metaractor/failure_output.rb
106
164
  - lib/metaractor/handle_errors.rb
165
+ - lib/metaractor/namespace.rb
107
166
  - lib/metaractor/parameters.rb
108
167
  - lib/metaractor/run_with_context.rb
109
168
  - lib/metaractor/spec.rb
110
169
  - lib/metaractor/version.rb
111
170
  - metaractor.gemspec
112
- homepage: https://github.com/outstand/metaractor
171
+ homepage:
113
172
  licenses:
114
173
  - Apache-2.0
115
- metadata: {}
116
- post_install_message:
174
+ metadata:
175
+ homepage_uri: https://github.com/outstand/metaractor
176
+ source_code_uri: https://github.com/outstand/metaractor
177
+ post_install_message:
117
178
  rdoc_options: []
118
179
  require_paths:
119
180
  - lib
@@ -128,8 +189,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
189
  - !ruby/object:Gem::Version
129
190
  version: '0'
130
191
  requirements: []
131
- rubygems_version: 3.0.3
132
- signing_key:
192
+ rubygems_version: 3.1.6
193
+ signing_key:
133
194
  specification_version: 4
134
195
  summary: Adds parameter validation and error control to interactor
135
196
  test_files: []