gl_command 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d64529ae90558047a1a67fb4641fcbc6807f4bac0ef8b503945d2981dac33f80
4
- data.tar.gz: 541c75d629f1ba4971ab7d6fd37ac4a1fdfee2f19cb6256477d4857408fb64ce
3
+ metadata.gz: 5c1d353b0428b6aa32adf96b5962256b415dfacc45116d3e4a21ea8ca345de8e
4
+ data.tar.gz: f8056f5711ef8ffdfafd3678fd25c05fa1879f2893f1d9acecaa9a3880565d7a
5
5
  SHA512:
6
- metadata.gz: 7767272a19c79ed50bc21bcd5a94a04f8f16c26e509bdfc3b86937e8204dabf2d053ea2fc85919acc56de3a770fd35ff88490b2d101fdcf6e6a89a714c55176f
7
- data.tar.gz: f4fd2347bedf5ccbd94c09e230bf760d16d0ec1f19a4ecada73416d7a4b0da52a7f79759f074c4a38f98cd783ce45a07dd84c46dd1f13b87685cfdec8d0e9c9e
6
+ metadata.gz: 8229ae95a48c6b5fc83fbeb647d604666be82cc2e35b08d294c6c8c4c7e44ec4c52d40f520ead9005d38edeee0aab9fc03f0db9e637271b2fd1dbd98619efd21
7
+ data.tar.gz: 678f19f03349988f5a033eeb1f5cff6acb846e0c2b5eea744b7493e0419232d7e0e56f2b4684c9857f0d5e78d7119b9147bef86cf0b5ac642056f9b67365cd8d
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GLCommand
2
2
 
3
- `GLCommand` is a way to encapsulate business logic.
3
+ `GLCommand` is a way to encapsulate business logic and standardize error handling.
4
4
 
5
5
  Calling a command returns a `GLCommand::Context` which has these properties:
6
6
 
@@ -10,6 +10,22 @@ Calling a command returns a `GLCommand::Context` which has these properties:
10
10
  - `full_error_message` - which renders a string from the error, or can be set explicitly (used to show a legible error to the user).
11
11
  - `success` - `true` if the command executed without an error (false if there is an `error`)
12
12
 
13
+ # Table of contents
14
+
15
+ - [Installation](#installation)
16
+ - [Using GLCommand](#using-glcommand)
17
+ - [Success/Failure](#successfailure)
18
+ - [Displaying errors (use `full_error_message`)](#displaying-errors-use-full_error_message)
19
+ - [stop_and_fail!](#stop_and_fail)
20
+ - [Validations](#validations)
21
+ - [Best practices for error handling](#best-practices-for-error-handling)
22
+ - [GLExceptionNotifier](#glexceptionnotifier)
23
+ - [Chainable](#chainable)
24
+ - [Testing `GLCommand`s](#testing-glcommands)
25
+ - [Stubbing with `build_context`](#stubbing-with-build_context)
26
+ - [Rspec matchers](#rspec-matchers)
27
+ - [Publishing the gem to Rubygems](#publishing-the-gem-to-rubygems)
28
+
13
29
 
14
30
  ## Installation
15
31
 
@@ -50,7 +66,7 @@ class SomeCommand < GLCommand::Callable
50
66
  end
51
67
  ```
52
68
 
53
- ## Success/Failure
69
+ ### Success/Failure
54
70
 
55
71
  GLCommand context's are successful by default (`successful?` aliases `success?`).
56
72
 
@@ -70,13 +86,14 @@ Here are the ways of adding an error to a command:
70
86
 
71
87
  If you invoke a command with `.call!` all of the above will raise an exception
72
88
 
73
- If a command fails, it will call its `rollback` method before returning (even when invoked with `.call!`)
89
+ If a command fails, it calls its `rollback` method before returning (even when invoked with `.call!`)
74
90
 
75
- ### Displaying errors
91
+
92
+ ### Displaying errors (use `full_error_message`)
76
93
 
77
94
  In addition to encapsulating business logic, GLCommand also standardizes error handling.
78
95
 
79
- This means that rather than having to rescue errors in controllers, you can just render the command's `full_error_message`
96
+ This means that rather than having to rescue errors in controllers, just render the command's `full_error_message`
80
97
 
81
98
  ```ruby
82
99
  result = GLCommand::Callable.call(params)
@@ -88,7 +105,7 @@ else
88
105
  end
89
106
  ```
90
107
 
91
- In general, use `context.full_error_message` to render errors.
108
+ In general, use `context.full_error_message` to render errors (rather than `context.error.message` which might not have the full error message text).
92
109
 
93
110
 
94
111
  ### `stop_and_fail!`
@@ -130,16 +147,45 @@ stop_and_fail!('An error message', no_notify: true) # GLExceptionNotifier is *no
130
147
 
131
148
  ### Validations
132
149
 
133
- You can add validations to `GLCommand::Callable` and `GLCommand::Chainable`.
150
+ You can add validations to `GLCommand::Callable` and `GLCommand::Chainable`. They include `ActiveModel::Validations`, so you can use [Rails active record validations](https://guides.rubyonrails.org/active_record_validations.html).
151
+
152
+ If the validations fail, the command returns `success: false` without executing and if validations fail, `GLExceptionNotifier` is **not** called.
153
+
154
+ ```ruby
155
+ class ExampleCommand < GLCommand::Callable
156
+ validates :name, presence: true
157
+ validate :name_must_start_with_cool
158
+
159
+ def name_must_start_with_cool
160
+ return true unless name.start_with?('cool')
161
+
162
+ errors.add(:name, "Doesn't start with 'cool'")
163
+ end
164
+ end
165
+ ```
166
+
167
+ ### Best practices for error handling
134
168
 
135
- If the validations fail, the command returns `success: false` without executing.
169
+ #### Only add validation errors in validations
136
170
 
137
- If validations fail, `GLExceptionNotifier` is not called
171
+ i.e. don't use `errors.add` in the `call` method. Use `stop_and_fail!` instead.
138
172
 
173
+ #### Prefer raising the original error
174
+
175
+ For example, if you want to raise a custom error message, don't rescue and then `stop_and_fail!('Some special error message')`. Do this instead:
176
+
177
+ ```ruby
178
+ rescue StandardError => e
179
+ context.full_error_message = "Some special error message"
180
+ raise e
181
+ end
182
+ ```
183
+
184
+ This will preserve the original error and stack trace, which makes it easier to debug and track down issues.
139
185
 
140
186
  ## GLExceptionNotifier
141
187
 
142
- [ExceptionNotifier](https://github.com/givelively/gl_exception_notifier) is Give Lively's wrapper for notify our error monitoring service (currently [Sentry](https://github.com/getsentry/sentry-ruby))
188
+ [GLExceptionNotifier](https://github.com/givelively/gl_exception_notifier) is Give Lively's wrapper for notify our error monitoring service (currently [Sentry](https://github.com/getsentry/sentry-ruby))
143
189
 
144
190
  When a command fails `GLExceptionNotifier` is called, unless:
145
191
 
@@ -147,7 +193,7 @@ When a command fails `GLExceptionNotifier` is called, unless:
147
193
  - The failure is a validation failure
148
194
  - `stop_and_fail!` is called with `no_notify: true` - for example `stop_and_fail!('An error message', no_notify: true)`
149
195
 
150
- **NOTE:** commands that invoke other commands with `call!` inherit the no_notify property of called command.
196
+ **NOTE:** commands that invoke other commands with `call!` inherit the `no_notify` property of the called command.
151
197
 
152
198
  ```ruby
153
199
  class InteriorCommand < GLCommand::Callable
@@ -211,9 +257,104 @@ class SomeChain < GLCommand::Chainable
211
257
  chain(:item)
212
258
  end
213
259
  end
260
+ ```
261
+
262
+ ## RSpec Matchers
263
+
264
+ `GLCommand` comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
265
+
266
+ ### Setup
267
+
268
+ To enable the matchers, add the following line to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:
269
+
270
+ ```ruby
271
+ require 'gl_command/rspec'
272
+ ```
273
+
274
+ This will automatically include the necessary matchers and configure RSpec for specs marked with `type: :command`.
275
+
276
+ ### Usage
277
+
278
+ You can now test your command's interface like this:
279
+
280
+ ```ruby
281
+ # spec/commands/some_command_spec.rb
282
+
283
+ RSpec.describe SomeCommand, type: :command do
284
+ describe 'interface' do
285
+ it { is_expected.to require(:user).being(User) }
286
+ it { is_expected.to allow(:subject) }
287
+ it { is_expected.to returns(:message) }
288
+ it { is_expected.not_to require(:other_thing) }
289
+ end
290
+ end
291
+ ```
292
+
293
+ **Note:** The `.being(ClassName)` chain is supported for `require` and `allow` but not for `return`, as `GLCommand` does not store type information for return values.
294
+
295
+ ## Testing `GLCommand`s
296
+
297
+ Give Lively uses Rspec for testing, so this section assumes you're using RSpec.
298
+
299
+ ### Stubbing with `build_context`
300
+
301
+ If you need the response from a command (typically because you are stubbing it), use the `build_context` method to create a context with the desired response. This has the advantage of using the actual Command's `requires`, `allows`, and `returns` methods.
302
+
303
+ ```ruby
304
+ class SomeCommand < GLCommand::Callable
305
+ requires user: User
306
+ allows :subject
307
+ returns :message
308
+
309
+ def call
310
+ user.update!(subject:)
311
+ context.message = "Hello - user subject: #{user.subject}"
312
+ end
313
+ end
314
+
315
+ result = SomeCommand.build_context(user: User.new, message: "Hello!")
316
+ result.success? # true
317
+ result.full_error_message # nil
318
+ result_error = SomeCommand.build_context(error: "invalid")
319
+ result_error.success? # false
320
+ result_error.full_error_message # "invalid"
214
321
 
322
+ SomeCommand.build_context(other_thing: "some other thing")
323
+ # ArgumentError: Unknown argument or return attribute: 'other_thing'
324
+ ```
325
+
326
+ ### RSpec Matchers
327
+
328
+ `GLCommand` comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
329
+
330
+ ### Setup
331
+
332
+ To enable the matchers, add the following line to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:
333
+
334
+ ```ruby
335
+ require 'gl_command/rspec'
336
+ ```
337
+
338
+ This will automatically include the necessary matchers and configure RSpec for specs marked with `type: :command`.
339
+
340
+ ### Usage
341
+
342
+ You can now test your command's interface like this:
343
+
344
+ ```ruby
345
+ # spec/commands/some_command_spec.rb
346
+
347
+ RSpec.describe SomeCommand, type: :command do
348
+ describe 'interface' do
349
+ it { is_expected.to require(:user).being(User) }
350
+ it { is_expected.to allow(:subject) }
351
+ it { is_expected.to returns(:message) }
352
+ it { is_expected.not_to require(:other_thing) }
353
+ end
354
+ end
215
355
  ```
216
356
 
357
+ **Note:** The `.being(ClassName)` chain is supported for `require` and `allow` but not for `return`, as `GLCommand` does not store type information for return values.
217
358
 
218
359
  ## Publishing the gem to Rubygems
219
360
 
@@ -214,12 +214,15 @@ module GLCommand
214
214
  raise ArgumentError, "unknown #{error_keys_str(unknown)}" if unknown.any?
215
215
 
216
216
  # strong_attributes type checking
217
+ # type can be a class (e.g. String), a symbol naming a predicate method
218
+ # the value must answer truthily (e.g. :acts_as_syncable?), or an array of
219
+ # either (e.g. [User, AdminUser]), in which case the value may match any
217
220
  self.class.requires.merge(self.class.allows).each do |arg, type|
218
- next if type.nil? || args[arg].is_a?(type)
221
+ next if type.nil? || Array(type).any? { |t| value_matches_type?(args[arg], t) }
219
222
  # Validation skipped if allows and nil (but not if blank)
220
223
  next if args[arg].nil? && self.class.allows.include?(arg)
221
224
 
222
- raise GLCommand::ArgumentTypeError, ":#{arg} is not a #{type}"
225
+ raise GLCommand::ArgumentTypeError, ":#{arg} is not #{type_error_str(type)}"
223
226
  end
224
227
  end
225
228
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -227,5 +230,21 @@ module GLCommand
227
230
  def error_keys_str(keys)
228
231
  "keyword#{keys.count > 1 ? 's' : ''}: #{keys.map { |k| ":#{k}" }.join(', ')}"
229
232
  end
233
+
234
+ # A type can be a class (checked with is_a?) or a symbol naming a predicate
235
+ # method the value must respond to and return truthy from
236
+ def value_matches_type?(value, type)
237
+ if type.is_a?(Symbol)
238
+ value.respond_to?(type) && value.public_send(type)
239
+ else
240
+ value.is_a?(type)
241
+ end
242
+ end
243
+
244
+ def type_error_str(type)
245
+ return "one of #{type.join(', ')}" if type.is_a?(Array)
246
+
247
+ type.is_a?(Symbol) ? type.to_s : "a #{type}"
248
+ end
230
249
  end
231
250
  end
@@ -27,7 +27,7 @@ module GLCommand
27
27
 
28
28
  # If someone calls #errors, they expect to get the errors! Include the non-validation error, if it exists
29
29
  def errors
30
- current_errors&.add(:base, "Command Error: #{full_error_message}") if add_command_error?
30
+ current_errors&.add(:base, full_error_message) if add_command_error?
31
31
  current_errors
32
32
  end
33
33
 
@@ -126,7 +126,10 @@ module GLCommand
126
126
  private
127
127
 
128
128
  def current_errors
129
- @callable&.errors
129
+ return @callable.errors if defined?(@callable)
130
+
131
+ # @standalone_errors only is instantiated when you use build_context
132
+ (@standalone_errors ||= ActiveModel::Errors.new(self))
130
133
  end
131
134
 
132
135
  def add_command_error?
@@ -136,7 +139,7 @@ module GLCommand
136
139
 
137
140
  # Add command error unless the existing error is a validation error or there's already a command error
138
141
  @error&.class != ActiveRecord::RecordInvalid &&
139
- current_errors.full_messages.none? { |err| err.start_with?('Command Error: ') }
142
+ current_errors.full_messages.none? { |err| err == @error.message }
140
143
  end
141
144
 
142
145
  def exception?(passed_error)
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is intended to be required by lib/gl_command/rspec.rb
4
+
5
+ module GLCommand
6
+ module Matchers
7
+ # Base matcher for `requires` and `allows`, which store a Hash of { attribute: Type }.
8
+ class CommandArgumentMatcher
9
+ def initialize(attribute)
10
+ @attribute = attribute
11
+ @expected_type = nil
12
+ end
13
+
14
+ def being(expected_type)
15
+ @expected_type = expected_type
16
+ self
17
+ end
18
+
19
+ def matches?(command_class)
20
+ @command_class = command_class.is_a?(Class) ? command_class : command_class.class
21
+ attributes = @command_class.public_send(scope)
22
+
23
+ return false unless attributes.key?(@attribute)
24
+ return true if @expected_type.nil? # Type check not requested
25
+
26
+ attributes[@attribute] == @expected_type
27
+ end
28
+
29
+ def description
30
+ "#{action} argument `#{@attribute}`"
31
+ end
32
+
33
+ def failure_message
34
+ message = "Expected #{@command_class.name} to #{action} `#{@attribute}`"
35
+ message += " of type `#{@expected_type}`" if @expected_type
36
+ message
37
+ end
38
+
39
+ def failure_message_when_negated
40
+ message = "Expected #{@command_class.name} not to #{action} `#{@attribute}`"
41
+ message += " of type `#{@expected_type}`" if @expected_type
42
+ message
43
+ end
44
+ end
45
+
46
+ class RequireArgumentMatcher < CommandArgumentMatcher
47
+ private
48
+
49
+ def scope = :requires
50
+ def action = 'require'
51
+ end
52
+
53
+ class AllowArgumentMatcher < CommandArgumentMatcher
54
+ private
55
+
56
+ def scope = :allows
57
+ def action = 'allow'
58
+ end
59
+
60
+ # Specific matcher for `returns`, which only stores an Array of keys.
61
+ class ReturnAttributeMatcher
62
+ def initialize(attribute)
63
+ @attribute = attribute
64
+ @type_check_attempted = false
65
+ end
66
+
67
+ def being(_expected_type)
68
+ @type_check_attempted = true
69
+ self
70
+ end
71
+
72
+ # rubocop:disable Layout/LineLength
73
+ def matches?(command_class)
74
+ @command_class = command_class.is_a?(Class) ? command_class : command_class.class
75
+
76
+ if @type_check_attempted
77
+ @failure_reason = 'GLCommand::Callable does not store types for `returns`, so `.being()` cannot be used.'
78
+ return false
79
+ end
80
+
81
+ @command_class.returns.include?(@attribute)
82
+ end
83
+ # rubocop:enable Layout/LineLength
84
+
85
+ def description
86
+ "return attribute `#{@attribute}`"
87
+ end
88
+
89
+ def failure_message
90
+ return @failure_reason if @failure_reason
91
+
92
+ "Expected #{@command_class.name} to return `#{@attribute}`"
93
+ end
94
+
95
+ def failure_message_when_negated
96
+ "Expected #{@command_class.name} not to return `#{@attribute}`"
97
+ end
98
+ end
99
+
100
+ # Helper methods to provide the clean syntax in specs
101
+ def require(attribute)
102
+ RequireArgumentMatcher.new(attribute)
103
+ end
104
+
105
+ def allow(attribute)
106
+ AllowArgumentMatcher.new(attribute)
107
+ end
108
+
109
+ def returns(attribute)
110
+ ReturnAttributeMatcher.new(attribute)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gl_command/rspec/matchers'
4
+
5
+ # A shared context to automatically set the subject of a spec to the
6
+ # described class. This allows `is_expected` to work directly on the
7
+ # command class in specs with `type: :command`.
8
+ RSpec.shared_context 'GLCommand::Command subject' do
9
+ subject { described_class }
10
+ end
11
+
12
+ RSpec.configure do |config|
13
+ # Makes the matcher methods (require, allow, returns) available in these specs.
14
+ config.include GLCommand::Matchers, type: :command
15
+
16
+ # Allows `is_expected` to work directly on the command class.
17
+ config.include_context 'GLCommand::Command subject', type: :command
18
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'active_support/concern'
4
4
  require 'active_model'
5
+ require 'active_record'
5
6
 
6
7
  module GLCommand
7
8
  module Validatable
@@ -12,7 +13,7 @@ module GLCommand
12
13
  include ActiveModel::Validations
13
14
 
14
15
  class_methods do
15
- def i18n_scope
16
+ define_method(:i18n_scope) do
16
17
  :activerecord
17
18
  end
18
19
  end
@@ -1,3 +1,3 @@
1
1
  module GLCommand
2
- VERSION = '1.3.0'.freeze
2
+ VERSION = '2.0.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gl_command
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Give Lively
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-22 00:00:00.000000000 Z
11
+ date: 2026-07-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -54,6 +54,8 @@ files:
54
54
  - lib/gl_command/chainable_context.rb
55
55
  - lib/gl_command/context.rb
56
56
  - lib/gl_command/context_inspect.rb
57
+ - lib/gl_command/rspec.rb
58
+ - lib/gl_command/rspec/matchers.rb
57
59
  - lib/gl_command/validatable.rb
58
60
  - lib/gl_command/version.rb
59
61
  homepage: https://github.com/givelively/gl_command
@@ -76,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
78
  - !ruby/object:Gem::Version
77
79
  version: '0'
78
80
  requirements: []
79
- rubygems_version: 3.4.19
81
+ rubygems_version: 3.5.22
80
82
  signing_key:
81
83
  specification_version: 4
82
84
  summary: Give Lively Commands