application_action 0.1.0 → 0.2.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: 40e24ce5fd068b724a6573c07d3154491898503fe9c1c4839269ddc0de3be3b5
4
- data.tar.gz: e4b19aa8a56a71fe567ca1b3a95d549c89c8de3ef8349bc383bb41a56820bcc9
3
+ metadata.gz: 27b97dc76c7dc83d6bc045272e8c609f084802792b5216cd6973c91193cd5f0e
4
+ data.tar.gz: d33acad78be39e11c1356a1864631327dbf8ce1b60afa45cadaca4f21ad1183c
5
5
  SHA512:
6
- metadata.gz: 89b664cc4431ba1086548f131bb9446724fac74f3da63607c88b0e3690451c1a6a269b95a6af13cd9e56763cc95d6cd57c1a82931b68dbc66bf2057ccfe37c0b
7
- data.tar.gz: d1fad00bfe14f8cf62ef0e9a02792bc91c56cfbccb6399fe3daa55043187366f836836052235a7bc2e0e7c79120115f7839c3ae9333ea4b6cf2ecdab4b5b4456
6
+ metadata.gz: 453de2f239c07d257e3aa0d6c7fa79b1f13149babd3219663f5d6bfb637bc85810fa744803a213d4352dac7fb23547bfb44555ea643931775488870ddfd79968
7
+ data.tar.gz: a00c1f08163af8c36f7fb34fae4b974ebbbf0c72906af47dd5803c77544dd16204a4685806b17a42930389a8eeedf0077d645beda3e7c52ccc198ac41a874d5d
data/README.md CHANGED
@@ -1,28 +1,244 @@
1
1
  # ApplicationAction
2
- Short description and motivation.
3
2
 
4
- ## Usage
5
- How to use my plugin.
3
+ Tiny on code, heavy on concept. ApplicationAction is an opiniated concept extend of the Ruby on Rails framework.
4
+
5
+ ApplicationAction is an useful `Action` pattern I found myself following for many years among many Rails projects so I hope its usefull for you too. It mimics `ApplicationRecord`'s interface to the `Action`s your applications execute. Its easy to extent, test, compose and reuse.
6
+
7
+ This is the code. All of it:
8
+
9
+ ```ruby
10
+ class ApplicationAction
11
+ include ActiveModel::Model
12
+
13
+ def save
14
+ return false unless valid?
15
+
16
+ ApplicationRecord.transaction { run }
17
+
18
+ return true
19
+ end
20
+
21
+ def save!
22
+ raise errors.full_messages.join(', ') unless save
23
+ end
24
+
25
+ def run
26
+ raise 'You should write your own #run method'
27
+ end
28
+ end
29
+ ```
6
30
 
7
31
  ## Installation
8
- Add this line to your application's Gemfile:
32
+
33
+ Add this line to your Gemfile:
9
34
 
10
35
  ```ruby
11
36
  gem 'application_action'
12
37
  ```
13
38
 
14
39
  And then execute:
40
+
15
41
  ```bash
16
42
  $ bundle
17
43
  ```
18
44
 
19
- Or install it yourself as:
20
- ```bash
21
- $ gem install application_action
45
+ ## Usage
46
+
47
+ `app/actions/some_action.rb`
48
+
49
+ ```ruby
50
+ class SomeAction < ApplicationAction
51
+ attr_accessor :foo, :bar
52
+
53
+ # write what you need to validate before run the action
54
+ validates :foo, :bar, presence: true
55
+ validate :foo_is_complete, :bar_is_available
56
+
57
+ def run
58
+ # what the action should do when its valid
59
+ end
60
+
61
+ private
62
+
63
+ # the validations
64
+ def foo_is_complete
65
+ errors.add(:foo, :not_complete) unless foo.complete?
66
+ end
67
+
68
+ def bar_is_available
69
+ errors.add(:foo, :not_available) unless bar.available?
70
+ end
71
+ end
72
+ ```
73
+
74
+ Then, whenever suits you, call the action like this:
75
+
76
+ ```ruby
77
+ action = SomeAction.new(foo: foo, bar: bar)
78
+ action.valid?
79
+ action.save
80
+ action.save! # raises the validations errors as RuntimeErrors
22
81
  ```
23
82
 
83
+ Having being through all the "fat models, skinny controllers" eras, I find an `Action` usefull when I have something my application need to do but no model or controller seem like the right place. It's usually some logic involving multiple models and a different set of validations. Its specially usefull when the same `Action` needs to be called from different entries (e.g. API, WEB Interface, background job, etc).
84
+
85
+ **`#run` method is atomic** so feel free to change multiple database tables using different models `.create!` or `.update!` that it will all get rolled back if some exception happens. Be sure you create all the validations you need to ensure your `Action` will run with no exception. Its easy to handle validations errors and display messages this way.
86
+
87
+ ## Philosophy
88
+
89
+ Think of `Action`s as one small state transition of your application. It's the representation of what your application does. So everytime an `Action` run, something changes and there are consequences. Each `Action` have a well-defined and validated scenario before running.
90
+
91
+ #### The business logic should not live in the models
92
+
93
+ You heard the story before: `ActiveRecord` already does too much. The database interface, query, validations and relationships are more than enough.
94
+
95
+ Models should represent an entity stored at the database, a small piece of your entire application state. A single model or record should not handle complex operations, specially involving multiple models.
96
+
97
+ Models validations should only check what is expected for every record, everytime. Some validations will only be applied in certain moments so these validations lives in an `Action`. [ActiveRecord's validations scope](https://guides.rubyonrails.org/active_record_validations.html#on) can handle simple scenarios but `Action`s validations is a better suit for complex logics.
98
+
99
+ #### The business logic should not live in controllers
100
+
101
+ Same here. Controller already handle request params and formats, response codes and authorization. But most important, you should be able to test your business logic without the need of an HTTP request.
102
+
103
+ #### Actions are important changes
104
+
105
+ `Action`s should have strict validations to make sure everything is ok before running. `Action`s are atomic, so you must have it completely done, or not done at all. This will help with your database (and the whole app) consistency.
106
+
107
+ They should be well tested and you should be able to test every scenario without an integration test. You can unit test every `Action` validation and every change it makes when it runs.
108
+
109
+ ### A Practical Example
110
+
111
+ #### Uber-like Logic
112
+
113
+ Consider an Uber-like app, where passengers request for trips and the nearest available driver is found. The driver can refuse the request, so the next driver should be requested. Driver also have a 1 minute timeout to respond the request, otherwise it expires and the next driver is called. You can have some _DRY_ `Action`s like these:
114
+
115
+ 1. Request Trip Action:
116
+
117
+ ```ruby
118
+ class RequestTrip < ApplicationAction
119
+ attr_accessor :passenger, :pickup_address, :destination_address
120
+
121
+ validate :passenger_is_not_blocked
122
+ validate :passenger_is_not_in_debt
123
+
124
+ def run
125
+ Trip.create!(
126
+ passenger: passenger,
127
+ pickup_address: pickup_address,
128
+ destination_address: destination_address,
129
+ requested_at: Time.current,
130
+ driver: nil,
131
+ accepted: false
132
+ )
133
+
134
+ FindTripDriver.new(trip: trip).save!
135
+ end
136
+
137
+ private
138
+
139
+ def passenger_is_not_blocked
140
+ #...
141
+ end
142
+
143
+ def passenger_is_not_in_debt
144
+ #...
145
+ end
146
+ end
147
+ ```
148
+
149
+ 2. Find a Trip Driver
150
+
151
+ ```ruby
152
+ class FindTripDriver < ApplicationAction
153
+ attr_accessor :trip
154
+
155
+ validate :trip_is_still_a_request
156
+ validates :nearest_driver, :trip, presence: true
157
+
158
+ def nearest_driver
159
+ Driver.available.where(...).first
160
+ end
161
+
162
+ def run
163
+ trip.update!(driver: nearest_driver)
164
+ driver.update!(current_trip: trip)
165
+ invite = TripInvitation.create!(trip: trip, driver: driver)
166
+
167
+ Notifications::InviteDriverToTrip.new(invite: invite).send!
168
+
169
+ # rejects automatically after 1 minute
170
+ DriverRejectTripJob.set(wait: 1.minute).perform_later(invite: invite)
171
+ end
172
+
173
+ private
174
+
175
+ def trip_is_still_a_request
176
+ errors.add(:trip, :already_accepted) if trip.accepted?
177
+ end
178
+ end
179
+ ```
180
+
181
+ 3. Driver Rejects the request
182
+
183
+ ```ruby
184
+ class DriverRejectTrip < ApplicationAction
185
+ attr_accessor :invite
186
+
187
+ validates :trip_is_not_already_accepted
188
+
189
+ delegate :trip, :driver, to: :invite
190
+
191
+ def run
192
+ invite.update!(accepted: false)
193
+ trip.update!(driver: nil)
194
+ driver.update!(current_trip: nil)
195
+
196
+ FindTripDriver.new(trip: trip).save! # easy to reuse the action
197
+ end
198
+
199
+ private
200
+
201
+ def trip_is_not_already_accepted
202
+ #...
203
+ end
204
+ end
205
+ ```
206
+
207
+ - Note how simple it is to test these actions.
208
+ - You can call them at diferent Controllers, from `ActiveJob`s or **GraphQL Mutations**
209
+ - Your life at the `rails console` will be a lot easer having a way to call these business logic directly. No need of api calls or web interface.
210
+
211
+ ### Translating error messages
212
+
213
+ As the `Action`s includes `ActiveModel::Model` you can have each action's error message get translated by following this structure:
214
+
215
+ ```yml
216
+ en:
217
+ activemodel:
218
+ attributes:
219
+ find_tripd_river: # <- The action name snake-cased
220
+ driver: Driver
221
+ trip: Trip
222
+ errors:
223
+ models:
224
+ find_tripd_river: # <- The action name snake-cased
225
+ attributes:
226
+ trip:
227
+ already_accepted: "was already accepted." # <- each error message translation
228
+ ```
229
+
230
+ ## Inspiration
231
+
232
+ The experience of building multiple web applications with Rails, including different environments like 100% web interface apps or API only (both REST & GraphQL) apps, a single product with a legacy monorepo maintained by a big team or multiple quickly built on demand projects, made it clear this commom gap of Rails on where to place some more complex business logic. I covered this gap in many different ways in the past. From **Fat Models** to **Fat Controllers**, sometimes as `PORO`s under the `lib` folder and sometimes with different names (as `Services` or `Runners`).
233
+
234
+ It was after i built some React SPA's and got familiar with the [Flux](https://facebook.github.io/flux/) architecture to handle front-end's application state and later getting used to the [Redux](https://redux.js.org) implementation library that i shamelessly copied the name **Action** as I found it's was a nice fit for the case of a Rails application as well.
235
+
236
+ I could translate the [Redux's Core Concepts](https://redux.js.org/introduction/core-concepts) and [Principles](https://redux.js.org/introduction/three-principles) to a Rails. I can see the database as the entire application state, the single source of truth and one should avoid change the application's state without an `Action` (specially for big changes). Every `Action` have an explicit changelist on the state so its easy to track how the state was before running and how the state became after.
237
+
24
238
  ## Contributing
25
- Contribution directions go here.
239
+
240
+ `bin/rspec spec` to run te spec suit and all PRs are welcome.
26
241
 
27
242
  ## License
243
+
28
244
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -6,6 +6,8 @@ class ApplicationAction
6
6
 
7
7
  ApplicationRecord.transaction { run }
8
8
 
9
+ after_run
10
+
9
11
  return true
10
12
  end
11
13
 
@@ -16,4 +18,6 @@ class ApplicationAction
16
18
  def run
17
19
  raise 'You should write your own #run method'
18
20
  end
21
+
22
+ def after_run; end
19
23
  end
@@ -1,3 +1,3 @@
1
1
  class ApplicationAction
2
- VERSION = '0.1.0'
2
+ VERSION = '0.2.0'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: application_action
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rudiney Altair Franceschi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-01-11 00:00:00.000000000 Z
11
+ date: 2021-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -106,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
106
  - !ruby/object:Gem::Version
107
107
  version: '0'
108
108
  requirements: []
109
- rubygems_version: 3.0.3
109
+ rubygems_version: 3.1.4
110
110
  signing_key:
111
111
  specification_version: 4
112
112
  summary: Adds the 'Actions' concept to your Rails APP