use_cases 0.3.8 → 1.0.12

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: ad9722f48d9d6f073a9699cb042c246d935994b4384e340e3b985c35ea6a697b
4
- data.tar.gz: 8d3cc6daeef1b64fb86a96a9e6ff13a3b95ef328ff2a4e453696cae0724a1572
3
+ metadata.gz: e41f6c5b8670e047cafdd85d068a66aa0efac27700f2c179d5d893ee1593c466
4
+ data.tar.gz: 629cef02e18325528d6155a34356b80e48acaf31520ef45fddb4937e058c3ad2
5
5
  SHA512:
6
- metadata.gz: aefef04cfa79694d593848a6c245ad3d282416fdf28de6a934581f8fa15bfadf1faf5fccf1624a272b997473619af36841464feb8b1f26b6329ee8333a688e4b
7
- data.tar.gz: 00bb31d99dfd4e9f1d98d99778b1263e4d32949dc5270af41f78d19dba6a460afeba44797219984dcbe964c516c150e2c0ee1db1392645760de14a7599ae2876
6
+ metadata.gz: 2b5fdb9fd7bd52211b9194abd49fa2247b9b814e26f763f25f0c9f3ee995dff527977a19e7192576565a563af19dbf3cfeccae833fbd74f4fba74fa4589f46a3
7
+ data.tar.gz: 7d51d14db15b08764a1bbf3dc6ccede0806085e532e42273e436f1c91029f911282c3da31495b978a7dc87d5ba569fa33bc077b1b15673d7df795c9bf53ed781
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.1] - 2021-12-19
4
+
5
+ - Async published events are now suffixed by `".async"`
6
+
7
+ ## [1.0.0] - 2021-12-19
8
+
9
+ - Fixed some minor bugs that have been pending todos.
10
+ - Deprecated the `UseCases::Base` superclass as a DSL injection method. You must now use the `UseCase` module.
11
+ - Added the `publish` option for steps, which allows the publishing of an event when a step is completed.
12
+
3
13
  ## [0.1.0] - 2021-09-21
4
14
 
5
15
  - Added the basic DSL of them with the following modules:
@@ -9,7 +19,7 @@
9
19
  - Notifications [WIP]
10
20
  - StackRunner
11
21
  - Stack
12
- - StepResult
22
+ - UseCases::Result
13
23
  - Steps
14
24
  - Validate
15
25
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- use_cases (0.3.7)
4
+ use_cases (1.0.12)
5
5
  activesupport
6
6
  dry-events
7
7
  dry-matcher
@@ -126,4 +126,4 @@ DEPENDENCIES
126
126
  use_cases!
127
127
 
128
128
  BUNDLED WITH
129
- 2.2.25
129
+ 2.2.28
data/README.md CHANGED
@@ -4,250 +4,179 @@
4
4
 
5
5
  # UseCases
6
6
 
7
- ## Currently Unstable! Use at your own risk.
7
+ `UseCases` is a dry-ecosystem-based gem that implements a DSL for the use case pattern using the railway programming paradigm.
8
8
 
9
- `UseCases` is a gem based on the [dry-transaction](https://dry-rb.org/gems/dry-transaction/) DSL that implements macros commonly used internally by Ring Twice.
9
+ It's concept is largely based on `dry-transaction` but does not use it behind the scenes. Instead it relies on other `dry` libraries like [dry-validation](https://dry-rb.org/gems/dry-validation/), [dry-events](https://dry-rb.org/gems/dry-validation/) and [dry-monads](https://dry-rb.org/gems/dry-validation/) to implement a DSL that can be flexible enough for your needs.
10
10
 
11
- `UseCases` does not however use `dry-transaction` behind the scenes. Instead it relies on other `dry` libraries like [dry-validation](https://dry-rb.org/gems/dry-validation/), [dry-events](https://dry-rb.org/gems/dry-validation/) and [dry-monads](https://dry-rb.org/gems/dry-validation/) to implement a DSL that can be flexible enough for our needs.
11
+ ### Including UseCase
12
12
 
13
- ## Why `UseCases` came about:
13
+ Including the `UseCase` module ensures that your class implements the base use case [Base DSL](#the-base-dsl).
14
14
 
15
- 1. It allows you to use `dry-validation` without much gymastics.
16
- 2. It abstracts common steps like **authorization** and **validation** into macros.
17
- 3. It solves what we consider a problem of `dry-transaction`. The way it funnels down `input` through `Dry::Monads` payloads alone. `UseCases` offers more flexibility in a way that still promotes functional programming values.
18
-
19
- ## Installation
15
+ ```ruby
16
+ class Users::Create
17
+ include UseCase
18
+ end
19
+ ````
20
20
 
21
- Add this line to your application's Gemfile:
21
+ In order to add optional modules (optins), use the following notation:
22
22
 
23
23
  ```ruby
24
- gem 'use_cases'
24
+ class Users::Create
25
+ include UseCase[:validated, :transactional, :publishing]
26
+ end
25
27
  ```
26
28
 
27
- And then execute:
28
-
29
- $ bundle install
30
-
31
- Or install it yourself as:
32
-
33
- $ gem install use_cases
34
-
35
- ## Usage
36
-
37
- To fully understand `UseCases`, make sure to read [dry-transaction](https://dry-rb.org/gems/dry-transaction/0.13/)'s documentation first.
38
-
39
- ### Validations
40
-
41
- See [dry-validation](https://dry-rb.org/gems/dry-validation/)
42
-
43
- ### Step Adapters
44
-
45
- https://dry-rb.org/gems/dry-transaction/0.13/step-adapters/
46
-
47
- **Basic Example**
29
+ ### Using a UseCase
48
30
 
49
31
  ```ruby
50
- class YourCase < UseCases::Base
51
- params {}
32
+ create_user = Users::Create.new
33
+ params = { first_name: 'Don', last_name: 'Quixote' }
52
34
 
53
- step :do_something
35
+ result = create_user.call(params, current_user)
54
36
 
55
- def do_something(params, current_user)
56
- params[:should_fail] ? Failure([:failed, "failed"]) : Success("it succeeds!")
57
- end
58
- end
37
+ # Checking if succeeded
38
+ result.success?
59
39
 
60
- params = { should_fail: true }
40
+ # Checking if failed
41
+ result.failure?
61
42
 
62
- YourCase.new.call(params, nil) do |match|
63
- match.failure :failed do |(code, result)|
64
- puts code
65
- end
43
+ # Getting return value
44
+ result.value!
45
+ ```
66
46
 
67
- match.success do |message|
68
- puts message
69
- end
70
- end
71
- # => failed
47
+ Or with using dry-matcher by passing a block:
72
48
 
73
- params = { should_fail: false }
49
+ ```ruby
50
+ create_user = Users::Create.new
51
+ params = { first_name: 'Don', last_name: 'Quixote' }
74
52
 
75
- YourCase.new.call(params, nil) do |match|
76
- match.failure :failed do |(code, result)|
77
- puts code
53
+ create_user.call(params, current_user) do |on|
54
+ on.success do |user|
55
+ puts "#{user.first_name} created!"
78
56
  end
79
57
 
80
- match.success do |message|
81
- puts message
58
+ on.failure do |(code, message)|
59
+ puts "Failure (#{code}): #{message}"
82
60
  end
83
61
  end
84
- # => it succeeds!
85
62
  ```
86
63
 
87
- **Complex Example**
88
-
89
- ```ruby
90
- class YourCase < UseCases::Base
91
- params {}
64
+ ### Available Optins
92
65
 
93
- try :load_some_resource
94
66
 
95
- step :change_this_resource
67
+ | Optin | Description |
68
+ |---|---|
69
+ | `:authorized` | Adds an extra `authorize` step macro, used to check user permissions. |
70
+ | `:prepared` | Adds an extra `prepare` step macro, used to run some code before the use case runs. |
71
+ | `:publishing` | Adds extra extra `publish` option to all steps, which allows a step to broadcast an event after executing |
72
+ | `:transactional` | Calls `#transaction` on a given `transaction_handler` object around the use case execution |
73
+ | `:validated` | Adds all methods of `dry-transaction` to the use case DSL, which run validations on the received `params` object. |
96
74
 
97
- tee :log_a_message
75
+ ### The base DSL
98
76
 
99
- check :user_can_create_another_resource?
77
+ Use cases implements a DSL similar to dry-transaction, using the [Railway programming paradigm](https://fsharpforfunandprofit.com/rop/).
100
78
 
101
- map :create_some_already_validated_resource
102
79
 
103
- enqueue :send_email_to_user
80
+ Each step macro has a different use case, and so a different subset of available options, different expectations in return values, and interaction with the following step.
104
81
 
105
- private
106
-
107
- def load_some_resource(_, params)
108
- Resource.find(params[:id])
109
- end
82
+ By taking a simple look at the definition of a use case, anyone should be able to understand the business rules it emcompasses. For that it is necessary to understand the following matrix.
110
83
 
111
- def change_this_resource(resource, params)
112
- resource.text = params[:new_text]
113
-
114
- if resource.text == params[:new_text]
115
- Success(resource)
116
- else
117
- Failure([:failed, "could not update resource"])
118
- end
119
- end
120
-
121
- def log_a_message(resource)
122
- Logger.info('Resource updated')
123
- end
124
84
 
125
- def user_can_create_another_resource?(_, _, user)
126
- user.can_create?(Resource)
127
- end
85
+ | | Rationale for use | Accepted Options | Expected return | Passes return value |
86
+ |---|---|---|---|---|
87
+ | **step** | This step has some complexity, and it can fail or succeed. | `with`, `pass` | `Success`/ `Failure` | ✅ |
88
+ | **check** | This step checks sets some rules for the operation, usually verifying that domain models fulfil some conditions. | `with`, `pass`, `failure`, `failure_message` | `boolean` | ❌ |
89
+ | **map** | Nothing should go wrong within this step. If it does, it's an unexpected application error. | `with`, `pass` | `any` | ✅ |
90
+ | **try** | We expect that, in some cases, errors will occur, and the operation fails in that case. | `catch`, `with`, `pass`, `failure`, `failure_message` | `any` | ✅ |
91
+ | **tee** | We don't care if this step succeeds or fails, it's used for non essential side effects. | `with`, `pass` | `any` | ❌ |
128
92
 
129
- def create_some_already_validated_resource(resource, params, user)
130
- new_resource = Resource.create(text: params[:text])
131
- end
93
+ #### Optional steps
132
94
 
133
- def send_email_to_user(new_resource, _, user)
134
- ResourcEMailer.notify_user(user, new_resource).deliver!
135
- end
136
- end
137
- ```
95
+ | | Rationale for use | Accepted Options | Expected return | Passes return value |
96
+ |---|---|---|---|---|
97
+ | **enqueue** *(requires ActiveJob defined) | The same as a `tee`, but executed later to perform non-essential expensive operations. | `with`, `pass`, and sidekiq options | `any` | ❌ |
98
+ | **authorize**<br> *(requires authorized) | Performs authorization on the current user, by running a `check` which, in case of failure, always returns an `unauthorized` failure. | `with`, `pass`, `failure_message` | `boolean` | ❌ |
99
+ | **prepare**<br> *(requires prepared) | Adds a `tee` step that always runs first. Used to mutate params if necessary. | `with`, `pass` | `any` | ❌ |
138
100
 
139
- ### Authorization
140
101
 
141
- `authorize` is a `check` step that returns an `:unauthorized` code in it's `Failure`.
102
+ ### Defining Steps
142
103
 
143
- **Example**
104
+ Defining a step can be done in the body of the use case.
144
105
 
145
106
  ```ruby
146
- class YourCase < UseCases::Base
147
- authorize 'User must be over 18' do |user|
148
- user.age >= 18
149
- end
150
- end
151
-
152
- user = User.where('age = 15').first
107
+ class Users::DeleteAccount
108
+ include UseCases[:validated, :transactional, :publishing, :validated]
153
109
 
154
- YourCase.new.call({}, user) do |match|
155
- match.failure :unauthorized do |(code, result)|
156
- puts code
157
- end
158
- end
159
- # => User must be over 18
110
+ step :do_something, {}
160
111
  ```
161
112
 
162
- ### Example
163
-
164
- For the case of creating posts within a thread.
165
-
166
- **Specs**
167
-
168
- - Only active users or the thread owner can post.
169
- - The post must be between 25 and 150 characters.
170
- - The post must be sanitized to remove any sensitive or explicit content.
171
- - The post must be saved to the database in the end.
172
- - In case any conditions are not met, an failure should be returned with it's own error code.
113
+ In real life, a simple use case would look something like:
173
114
 
174
115
  ```ruby
175
- class Posts::Create < UseCases::Base
116
+ class Users::DeleteAccount
117
+ include UseCases[:validated, :transactional, :publishing, :validated]
176
118
 
177
119
  params do
178
- required(:body).filled(:string).value(size?: 25..150)
179
- required(:thread_id).filled(:integer)
120
+ required(:id).filled(:str?)
180
121
  end
181
122
 
182
- try :load_post_thread, failure: :not_found
123
+ authorize :user_owns_account?, failure_message: 'Cannot delete account'
124
+ try :load_account, catch: ActiveRecord::RecordNotFound, failure: :account_not_found, failure_message: 'Account not found'
125
+ map :delete_account, publish: :account_deleted
126
+ enqueue :send_farewell_email
183
127
 
184
- authorize 'User is not active' do |user, params, thread|
185
- user.active?
186
- end
128
+ private
187
129
 
188
- authorize 'User must be active or the thread author.' do |user, params, thread|
189
- user.active? || thread.author == user
130
+ def user_owns_account?(_previous_step_input, params, current_user)
131
+ current_user.account_id == params[:id]
190
132
  end
191
133
 
192
- step :sanitize_body
193
-
194
- step :create_post
195
-
196
- private
197
-
198
- def load_post_thread(params, user)
199
- Thread.find(params[:thread_id])
134
+ def load_account(_previous_step_input, params, _current_user)
135
+ Account.find_by!(user_id: params[:id])
200
136
  end
201
137
 
202
- def sanitize_body(params)
203
- SanitizeText.new.call(params[:body]).to_monad
138
+ def delete_account(account, _params, _current_user)
139
+ account.destroy!
204
140
  end
205
141
 
206
- def create_post(body, params, user)
207
- post = Post.new(body: body, user_id: user.id, thread_id: params[:thread_id])
208
-
209
- post.save ? Success(post) : Failure([:failed_to_save, post.errors.details])
142
+ # since this executed async, all args are serialized
143
+ def send_farewell_email(account_attrs, params, current_user_attrs)
144
+ user = User.find(params[:id])
145
+ UserMailer.farewell(user).deliver_now!
210
146
  end
211
147
  end
212
148
  ```
213
149
 
214
- And in your controller action
150
+ #### Available Options
151
+
152
+ | Name | Description | Expected Usage |
153
+ |---|---|---|
154
+ | `with` | Retrieves the callable object used to perform the step. |<em><br> Symbol: `send(options[:with])` <br> String: `UseCases.config.container[options[:with]]` <br> Class: `options[:with]`</em> |
155
+ | `pass` | An array of the arguments to pass to the object set by `with`. <br> _options: params, current_user & previous_step_result_ | Array\<Symbol> |
156
+ | `failure` | The code passed to the Failure object. | Symbol / String |
157
+ | `failure_message` | The string message passed to the Failure object. | Symbol / String |
158
+ | `catch` | Array of error classes to rescue from. | Array\<Exception>
159
+
160
+ ## Installation
161
+
162
+ Add this line to your application's Gemfile:
215
163
 
216
164
  ```ruby
217
- # app/controllers/posts_controller.rb
218
- class PostsController < ApplicationController
219
- def create
220
- Posts::Create.new.call(params, current_user) do |match|
221
-
222
- # in success, the return value is the Success payload of the last step (#create_post)
223
- match.success do |post|
224
- # result => <Post:>
225
- end
226
-
227
- # in case ::params or any other dry-validation fails.
228
- match.failure :validation_error do |result|
229
- # result => [:validation_error, ['validation_error', { thread_id: 'is missing' }]
230
- end
231
-
232
- # in case ::try raises an error (ActiveRecord::NotFound in this case)
233
- match.failure :not_found do |result|
234
- # result => [:not_found, ['not_found', 'Could not find thread with id='<params[:thread_id]>'']
235
- end
236
-
237
- # in case any of the ::authorize blocks returns false
238
- match.failure :unauthorized do |result|
239
- # result => [:unauthorized, ['unauthorized', 'User is not active']
240
- end
241
-
242
- # in case #create_post returns a Failure
243
- match.failure :failed_to_save do |result|
244
- # result => [:failed_to_save, ['failed_to_save', { user_id: 'some error' }]
245
- end
246
- end
247
- end
248
- end
165
+ gem 'use_cases'
249
166
  ```
250
167
 
168
+ And then execute:
169
+
170
+ $ bundle install
171
+
172
+ Or install it yourself as:
173
+
174
+ $ gem install use_cases
175
+
176
+ ## Usage
177
+
178
+ To get a good basis to get started on `UseCases`, make sure to read [dry-transaction](https://dry-rb.org/gems/dry-transaction/0.13/)'s documentation first.
179
+
251
180
  ## Development
252
181
 
253
182
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/lib/use_case.rb CHANGED
@@ -6,20 +6,16 @@ require "dry/monads/do"
6
6
  require "dry/monads/do/all"
7
7
  require "dry/matcher/result_matcher"
8
8
 
9
- require "use_cases/authorize"
10
9
  require "use_cases/dsl"
11
- require "use_cases/errors"
12
- require "use_cases/validate"
13
10
  require "use_cases/stack"
14
11
  require "use_cases/params"
15
12
  require "use_cases/stack_runner"
16
- require "use_cases/step_result"
17
- require "use_cases/notifications"
18
- require "use_cases/prepare"
13
+ require "use_cases/result"
19
14
  require "use_cases/step_adapters"
20
15
  require "use_cases/module_optins"
21
16
 
22
17
  module UseCase
18
+ extend UseCases::DSL
23
19
  extend UseCases::ModuleOptins
24
20
 
25
21
  def self.included(base)
@@ -33,7 +29,6 @@ module UseCase
33
29
  extend UseCases::ModuleOptins
34
30
 
35
31
  include UseCases::StepAdapters
36
- include UseCases::Notifications
37
32
  end
38
33
  end
39
34
 
@@ -41,7 +36,6 @@ module UseCase
41
36
 
42
37
  def initialize(*)
43
38
  @stack = UseCases::Stack.new(self.class.__steps__).bind(self)
44
- # self.class.bind_step_subscriptions
45
39
  end
46
40
 
47
41
  def call(params, current_user = nil)
data/lib/use_cases/dsl.rb CHANGED
@@ -17,19 +17,5 @@ module UseCases
17
17
  def __steps__
18
18
  @__steps__ ||= []
19
19
  end
20
-
21
- def subscribe(listeners)
22
- @listeners = listeners
23
-
24
- if listeners.is_a?(Hash)
25
- listeners.each do |step_name, listener|
26
- __steps__.detect { |step| step.name == step_name }.subscribe(listener)
27
- end
28
- else
29
- __steps__.each do |step|
30
- step.subscribe(listeners)
31
- end
32
- end
33
- end
34
20
  end
35
21
  end
@@ -0,0 +1,13 @@
1
+
2
+ return unless defined? ActiveJob
3
+
4
+ module UseCases
5
+ module Events
6
+ class PublishJob < ActiveJob::Base
7
+ def perform(publish_key, payload)
8
+ publish_key += ".async"
9
+ UseCases.publisher.register_and_publish_event(publish_key, payload)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,50 @@
1
+ require "dry/events/publisher"
2
+
3
+ module UseCases
4
+ module Events
5
+ class Publisher
6
+ include Dry::Events::Publisher[:use_cases]
7
+
8
+ def self.register_and_publish_event(event_name, payload)
9
+ register_events(event_name)
10
+
11
+ new.tap do |publisher|
12
+ publisher.subscribe_to_event(event_name)
13
+ publisher.publish(event_name, payload)
14
+ end
15
+
16
+ return unless defined? UseCases::Events::PublishJob
17
+
18
+ UseCases::Events::PublishJob.perform_later(event_name, payload)
19
+ end
20
+
21
+ def self.extract_payload(use_case_args)
22
+ {
23
+ return_value: use_case_args[0],
24
+ params: use_case_args[1],
25
+ current_user: use_case_args[2]
26
+ }
27
+ end
28
+
29
+ def self.register_events(event_name)
30
+ [event_name, "#{event_name}.aync"].each do |key|
31
+ register_event(key) unless events[key]
32
+ end
33
+ end
34
+
35
+ def subscribe_to_event(event_name)
36
+ subscribers_for(event_name).each(&method(:subscribe))
37
+ end
38
+
39
+ def subscribers_for(event_name)
40
+ available_subscribers.select do |subscriber|
41
+ subscriber.subscribed_to?(event_name)
42
+ end
43
+ end
44
+
45
+ def available_subscribers
46
+ UseCases::Events::Subscriber.descendants.map(&:new) + UseCases.subscribers
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ require "active_support/core_ext/class/subclasses"
2
+
3
+ module UseCases
4
+ module Events
5
+ class Subscriber
6
+ def subscribed_to?(event_name)
7
+ event_handler_name = "on_#{event_name.gsub('.', '_')}"
8
+
9
+ respond_to?(event_handler_name)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "use_cases/step_adapters/check"
4
+
5
+ module UseCases
6
+ module ModuleOptins
7
+ module Authorized
8
+ def self.included(base)
9
+ super
10
+ base.class_eval do
11
+ extend DSL
12
+ end
13
+ end
14
+
15
+ module DSL
16
+ DEFAULT_OPTIONS = {
17
+ failure: :unauthorized,
18
+ failure_message: "Not Authorized",
19
+ merge_input_as: :resource
20
+ }
21
+
22
+ def authorize(name, options = {})
23
+ options = DEFAULT_OPTIONS.merge(options)
24
+
25
+ check name, options
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "use_cases/step_adapters/tee"
4
+
5
+ module UseCases
6
+ module ModuleOptins
7
+ module Prepared
8
+ def self.included(base)
9
+ super
10
+ base.class_eval do
11
+ extend DSL
12
+ end
13
+ end
14
+
15
+ module DSL
16
+ def prepare(name, options = {})
17
+ __steps__.unshift StepAdapters::Tee.new(name, nil, options)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "use_cases/events/publisher"
4
+ require "use_cases/events/subscriber"
5
+ require "use_cases/events/publish_job"
6
+
7
+ module UseCases
8
+ module ModuleOptins
9
+ module Publishing
10
+ def self.included(base)
11
+ super
12
+ StepAdapters::Abstract.prepend StepCallPatch
13
+ end
14
+
15
+ module StepCallPatch
16
+ def call(*args)
17
+ super(*args).tap do |result|
18
+ publish_step_result(result, args)
19
+ end
20
+ end
21
+
22
+ def publish_step_result(step_result, args)
23
+ return unless options[:publish]
24
+
25
+ key = extract_event_key(step_result)
26
+ payload = extract_payload(step_result, args)
27
+
28
+ UseCases.publisher.register_and_publish_event(key, payload)
29
+ end
30
+
31
+ private
32
+
33
+ def extract_payload(step_result, args)
34
+ {
35
+ return_value: step_result.value!,
36
+ params: args[-2],
37
+ current_user: args[-1]
38
+ }
39
+ end
40
+
41
+ def extract_event_key(step_result)
42
+ options[:publish].to_s + (step_result.success? ? ".success" : ".failure")
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end