steel_wheel 0.5.1 → 0.6.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/.codeclimate.yml +2 -0
- data/.github/workflows/ruby.yml +21 -26
- data/.gitignore +0 -0
- data/.rspec +0 -0
- data/.rubocop.yml +188 -0
- data/.ruby-version +1 -1
- data/.travis.yml +0 -0
- data/CODE_OF_CONDUCT.md +0 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +114 -95
- data/LICENSE.txt +0 -0
- data/README.md +307 -4
- data/Rakefile +7 -1
- data/assets/action_diagram.png +0 -0
- data/bin/console +1 -0
- data/lib/generators/steel_wheel/application_handler/USAGE +8 -0
- data/lib/generators/steel_wheel/application_handler/application_handler_generator.rb +12 -0
- data/lib/generators/steel_wheel/application_handler/templates/handler_template.rb +4 -0
- data/lib/generators/steel_wheel/command/USAGE +0 -0
- data/lib/generators/steel_wheel/command/command_generator.rb +10 -10
- data/lib/generators/steel_wheel/command/templates/command_template.rb +3 -1
- data/lib/generators/steel_wheel/handler/USAGE +0 -0
- data/lib/generators/steel_wheel/handler/handler_generator.rb +10 -10
- data/lib/generators/steel_wheel/handler/templates/handler_template.rb +13 -7
- data/lib/generators/steel_wheel/params/USAGE +0 -0
- data/lib/generators/steel_wheel/params/params_generator.rb +10 -10
- data/lib/generators/steel_wheel/params/templates/params_template.rb +3 -1
- data/lib/generators/steel_wheel/query/USAGE +0 -0
- data/lib/generators/steel_wheel/query/query_generator.rb +10 -10
- data/lib/generators/steel_wheel/query/templates/query_template.rb +3 -1
- data/lib/steel_wheel/command.rb +8 -2
- data/lib/steel_wheel/handler.rb +86 -29
- data/lib/steel_wheel/params.rb +10 -1
- data/lib/steel_wheel/query.rb +7 -1
- data/lib/steel_wheel/response.rb +10 -5
- data/lib/steel_wheel/skip_active_model_errors_keys.rb +12 -1
- data/lib/steel_wheel/version.rb +3 -1
- data/lib/steel_wheel.rb +3 -1
- data/steel_wheel.gemspec +6 -11
- metadata +21 -101
- data/lib/generators/steel_wheel/generic_generator.rb +0 -23
data/README.md
CHANGED
@@ -1,7 +1,124 @@
|
|
1
1
|
# SteelWheel
|
2
|
-
[](https://codeclimate.com/github/andriy-baran/steel_wheel/maintainability)
|
2
|
+
[](https://codeclimate.com/github/andriy-baran/steel_wheel/maintainability)
|
3
|
+
[](https://codeclimate.com/github/andriy-baran/steel_wheel/test_coverage)
|
4
|
+
[](https://badge.fury.io/rb/steel_wheel)
|
3
5
|
|
4
|
-
|
6
|
+
The library is a tool for building highly structured service objects.
|
7
|
+
|
8
|
+
## Concepts
|
9
|
+
|
10
|
+
### Stages
|
11
|
+
We may consider any controller action as a sequence of following stages:
|
12
|
+
1. **Input validations and preparations**
|
13
|
+
* Describe the structure of parameters
|
14
|
+
* Validate values, provide defaults
|
15
|
+
2. **Querying data and preparing context**
|
16
|
+
* Records lookups by IDs in parameters
|
17
|
+
* Validate permissions to perform an action
|
18
|
+
* Validate conditions (business logic requirements)
|
19
|
+
* Inject Dependencies
|
20
|
+
* Set up current user
|
21
|
+
3. **Performing Action (skipped on GET requests)**
|
22
|
+
* Updade database state
|
23
|
+
* Enqueue jobs
|
24
|
+
* Handle exceptions
|
25
|
+
* Validate intermediate states
|
26
|
+
4. **Exposing Results/Errors**
|
27
|
+
* Presenters
|
28
|
+
* Contextual information useful for the users
|
29
|
+
|
30
|
+
### Implementation of stages
|
31
|
+
As you can see each step has specific tasks and can be implemented as a separate object.
|
32
|
+
|
33
|
+
**SteelWheel::Params (gem https://github.com/andriy-baran/easy_params)**
|
34
|
+
* provides DSL for `params` structure definition
|
35
|
+
* provides type coercion and default values for individual attributes
|
36
|
+
* has ActionModel::Validation included
|
37
|
+
* implements `http_status` method that returs HTTP error code
|
38
|
+
|
39
|
+
**SteelWheel::Query**
|
40
|
+
* has `Memery` module included
|
41
|
+
* has ActionModel::Validation included
|
42
|
+
* implements `http_status` method that returs HTTP error code
|
43
|
+
|
44
|
+
**SteelWheel::Command**
|
45
|
+
* has ActionModel::Validation included
|
46
|
+
* implements `http_status` method that returs HTTP error code
|
47
|
+
* implements `call` method that should do the stuff
|
48
|
+
|
49
|
+
**SteelWheel::Response**
|
50
|
+
* has ActionModel::Validation included
|
51
|
+
* implements `status` method that returs HTTP error code
|
52
|
+
* implements `success?` method that checks if there are any errors
|
53
|
+
|
54
|
+
### Process
|
55
|
+
Let's image the process that connects stages described above
|
56
|
+
* Get an input and initialize object for params, trigger callbacks
|
57
|
+
* Initialize object for preparing context and give it an access to previous object, trigger callbacks
|
58
|
+
* Initialize object for performing action and give it an access to previous object, trigger callbacks
|
59
|
+
* Initialize resulting object and give it an access to previous object,
|
60
|
+
* Run validations, collect errros, trigger callbacks
|
61
|
+
* If everything is ok run action and handle errors that appear during execution time.
|
62
|
+
* If we have an error on any stage we stop validating following objects.
|
63
|
+
|
64
|
+
### Callbacks
|
65
|
+
|
66
|
+
We have two types of callbacks explicit and implicit
|
67
|
+
|
68
|
+
### Implicit callbacks
|
69
|
+
|
70
|
+
We define them via handler instance methods
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
def on_params_created(params)
|
74
|
+
# NOOP
|
75
|
+
end
|
76
|
+
|
77
|
+
def on_query_created(query)
|
78
|
+
# NOOP
|
79
|
+
end
|
80
|
+
|
81
|
+
def on_command_created(command)
|
82
|
+
# NOOP
|
83
|
+
end
|
84
|
+
|
85
|
+
def on_response_created(command)
|
86
|
+
# NOOP
|
87
|
+
end
|
88
|
+
|
89
|
+
# After validation callbacks
|
90
|
+
|
91
|
+
def on_failure(flow)
|
92
|
+
# NOOP
|
93
|
+
end
|
94
|
+
|
95
|
+
def on_success(flow)
|
96
|
+
# NOOP
|
97
|
+
end
|
98
|
+
```
|
99
|
+
|
100
|
+
### Explicit callbacks
|
101
|
+
|
102
|
+
We define them during instantiation of hanler by providing a block parameter
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
handler = handler_class.new do |c|
|
106
|
+
c.params { |o| puts o }
|
107
|
+
c.query { |o| puts o }
|
108
|
+
c.command { |o| puts o }
|
109
|
+
c.response { |o| puts o }
|
110
|
+
end
|
111
|
+
result = handler.handle(input: { id: 1 })
|
112
|
+
```
|
113
|
+
In addition we can manipulate with objects directly via callback of `handle` mathod
|
114
|
+
```ruby
|
115
|
+
result = handler_class.handle(input: { id: 1 }) do |c|
|
116
|
+
c.params.id = 12
|
117
|
+
c.query.user = current_user
|
118
|
+
c.command.request_headers = request.headers
|
119
|
+
c.response.prepare_presenter
|
120
|
+
end
|
121
|
+
```
|
5
122
|
|
6
123
|
## Installation
|
7
124
|
|
@@ -21,9 +138,195 @@ Or install it yourself as:
|
|
21
138
|
|
22
139
|
## Usage
|
23
140
|
|
24
|
-
|
141
|
+
Add base handler
|
142
|
+
|
143
|
+
```bash
|
144
|
+
bin/rails g steel_wheel:application_handler
|
145
|
+
```
|
146
|
+
|
147
|
+
Add specific handler
|
148
|
+
|
149
|
+
```bash
|
150
|
+
bin/rails g steel_wheel:handler products/create
|
151
|
+
```
|
152
|
+
This will generate `app/handlers/products/create_handler.rb`. And we can customize it
|
153
|
+
|
154
|
+
```ruby
|
155
|
+
class Products::CreateHandler < ApplicationHandler
|
156
|
+
define do
|
157
|
+
params do
|
158
|
+
attribute :title, string
|
159
|
+
attribute :weight, string
|
160
|
+
attribute :price, string
|
161
|
+
|
162
|
+
validates :title, :weight, :price, presence: true
|
163
|
+
validates :weight, allow_blank: true, format: { with: /\A[0-9]+\s[g|kg]\z/ }
|
164
|
+
end
|
25
165
|
|
26
|
-
|
166
|
+
query do
|
167
|
+
validate :product, :variant
|
168
|
+
|
169
|
+
memoize def new_product
|
170
|
+
Product.new(title: title)
|
171
|
+
end
|
172
|
+
|
173
|
+
memoize def new_variant
|
174
|
+
new_product.build_variant(weight: weight, price: price)
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def product
|
180
|
+
errors.add(:base, :unprocessable_entity, new_product.errors.full_messages.join("\n")) if new_product.invalid?
|
181
|
+
end
|
182
|
+
|
183
|
+
def variant
|
184
|
+
errors.add(:base, :unprocessable_entity, new_variant.errors.full_messages.join("\n")) if new_variant.invalid?
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
command do
|
189
|
+
def add_to_stock!
|
190
|
+
PointOfSale.find_each do |pos|
|
191
|
+
PosProductStock.create!(pos_id: pos.id, product_id: new_product.id, on_hand: 0.0)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def call(response)
|
196
|
+
::ApplicationRecord.transaction do
|
197
|
+
new_product.save!
|
198
|
+
new_variant.save!
|
199
|
+
add_to_stock!
|
200
|
+
rescue => e
|
201
|
+
response.errors.add(:unprocessable_entity, e.message)
|
202
|
+
raise ActiveRecord::Rollback
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def on_success(flow)
|
209
|
+
flow.call
|
210
|
+
end
|
211
|
+
end
|
212
|
+
```
|
213
|
+
Looks too long. Lets move code into separate files.
|
214
|
+
```bash
|
215
|
+
bin/rails g steel_wheel:params products/create
|
216
|
+
```
|
217
|
+
Add relative code
|
218
|
+
```ruby
|
219
|
+
# Base class also can be refered via
|
220
|
+
# ApplicationHandler.main_builder.abstract_factory.params_factory.base_class
|
221
|
+
class Products::CreateHandler
|
222
|
+
class Params < SteelWheel::Params
|
223
|
+
attribute :title, string
|
224
|
+
attribute :weight, string
|
225
|
+
attribute :price, string
|
226
|
+
|
227
|
+
validates :title, :weight, :price, presence: true
|
228
|
+
validates :weight, allow_blank: true, format: { with: /\A[0-9]+\s[g|kg]\z/ }
|
229
|
+
end
|
230
|
+
end
|
231
|
+
```
|
232
|
+
Than do the same for query
|
233
|
+
```bash
|
234
|
+
bin/rails g steel_wheel:query products/create
|
235
|
+
```
|
236
|
+
Add code...
|
237
|
+
```ruby
|
238
|
+
# Base class also can be refered via
|
239
|
+
# ApplicationHandler.main_builder.abstract_factory.query_factory.base_class
|
240
|
+
class Products::CreateHandler
|
241
|
+
class Query < SteelWheel::Query
|
242
|
+
validate :product, :variant
|
243
|
+
|
244
|
+
memoize def new_product
|
245
|
+
Product.new(title: title)
|
246
|
+
end
|
247
|
+
|
248
|
+
memoize def new_variant
|
249
|
+
new_product.build_variant(weight: weight, price: price)
|
250
|
+
end
|
251
|
+
|
252
|
+
private
|
253
|
+
|
254
|
+
def product
|
255
|
+
errors.add(:unprocessable_entity, new_product.errors.full_messages.join("\n")) if new_product.invalid?
|
256
|
+
end
|
257
|
+
|
258
|
+
def variant
|
259
|
+
errors.add(:unprocessable_entity, new_variant.errors.full_messages.join("\n")) if new_variant.invalid?
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
```
|
264
|
+
And finally command
|
265
|
+
```bash
|
266
|
+
bin/rails g steel_wheel:command products/create
|
267
|
+
```
|
268
|
+
Move code
|
269
|
+
```ruby
|
270
|
+
class Products::CreateHandler
|
271
|
+
class Command < SteelWheel::Command
|
272
|
+
def add_to_stock!
|
273
|
+
::PointOfSale.find_each do |pos|
|
274
|
+
::PosProductStock.create!(pos_id: pos.id, product_id: new_product.id, on_hand: 0.0)
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
def call(response)
|
279
|
+
::ApplicationRecord.transaction do
|
280
|
+
new_product.save!
|
281
|
+
new_variant.save!
|
282
|
+
add_to_stock!
|
283
|
+
rescue => e
|
284
|
+
response.errors.add(:unprocessable_entity, e.message)
|
285
|
+
raise ActiveRecord::Rollback
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
290
|
+
```
|
291
|
+
Than we can update handler
|
292
|
+
```ruby
|
293
|
+
# app/handlers/manage/products/create_handler.rb
|
294
|
+
class Manage::Products::CreateHandler < ApplicationHandler
|
295
|
+
define do
|
296
|
+
params Params
|
297
|
+
query Query
|
298
|
+
command Command
|
299
|
+
end
|
300
|
+
|
301
|
+
def on_success(flow)
|
302
|
+
flow.call(flow)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
```
|
306
|
+
|
307
|
+
### HTTP status codes and errors handling
|
308
|
+
|
309
|
+
It's important to provide a correct HTTP status when we faced some problem(s) during request handling. The library encourages developers to add the status codes when they add errors.
|
310
|
+
```ruby
|
311
|
+
errors.add(:unprocessable_entity, 'error')
|
312
|
+
```
|
313
|
+
As you know `full_messages` will produce `['Unprocessable Entity error']` to prevent this and get only error `SteelWheel::Response` has special method that makes some error keys to behave like `:base`
|
314
|
+
```ruby
|
315
|
+
# Default setup
|
316
|
+
generic_validation_keys(:not_found, :forbidden, :unprocessable_entity, :bad_request, :unauthorized)
|
317
|
+
# To override it in your app
|
318
|
+
class SomeHandler
|
319
|
+
define do
|
320
|
+
response do
|
321
|
+
generic_validation_keys(:not_found, :forbidden, :unprocessable_entity, :bad_request, :unauthorized, :payment_required)
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
```
|
326
|
+
In Rails 6.1 `ActiveModel::Error` was introdused and previous setup is not needed, second argument is used instead
|
327
|
+
```ruby
|
328
|
+
errors.add(:base, :unprocessable_entity, 'error')
|
329
|
+
```
|
27
330
|
|
28
331
|
## Development
|
29
332
|
|
data/Rakefile
CHANGED
data/assets/action_diagram.png
CHANGED
File without changes
|
data/bin/console
CHANGED
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SteelWheel
|
4
|
+
class ApplicationHandlerGenerator < Rails::Generators::Base
|
5
|
+
source_root File.expand_path('templates', __dir__)
|
6
|
+
|
7
|
+
def copy_files
|
8
|
+
empty_directory Pathname.new('app/handlers')
|
9
|
+
template 'handler_template.rb', 'app/handlers/application_handler.rb'
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
File without changes
|
@@ -1,16 +1,16 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module SteelWheel
|
4
|
-
class CommandGenerator <
|
5
|
-
|
4
|
+
class CommandGenerator < Rails::Generators::NamedBase
|
5
|
+
source_root File.expand_path('templates', __dir__)
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
7
|
+
def copy_files
|
8
|
+
if behavior == :revoke
|
9
|
+
template 'command_template.rb', "app/handlers/#{file_path}_handler/command.rb"
|
10
|
+
elsif behavior == :invoke
|
11
|
+
empty_directory Pathname.new('app/commands').join(*class_path)
|
12
|
+
template 'command_template.rb', "app/handlers/#{file_path}_handler/command.rb"
|
13
|
+
end
|
14
14
|
end
|
15
15
|
end
|
16
16
|
end
|
File without changes
|
@@ -1,16 +1,16 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module SteelWheel
|
4
|
-
class HandlerGenerator <
|
5
|
-
|
4
|
+
class HandlerGenerator < Rails::Generators::NamedBase
|
5
|
+
source_root File.expand_path('templates', __dir__)
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
7
|
+
def copy_files
|
8
|
+
if behavior == :revoke
|
9
|
+
template 'handler_template.rb', "app/handlers/#{file_path}_handler.rb"
|
10
|
+
elsif behavior == :invoke
|
11
|
+
empty_directory Pathname.new('app/handlers').join(*class_path)
|
12
|
+
template 'handler_template.rb', "app/handlers/#{file_path}_handler.rb"
|
13
|
+
end
|
14
14
|
end
|
15
15
|
end
|
16
16
|
end
|
@@ -1,15 +1,21 @@
|
|
1
1
|
class <%= class_name %>Handler < ApplicationHandler
|
2
|
-
|
2
|
+
define do
|
3
|
+
params do
|
3
4
|
|
4
|
-
|
5
|
+
end
|
6
|
+
|
7
|
+
query do
|
8
|
+
|
9
|
+
end
|
5
10
|
|
6
|
-
|
7
|
-
|
8
|
-
|
11
|
+
command do
|
12
|
+
def call(*)
|
13
|
+
# NOOP
|
14
|
+
end
|
9
15
|
end
|
10
16
|
end
|
11
17
|
|
12
|
-
def on_success
|
13
|
-
flow.call
|
18
|
+
def on_success(flow)
|
19
|
+
flow.call(flow)
|
14
20
|
end
|
15
21
|
end
|
File without changes
|
@@ -1,16 +1,16 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module SteelWheel
|
4
|
-
class ParamsGenerator <
|
5
|
-
|
4
|
+
class ParamsGenerator < Rails::Generators::NamedBase
|
5
|
+
source_root File.expand_path('templates', __dir__)
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
7
|
+
def copy_files
|
8
|
+
if behavior == :revoke
|
9
|
+
template 'params_template.rb', "app/handlers/#{file_path}_handler/params.rb"
|
10
|
+
elsif behavior == :invoke
|
11
|
+
empty_directory Pathname.new('app/params').join(*class_path)
|
12
|
+
template 'params_template.rb', "app/handlers/#{file_path}_handler/params.rb"
|
13
|
+
end
|
14
14
|
end
|
15
15
|
end
|
16
16
|
end
|
File without changes
|
@@ -1,16 +1,16 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module SteelWheel
|
4
|
-
class
|
5
|
-
|
4
|
+
class QueryGenerator < Rails::Generators::NamedBase
|
5
|
+
source_root File.expand_path('templates', __dir__)
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
7
|
+
def copy_files
|
8
|
+
if behavior == :revoke
|
9
|
+
template 'query_template.rb', "app/handlers/#{file_path}_handler/query.rb"
|
10
|
+
elsif behavior == :invoke
|
11
|
+
empty_directory Pathname.new('app/queries').join(*class_path)
|
12
|
+
template 'query_template.rb', "app/handlers/#{file_path}_handler/query.rb"
|
13
|
+
end
|
14
14
|
end
|
15
15
|
end
|
16
16
|
end
|
data/lib/steel_wheel/command.rb
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module SteelWheel
|
4
|
+
# Base class for commands
|
2
5
|
class Command
|
3
6
|
include Memery
|
4
7
|
include ActiveModel::Validations
|
@@ -8,10 +11,13 @@ module SteelWheel
|
|
8
11
|
end
|
9
12
|
|
10
13
|
def http_status
|
11
|
-
errors.
|
14
|
+
return :ok if errors.empty?
|
15
|
+
return errors.keys.first unless defined?(ActiveModel::Error)
|
16
|
+
|
17
|
+
errors.map(&:type).first
|
12
18
|
end
|
13
19
|
|
14
|
-
def call
|
20
|
+
def call(*)
|
15
21
|
# NOOP
|
16
22
|
end
|
17
23
|
end
|