gl_command 1.2.0 → 1.4.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 +4 -4
- data/README.md +119 -8
- data/gl_command.gemspec +2 -0
- data/lib/gl_command/callable.rb +1 -0
- data/lib/gl_command/context_inspect.rb +40 -21
- data/lib/gl_command/rspec/matchers.rb +111 -0
- data/lib/gl_command/rspec.rb +18 -0
- data/lib/gl_command/validatable.rb +1 -0
- data/lib/gl_command/version.rb +1 -1
- metadata +18 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: affffd85a3150ca92bd45b28822125b8a64117bb41d2f8bffa9fd2861712c9d5
|
4
|
+
data.tar.gz: 45e62be391f6958720e3e8aec0a7f04fbaf0d9fe4a5b9b382fc2ae4b91246689
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9c2ac5fcedfc9156231e3d146a221945deca5e3f687b2413fe7db9fecd02e9ba3c8f0b94223d0e414fae1946b1ce52eb574118cfaa873a2524cea1f9b8a7812a
|
7
|
+
data.tar.gz: 31aee586028c0cf073f22f267ee3ad095f48041aace47df51fe9b4c48ed6a6487f2a1d138ad26e786f75e314adc087e87b573032663d9bf363adfceb6a8ac790
|
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,21 @@ 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
|
+
- [GLExceptionNotifier](#glexceptionnotifier)
|
22
|
+
- [Chainable](#chainable)
|
23
|
+
- [Testing `GLCommand`s](#testing-glcommands)
|
24
|
+
- [Stubbing with `build_context`](#stubbing-with-build_context)
|
25
|
+
- [Rspec matchers](#rspec-matchers)
|
26
|
+
- [Publishing the gem to Rubygems](#publishing-the-gem-to-rubygems)
|
27
|
+
|
13
28
|
|
14
29
|
## Installation
|
15
30
|
|
@@ -50,7 +65,7 @@ class SomeCommand < GLCommand::Callable
|
|
50
65
|
end
|
51
66
|
```
|
52
67
|
|
53
|
-
|
68
|
+
### Success/Failure
|
54
69
|
|
55
70
|
GLCommand context's are successful by default (`successful?` aliases `success?`).
|
56
71
|
|
@@ -70,13 +85,14 @@ Here are the ways of adding an error to a command:
|
|
70
85
|
|
71
86
|
If you invoke a command with `.call!` all of the above will raise an exception
|
72
87
|
|
73
|
-
If a command fails, it
|
88
|
+
If a command fails, it calls its `rollback` method before returning (even when invoked with `.call!`)
|
89
|
+
|
74
90
|
|
75
|
-
### Displaying errors
|
91
|
+
### Displaying errors (use `full_error_message`)
|
76
92
|
|
77
93
|
In addition to encapsulating business logic, GLCommand also standardizes error handling.
|
78
94
|
|
79
|
-
This means that rather than having to rescue errors in controllers,
|
95
|
+
This means that rather than having to rescue errors in controllers, just render the command's `full_error_message`
|
80
96
|
|
81
97
|
```ruby
|
82
98
|
result = GLCommand::Callable.call(params)
|
@@ -88,7 +104,7 @@ else
|
|
88
104
|
end
|
89
105
|
```
|
90
106
|
|
91
|
-
In general, use `context.full_error_message` to render errors.
|
107
|
+
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
108
|
|
93
109
|
|
94
110
|
### `stop_and_fail!`
|
@@ -139,7 +155,7 @@ If validations fail, `GLExceptionNotifier` is not called
|
|
139
155
|
|
140
156
|
## GLExceptionNotifier
|
141
157
|
|
142
|
-
[
|
158
|
+
[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
159
|
|
144
160
|
When a command fails `GLExceptionNotifier` is called, unless:
|
145
161
|
|
@@ -147,7 +163,7 @@ When a command fails `GLExceptionNotifier` is called, unless:
|
|
147
163
|
- The failure is a validation failure
|
148
164
|
- `stop_and_fail!` is called with `no_notify: true` - for example `stop_and_fail!('An error message', no_notify: true)`
|
149
165
|
|
150
|
-
**NOTE:** commands that invoke other commands with `call!` inherit the no_notify property of called command.
|
166
|
+
**NOTE:** commands that invoke other commands with `call!` inherit the `no_notify` property of the called command.
|
151
167
|
|
152
168
|
```ruby
|
153
169
|
class InteriorCommand < GLCommand::Callable
|
@@ -211,9 +227,104 @@ class SomeChain < GLCommand::Chainable
|
|
211
227
|
chain(:item)
|
212
228
|
end
|
213
229
|
end
|
230
|
+
```
|
231
|
+
|
232
|
+
## RSpec Matchers
|
233
|
+
|
234
|
+
`GLCommand` comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
|
235
|
+
|
236
|
+
### Setup
|
237
|
+
|
238
|
+
To enable the matchers, add the following line to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:
|
239
|
+
|
240
|
+
```ruby
|
241
|
+
require 'gl_command/rspec'
|
242
|
+
```
|
243
|
+
|
244
|
+
This will automatically include the necessary matchers and configure RSpec for specs marked with `type: :command`.
|
245
|
+
|
246
|
+
### Usage
|
247
|
+
|
248
|
+
You can now test your command's interface like this:
|
249
|
+
|
250
|
+
```ruby
|
251
|
+
# spec/commands/some_command_spec.rb
|
252
|
+
|
253
|
+
RSpec.describe SomeCommand, type: :command do
|
254
|
+
describe 'interface' do
|
255
|
+
it { is_expected.to require(:user).being(User) }
|
256
|
+
it { is_expected.to allow(:subject) }
|
257
|
+
it { is_expected.to returns(:message) }
|
258
|
+
it { is_expected.not_to require(:other_thing) }
|
259
|
+
end
|
260
|
+
end
|
261
|
+
```
|
262
|
+
|
263
|
+
**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.
|
264
|
+
|
265
|
+
## Testing `GLCommand`s
|
266
|
+
|
267
|
+
Give Lively uses Rspec for testing, so this section assumes you're using RSpec.
|
268
|
+
|
269
|
+
### Stubbing with `build_context`
|
270
|
+
|
271
|
+
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.
|
214
272
|
|
273
|
+
```ruby
|
274
|
+
class SomeCommand < GLCommand::Callable
|
275
|
+
requires user: User
|
276
|
+
allows :subject
|
277
|
+
returns :message
|
278
|
+
|
279
|
+
def call
|
280
|
+
user.update!(subject:)
|
281
|
+
context.message = "Hello - user subject: #{user.subject}"
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
result = SomeCommand.build_context(user: User.new, message: "Hello!")
|
286
|
+
result.success? # true
|
287
|
+
result.full_error_message # nil
|
288
|
+
result_error = SomeCommand.build_context(error: "invalid")
|
289
|
+
result_error.success? # false
|
290
|
+
result_error.full_error_message # "invalid"
|
291
|
+
|
292
|
+
SomeCommand.build_context(other_thing: "some other thing")
|
293
|
+
# ArgumentError: Unknown argument or return attribute: 'other_thing'
|
294
|
+
```
|
295
|
+
|
296
|
+
### RSpec Matchers
|
297
|
+
|
298
|
+
`GLCommand` comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
|
299
|
+
|
300
|
+
### Setup
|
301
|
+
|
302
|
+
To enable the matchers, add the following line to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:
|
303
|
+
|
304
|
+
```ruby
|
305
|
+
require 'gl_command/rspec'
|
306
|
+
```
|
307
|
+
|
308
|
+
This will automatically include the necessary matchers and configure RSpec for specs marked with `type: :command`.
|
309
|
+
|
310
|
+
### Usage
|
311
|
+
|
312
|
+
You can now test your command's interface like this:
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
# spec/commands/some_command_spec.rb
|
316
|
+
|
317
|
+
RSpec.describe SomeCommand, type: :command do
|
318
|
+
describe 'interface' do
|
319
|
+
it { is_expected.to require(:user).being(User) }
|
320
|
+
it { is_expected.to allow(:subject) }
|
321
|
+
it { is_expected.to returns(:message) }
|
322
|
+
it { is_expected.not_to require(:other_thing) }
|
323
|
+
end
|
324
|
+
end
|
215
325
|
```
|
216
326
|
|
327
|
+
**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
328
|
|
218
329
|
## Publishing the gem to Rubygems
|
219
330
|
|
data/gl_command.gemspec
CHANGED
data/lib/gl_command/callable.rb
CHANGED
@@ -202,6 +202,7 @@ module GLCommand
|
|
202
202
|
|
203
203
|
chain_rollback if self.class.chain? # defined in GLCommand::Chainable
|
204
204
|
rollback
|
205
|
+
instrument_command(:after_rollback)
|
205
206
|
end
|
206
207
|
|
207
208
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
@@ -1,5 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module GLCommand
|
2
4
|
class ContextInspect
|
5
|
+
PERMITTED_OUTPUTS = %i[string hash].freeze
|
6
|
+
|
3
7
|
class << self
|
4
8
|
def error(error_obj)
|
5
9
|
return '' if error_obj.blank?
|
@@ -7,35 +11,50 @@ module GLCommand
|
|
7
11
|
error_obj.is_a?(Array) ? error_obj.uniq.join(', ') : error_obj.to_s
|
8
12
|
end
|
9
13
|
|
10
|
-
def hash_params(hash)
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
elsif value.respond_to?(:uuid)
|
18
|
-
object_param_with_id(value, :uuid)
|
19
|
-
elsif value.respond_to?(:id)
|
20
|
-
object_param_with_id(value, :id)
|
21
|
-
else
|
22
|
-
value
|
23
|
-
end
|
24
|
-
"#{key}: #{value_s}"
|
25
|
-
end.join(', ')
|
14
|
+
def hash_params(hash, output: :string)
|
15
|
+
unless PERMITTED_OUTPUTS.include?(output)
|
16
|
+
raise "Unknown output type: #{output}, must be one of #{PERMITTED_OUTPUTS}"
|
17
|
+
end
|
18
|
+
|
19
|
+
result = hash.map { |key, value| output_for(key:, value:, output:) }
|
20
|
+
output == :string ? result.join(', ') : result.to_h
|
26
21
|
end
|
27
22
|
|
28
23
|
private
|
29
24
|
|
25
|
+
def output_for(key:, value:, output:)
|
26
|
+
value_s = if value.nil?
|
27
|
+
'nil'
|
28
|
+
elsif value.respond_to?(:to_sql)
|
29
|
+
object_param_as_sql(value, output:)
|
30
|
+
elsif value.respond_to?(:id)
|
31
|
+
object_param_with_id(value, :id, output:)
|
32
|
+
elsif value.respond_to?(:uuid)
|
33
|
+
object_param_with_id(value, :uuid, output:)
|
34
|
+
else
|
35
|
+
value
|
36
|
+
end
|
37
|
+
|
38
|
+
output == :string ? "#{key}: #{value_s}" : [key, value_s]
|
39
|
+
end
|
40
|
+
|
30
41
|
# Active record objects can be really big - rather than rendering the whole object, just show the ID
|
31
|
-
def object_param_with_id(obj, key)
|
42
|
+
def object_param_with_id(obj, key, output:)
|
32
43
|
obj_id = obj.send(key)
|
33
|
-
|
34
|
-
|
44
|
+
if output == :string
|
45
|
+
id_value = obj_id.is_a?(Integer) ? obj_id : "\"#{obj_id}\""
|
46
|
+
"#<#{obj.class.name} #{key}=#{id_value}>"
|
47
|
+
else
|
48
|
+
obj_id
|
49
|
+
end
|
35
50
|
end
|
36
51
|
|
37
|
-
def object_param_as_sql(obj)
|
38
|
-
|
52
|
+
def object_param_as_sql(obj, output:)
|
53
|
+
if output == :string
|
54
|
+
"#<#{obj.class.name} sql=\"#{obj.to_sql}\">"
|
55
|
+
else
|
56
|
+
obj.to_sql
|
57
|
+
end
|
39
58
|
end
|
40
59
|
end
|
41
60
|
end
|
@@ -0,0 +1,111 @@
|
|
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; end
|
50
|
+
def action; 'require'; end
|
51
|
+
end
|
52
|
+
|
53
|
+
class AllowArgumentMatcher < CommandArgumentMatcher
|
54
|
+
private
|
55
|
+
|
56
|
+
def scope; :allows; end
|
57
|
+
def action; 'allow'; end
|
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
|
+
def matches?(command_class)
|
73
|
+
@command_class = command_class.is_a?(Class) ? command_class : command_class.class
|
74
|
+
|
75
|
+
if @type_check_attempted
|
76
|
+
@failure_reason = 'GLCommand::Callable does not store types for `returns`, so `.being()` cannot be used.'
|
77
|
+
return false
|
78
|
+
end
|
79
|
+
|
80
|
+
@command_class.returns.include?(@attribute)
|
81
|
+
end
|
82
|
+
|
83
|
+
def description
|
84
|
+
"return attribute `#{@attribute}`"
|
85
|
+
end
|
86
|
+
|
87
|
+
def failure_message
|
88
|
+
return @failure_reason if @failure_reason
|
89
|
+
|
90
|
+
"Expected #{@command_class.name} to return `#{@attribute}`"
|
91
|
+
end
|
92
|
+
|
93
|
+
def failure_message_when_negated
|
94
|
+
"Expected #{@command_class.name} not to return `#{@attribute}`"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Helper methods to provide the clean syntax in specs
|
99
|
+
def require(attribute)
|
100
|
+
RequireArgumentMatcher.new(attribute)
|
101
|
+
end
|
102
|
+
|
103
|
+
def allow(attribute)
|
104
|
+
AllowArgumentMatcher.new(attribute)
|
105
|
+
end
|
106
|
+
|
107
|
+
def returns(attribute)
|
108
|
+
ReturnAttributeMatcher.new(attribute)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
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
|
data/lib/gl_command/version.rb
CHANGED
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.
|
4
|
+
version: 1.4.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-
|
11
|
+
date: 2025-07-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: 1.0.2
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
41
55
|
description:
|
42
56
|
email:
|
43
57
|
executables: []
|
@@ -54,6 +68,8 @@ files:
|
|
54
68
|
- lib/gl_command/chainable_context.rb
|
55
69
|
- lib/gl_command/context.rb
|
56
70
|
- lib/gl_command/context_inspect.rb
|
71
|
+
- lib/gl_command/rspec.rb
|
72
|
+
- lib/gl_command/rspec/matchers.rb
|
57
73
|
- lib/gl_command/validatable.rb
|
58
74
|
- lib/gl_command/version.rb
|
59
75
|
homepage: https://github.com/givelively/gl_command
|