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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +2 -0
  3. data/.github/workflows/ruby.yml +21 -26
  4. data/.gitignore +0 -0
  5. data/.rspec +0 -0
  6. data/.rubocop.yml +188 -0
  7. data/.ruby-version +1 -1
  8. data/.travis.yml +0 -0
  9. data/CODE_OF_CONDUCT.md +0 -0
  10. data/Gemfile +10 -0
  11. data/Gemfile.lock +114 -95
  12. data/LICENSE.txt +0 -0
  13. data/README.md +307 -4
  14. data/Rakefile +7 -1
  15. data/assets/action_diagram.png +0 -0
  16. data/bin/console +1 -0
  17. data/lib/generators/steel_wheel/application_handler/USAGE +8 -0
  18. data/lib/generators/steel_wheel/application_handler/application_handler_generator.rb +12 -0
  19. data/lib/generators/steel_wheel/application_handler/templates/handler_template.rb +4 -0
  20. data/lib/generators/steel_wheel/command/USAGE +0 -0
  21. data/lib/generators/steel_wheel/command/command_generator.rb +10 -10
  22. data/lib/generators/steel_wheel/command/templates/command_template.rb +3 -1
  23. data/lib/generators/steel_wheel/handler/USAGE +0 -0
  24. data/lib/generators/steel_wheel/handler/handler_generator.rb +10 -10
  25. data/lib/generators/steel_wheel/handler/templates/handler_template.rb +13 -7
  26. data/lib/generators/steel_wheel/params/USAGE +0 -0
  27. data/lib/generators/steel_wheel/params/params_generator.rb +10 -10
  28. data/lib/generators/steel_wheel/params/templates/params_template.rb +3 -1
  29. data/lib/generators/steel_wheel/query/USAGE +0 -0
  30. data/lib/generators/steel_wheel/query/query_generator.rb +10 -10
  31. data/lib/generators/steel_wheel/query/templates/query_template.rb +3 -1
  32. data/lib/steel_wheel/command.rb +8 -2
  33. data/lib/steel_wheel/handler.rb +86 -29
  34. data/lib/steel_wheel/params.rb +10 -1
  35. data/lib/steel_wheel/query.rb +7 -1
  36. data/lib/steel_wheel/response.rb +10 -5
  37. data/lib/steel_wheel/skip_active_model_errors_keys.rb +12 -1
  38. data/lib/steel_wheel/version.rb +3 -1
  39. data/lib/steel_wheel.rb +3 -1
  40. data/steel_wheel.gemspec +6 -11
  41. metadata +21 -101
  42. data/lib/generators/steel_wheel/generic_generator.rb +0 -23
data/README.md CHANGED
@@ -1,7 +1,124 @@
1
1
  # SteelWheel
2
- [![Maintainability](https://api.codeclimate.com/v1/badges/a197758aa1cfde54f0e1/maintainability)](https://codeclimate.com/github/andriy-baran/steel_wheel/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/a197758aa1cfde54f0e1/test_coverage)](https://codeclimate.com/github/andriy-baran/steel_wheel/test_coverage)
2
+ [![Maintainability](https://api.codeclimate.com/v1/badges/a197758aa1cfde54f0e1/maintainability)](https://codeclimate.com/github/andriy-baran/steel_wheel/maintainability)
3
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/a197758aa1cfde54f0e1/test_coverage)](https://codeclimate.com/github/andriy-baran/steel_wheel/test_coverage)
4
+ [![Gem Version](https://badge.fury.io/rb/steel_wheel.svg)](https://badge.fury.io/rb/steel_wheel)
3
5
 
4
- Allows to operate on any sequence of procedures as on linked list. It means that inserting and deleting any list/element into/from similar lists is very efficient. And a rich callbacks system gives entire control over the execution. Currently there is one example implemented. It helps organize common procedures in a Rails' controlers.
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
- [Getting started on Rails](https://github.com/andriy-baran/steel_wheel/wiki/Getting-started-on-Rails)
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
- [Wiki](https://github.com/andriy-baran/steel_wheel/wiki)
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
@@ -1,6 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
- task default: :spec
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
File without changes
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'bundler/setup'
4
5
  require 'steel_wheel'
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Generates operations files
3
+
4
+ Example:
5
+ rails generate steel_wheel:application_handler
6
+
7
+ This will create:
8
+ app/handlers/application_handler.rb
@@ -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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationHandler < SteelWheel::Handler
4
+ end
File without changes
@@ -1,16 +1,16 @@
1
- require_relative '../generic_generator'
1
+ # frozen_string_literal: true
2
2
 
3
3
  module SteelWheel
4
- class CommandGenerator < GenericGenerator
5
- setup_templates_root('command/templates')
4
+ class CommandGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path('templates', __dir__)
6
6
 
7
- on_revoke do
8
- template 'command_template.rb', "app/commands/#{file_path}_command.rb"
9
- end
10
-
11
- on_invoke do
12
- empty_directory Pathname.new('app/commands').join(*class_path)
13
- template 'command_template.rb', "app/commands/#{file_path}_command.rb"
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
@@ -1,3 +1,5 @@
1
- class <%= class_name %>Command < SteelWheel::Command
1
+ class <%= class_name %>Handler
2
+ class Command < base_class_for(:command)
2
3
 
4
+ end
3
5
  end
File without changes
@@ -1,16 +1,16 @@
1
- require_relative '../generic_generator'
1
+ # frozen_string_literal: true
2
2
 
3
3
  module SteelWheel
4
- class HandlerGenerator < GenericGenerator
5
- setup_templates_root('handler/templates')
4
+ class HandlerGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path('templates', __dir__)
6
6
 
7
- on_revoke do
8
- template 'handler_template.rb', "app/handlers/#{file_path}_handler.rb"
9
- end
10
-
11
- on_invoke do
12
- empty_directory Pathname.new('app/handlers').join(*class_path)
13
- template 'handler_template.rb', "app/handlers/#{file_path}_handler.rb"
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
- params_input do
2
+ define do
3
+ params do
3
4
 
4
- end
5
+ end
6
+
7
+ query do
8
+
9
+ end
5
10
 
6
- command_stage do
7
- def call
8
- # NOOP
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
- require_relative '../generic_generator'
1
+ # frozen_string_literal: true
2
2
 
3
3
  module SteelWheel
4
- class ParamsGenerator < GenericGenerator
5
- setup_templates_root('params/templates')
4
+ class ParamsGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path('templates', __dir__)
6
6
 
7
- on_revoke do
8
- template 'params_template.rb', "app/params/#{file_path}_params.rb"
9
- end
10
-
11
- on_invoke do
12
- empty_directory Pathname.new('app/params').join(*class_path)
13
- template 'params_template.rb', "app/params/#{file_path}_params.rb"
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
@@ -1,3 +1,5 @@
1
- class <%= class_name %>Params < SteelWheel::Params
1
+ class <%= class_name %>Handler
2
+ class Params < base_class_for(:params)
2
3
 
4
+ end
3
5
  end
File without changes
@@ -1,16 +1,16 @@
1
- require_relative '../generic_generator'
1
+ # frozen_string_literal: true
2
2
 
3
3
  module SteelWheel
4
- class CommandGenerator < GenericGenerator
5
- setup_templates_root('query/templates')
4
+ class QueryGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path('templates', __dir__)
6
6
 
7
- on_revoke do
8
- template 'query_template.rb', "app/queries/#{file_path}_query.rb"
9
- end
10
-
11
- on_invoke do
12
- empty_directory Pathname.new('app/queries').join(*class_path)
13
- template 'query_template.rb', "app/queries/#{file_path}_query.rb"
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
@@ -1,3 +1,5 @@
1
- class <%= class_name %>Query < SteelWheel::Query
1
+ class <%= class_name %>Handler
2
+ class Query < base_class_for(:query)
2
3
 
4
+ end
3
5
  end
@@ -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.keys.first
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