service_objects 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.coveralls.yml +1 -0
  3. data/.metrics +5 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +2 -0
  6. data/.travis.yml +4 -0
  7. data/.yardopts +3 -0
  8. data/Gemfile +3 -0
  9. data/Guardfile +16 -0
  10. data/LICENSE +21 -0
  11. data/README.md +461 -0
  12. data/Rakefile +17 -0
  13. data/config/metrics/STYLEGUIDE +230 -0
  14. data/config/metrics/cane.yml +5 -0
  15. data/config/metrics/churn.yml +6 -0
  16. data/config/metrics/flay.yml +2 -0
  17. data/config/metrics/metric_fu.yml +14 -0
  18. data/config/metrics/pippi.yml +3 -0
  19. data/config/metrics/reek.yml +1 -0
  20. data/config/metrics/roodi.yml +24 -0
  21. data/config/metrics/rubocop.yml +75 -0
  22. data/config/metrics/saikuro.yml +3 -0
  23. data/config/metrics/simplecov.yml +6 -0
  24. data/config/metrics/yardstick.yml +37 -0
  25. data/lib/service_objects/base.rb +43 -0
  26. data/lib/service_objects/helpers/dependable.rb +63 -0
  27. data/lib/service_objects/helpers/exceptions.rb +64 -0
  28. data/lib/service_objects/helpers/messages.rb +96 -0
  29. data/lib/service_objects/helpers/parameterized.rb +85 -0
  30. data/lib/service_objects/helpers/parameters.rb +71 -0
  31. data/lib/service_objects/helpers/validations.rb +54 -0
  32. data/lib/service_objects/invalid.rb +55 -0
  33. data/lib/service_objects/listener.rb +97 -0
  34. data/lib/service_objects/message.rb +117 -0
  35. data/lib/service_objects/null.rb +26 -0
  36. data/lib/service_objects/utils/normal_hash.rb +34 -0
  37. data/lib/service_objects/version.rb +9 -0
  38. data/lib/service_objects.rb +12 -0
  39. data/service_objects.gemspec +28 -0
  40. data/spec/spec_helper.rb +15 -0
  41. data/spec/tests/base_spec.rb +43 -0
  42. data/spec/tests/helpers/dependable_spec.rb +77 -0
  43. data/spec/tests/helpers/exceptions_spec.rb +112 -0
  44. data/spec/tests/helpers/messages_spec.rb +64 -0
  45. data/spec/tests/helpers/parameterized_spec.rb +136 -0
  46. data/spec/tests/helpers/parameters_spec.rb +71 -0
  47. data/spec/tests/helpers/validations_spec.rb +60 -0
  48. data/spec/tests/invalid_spec.rb +69 -0
  49. data/spec/tests/listener_spec.rb +50 -0
  50. data/spec/tests/message_spec.rb +191 -0
  51. data/spec/tests/null_spec.rb +17 -0
  52. data/spec/tests/utils/normal_hash_spec.rb +16 -0
  53. metadata +182 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b235fa6809000778410a4af3b1844d7665a600b9
4
+ data.tar.gz: 0f352e8303b2a39fd6e7ad72750b95f14b53c6dc
5
+ SHA512:
6
+ metadata.gz: 34c1d9c63afd08d42d7d37102bd85acb98e8e49f53024d469ef66ce6ed1be0b5ea0522a6b86009e694539635c134b94a49484d9bd8a9be6c919c7da1dd75b5d8
7
+ data.tar.gz: 2ede947fcf3c189e3ff82cd275ad43e5aefdc83bf7de3bee4cc0e2746aa425aed85e8d4b21e608b23c9dc098de3a2fcb3c76415674e160f89212a052a96615ae
data/.coveralls.yml ADDED
@@ -0,0 +1 @@
1
+ service_name: travis-ci
data/.metrics ADDED
@@ -0,0 +1,5 @@
1
+ # Settings for metric_fu and its packages are collected in the `config/metrics`
2
+ # and loaded by the Hexx::Suit::Metrics::MetricFu.
3
+
4
+ require "hexx-suit"
5
+ Hexx::Suit::Metrics::MetricFu.load
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,2 @@
1
+ ---
2
+ inherit_from: "./config/metrics/rubocop.yml"
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1
4
+ - 2.2
data/.yardopts ADDED
@@ -0,0 +1,3 @@
1
+ --asset LICENSE
2
+ --exclude lib/service_objects/version.rb
3
+ --out doc/api
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,16 @@
1
+ # encoding: utf-8
2
+
3
+ guard :rspec, cmd: "bundle exec rspec" do
4
+
5
+ watch("lib/service_objects.rb") { "spec" }
6
+
7
+ watch(%r{^lib/service_objects/(.+)\.rb$}) do |m|
8
+ "spec/tests/#{ m[1] }_spec.rb"
9
+ end
10
+
11
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
12
+
13
+ watch(/^spec\/spec_helper\w*\.rb$/) { "spec" }
14
+
15
+ watch(%r{^spec/tests/.+_spec\.rb$})
16
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2014 Andrew Kozin, https://github.com/nepalez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,461 @@
1
+ # ServiceObjects
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/service_objects.svg?style=flat)][gem]
4
+ [![Build Status](https://img.shields.io/travis/nepalez/service_objects/master.svg?style=flat)][travis]
5
+ [![Dependency Status](https://img.shields.io/gemnasium/nepalez/service_objects.svg?style=flat)][gemnasium]
6
+ [![Code Climate](https://img.shields.io/codeclimate/github/nepalez/service_objects.svg?style=flat)][codeclimate]
7
+ [![Coverage](https://img.shields.io/coveralls/nepalez/service_objects.svg?style=flat)][coveralls]
8
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)][license]
9
+
10
+ [gem]: https://rubygems.org/gems/service_objects
11
+ [travis]: https://travis-ci.org/nepalez/service_objects
12
+ [gemnasium]: https://gemnasium.com/nepalez/service_objects
13
+ [codeclimate]: https://codeclimate.com/github/nepalez/service_objects
14
+ [coveralls]: https://coveralls.io/r/nepalez/service_objects
15
+ [license]: file:LICENSE
16
+
17
+ The module implements two design patterns:
18
+
19
+ * The [Interactor pattern] to decouple business logics from both models and delivery mechanisms, such as [Rails].
20
+ * The [Observer pattern] to follow the [Tell, don't Ask] design princible.
21
+ The pattern is implemented with the help of [wisper] gem by [Kris Leech].
22
+
23
+ [Rails]: http://rubyonrails.org/
24
+ [Interactor pattern]: http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/
25
+ [Observer pattern]: http://reefpoints.dockyard.com/2013/08/20/design-patterns-observer-pattern.html
26
+ [Tell, don't Ask]: http://martinfowler.com/bliki/TellDontAsk.html
27
+ [wisper]: http://www.github.com/krisleech/wisper
28
+ [Kris Leech]: http://www.github.com/krisleech
29
+
30
+ The module API provides 3 classes:
31
+
32
+ * `ServiceObjects::Base` - for service objects.
33
+ * `ServiceObjects::Listener` - for decorating objects with methods called by service notificiations.
34
+ * `ServiceObjects::Message` - for messages published by service objects.
35
+
36
+ ## Installation
37
+
38
+ Add this line to your application's Gemfile:
39
+
40
+ ```ruby
41
+ gem "service_objects"
42
+ ```
43
+
44
+ And then execute:
45
+
46
+ ```
47
+ bundle
48
+ ```
49
+
50
+ Or install it yourself as:
51
+
52
+ ```
53
+ gem install service_objects
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ The basic usage of the services by example of Rails controller:
59
+
60
+ ```ruby
61
+ # lib/my_gem.rb
62
+ require "service_objects"
63
+
64
+ # app/services/add_foo.rb
65
+ class AddFoo < ServiceObjects::Base
66
+
67
+ # whitelists parameters and defines #params, #bar and #baz
68
+ allows_params :bar, :baz
69
+
70
+ # declares external dependencies
71
+ depends_on :find_foo, default: FindFoo
72
+
73
+ # calls the service, sorts out and reports its results
74
+ def run
75
+ run!
76
+ rescue Invalid => err
77
+ publish :error, err.messages
78
+ else
79
+ publish :created, @foo, messages
80
+ ensure
81
+ self
82
+ end
83
+
84
+ private
85
+
86
+ # business logic lives here
87
+ def run!
88
+ # ... usage of the external service find_foo postponed
89
+ add_foo
90
+ end
91
+
92
+ # rescues errors and re-raises them as Invalid with list of #messages
93
+ def add_foo
94
+ escape { @foo ||= Foo.create! bar: bar, baz: baz }
95
+ end
96
+ end
97
+ ```
98
+
99
+ ```ruby
100
+ # app/controllers/foos_controller.rb
101
+ class FoosController < ApplicationController
102
+
103
+ def create
104
+ # Create the service object with necessary parameters.
105
+ # All the business logic is encapsulated inside the service,
106
+ # and the controller knows nothing what the service will do.
107
+ service = AddFoo.new params.allow(:bar, :baz)
108
+ # Subscribe the listener for the service's notifications
109
+ service.subscribe listener, prefix: :on
110
+ # Run the service
111
+ service.run
112
+ # If the service doesn't call any listener method,
113
+ # then a listener provides some default actions
114
+ listener.finalize
115
+ end
116
+
117
+ private
118
+
119
+ # The listener decorates the controller with methods
120
+ # to listen the service object's notifications
121
+ # (see FoosListener#otherwise method below).
122
+ def listener
123
+ @listener ||= FoosListener.new self
124
+ end
125
+
126
+ # The class to work out service object notifications
127
+ # The #render method is delegated to the controller
128
+ class FoosListener < ServiceObjects::Listener
129
+
130
+ # The method to be called when a service publishes
131
+ # the 'added' notification.
132
+ def on_added(foo, *, messages)
133
+ render "created", locals: { foo: foo, messages: messages }
134
+ end
135
+
136
+ # The method to be called when a service publishes
137
+ # the 'error' notification.
138
+ def on_error(*, messages)
139
+ render "error", locals: { messages: messages }
140
+ end
141
+
142
+ # The method is called by the #finalize in case no methods has been called
143
+ # by the service object.
144
+ #
145
+ # This allows to focuse only on a subset of service notifications above.
146
+ def otherwise
147
+ render "no_result"
148
+ end
149
+ end
150
+ end
151
+ ```
152
+
153
+ The service can notify several listeners (controller itself, mailer etc.).
154
+
155
+ ## Base
156
+
157
+ The `ServiceObjects::Base` provides base class for services.
158
+
159
+ ```ruby
160
+ require "service_objects"
161
+
162
+ class AddFoo < ServiceObjects::Base
163
+ end
164
+ ```
165
+
166
+ ### Parameters declaration
167
+
168
+ Define allowed parameters for objects:
169
+
170
+ ```ruby
171
+ class AddFoo < ServiceObjects::Base
172
+ allows_params :bar, :baz
173
+ end
174
+ ```
175
+
176
+ Parameters are whitelisted and assigned to `#params` hash (all keys are *symbolized*).
177
+
178
+ Attributes are also defined as aliases for corresponding params, so that `#bar` and `#bar=` are equivalent to `#params[:bar]`, `#params[:bar]=`.
179
+
180
+ **Note**:
181
+ The service ignores parameters except for explicitly declared. The client can give relevant data to the service, and leave the latter to figure them out by itself.
182
+
183
+ ### Validation
184
+
185
+ The `ServiceObject::Base` includes [ActiveModel::Validations] with methods `.validates`, `.validate`, `#errors`, `#valid?` and `#invalid?`. Use them to add action context - specific validations.
186
+
187
+ The method `#validate!` raises the `ServiceObject::Invalid` if validation fails.
188
+
189
+ ```ruby
190
+ class AddFoo < ServiceObjects::Base
191
+ allows_params :bar, :baz
192
+
193
+ validates :bar, presence: true
194
+ validates :baz, numericality: { greater_than: 1 }, allow_nil: true
195
+
196
+ # ...
197
+
198
+ def run!
199
+ # ...
200
+ validate!
201
+ # ...
202
+ end
203
+ end
204
+ ```
205
+
206
+ **Note:** You aren't restricted in selecting time for validation. Prepare attributes (either "real" or [virtual]) and run `#validate!` when necessary.
207
+
208
+ [ActiveModel::Validations]: http://api.rubyonrails.org/classes/ActiveModel/Validations.html
209
+ [virtual]: http://railscasts.com/episodes/16-virtual-attributes?view=asciicast
210
+
211
+ ### Dependencies declaration
212
+
213
+ As a rule, services uses each other to keep the code DRY. For example, the service that *adds* a new foo (whatever it means) can use another service to *find* an existing foo.
214
+
215
+ To made all that dependencies injectable via [setter injection], define them explicitly:
216
+
217
+ ```ruby
218
+ class AddFoo < ServiceObjects::Base
219
+ # ...
220
+
221
+ # Providing the FindFoo is available at the moment AddFoo being defined:
222
+ depends_on :find_foo, default: FindFoo
223
+ end
224
+ ```
225
+
226
+ Default value can be either assigned or skipped. In the last case the [Null Object] will be assigned by default.
227
+
228
+ The class method is public to postpone the default implementation until it is defined:
229
+
230
+ ```ruby
231
+ class AddFoo < ServiceObjects::Base
232
+ # ...
233
+ depends_on :find_foo
234
+ end
235
+
236
+ # later
237
+ FindFoo = Class.new
238
+ AddFoo.depends_on :find_foo, default: FindFoo
239
+ ```
240
+
241
+ This provides the instance attribute `#find_foo`. You can inject the dependency to the via setter:
242
+
243
+ ```ruby
244
+ service = AddFoo.new bar: "bar", baz: "baz"
245
+ service.find_foo = GetFoo
246
+ ```
247
+
248
+ [setter injection]: http://brandonhilkert.com/blog/a-ruby-refactor-exploring-dependency-injection-options/
249
+ [Null Object]: https://robots.thoughtbot.com/rails-refactoring-example-introduce-null-object
250
+
251
+ ### Run method
252
+
253
+ It is expected the `run` method to provide all the necessary staff and notify listeners via `#publish` method.
254
+
255
+ See [wisper] for details on `#publish` and `#subscribe` methods.
256
+
257
+ ```ruby
258
+ class AddFoo < ServiceObjects::Base
259
+ # ...
260
+
261
+ # The method contains the reporting logic only
262
+ def run
263
+ run!
264
+ rescue Found
265
+ publish :found, @foo, messages
266
+ rescue Invalid => err
267
+ publish :error, err.messages
268
+ else
269
+ publish :added, @foo, messages
270
+ ensure
271
+ self
272
+ end
273
+
274
+ # ...
275
+
276
+ private
277
+
278
+ Found = Class.new(RuntimeError) # the internal message
279
+
280
+ # Business logic lives here
281
+ def run!
282
+ get_foo
283
+ create_foo
284
+ end
285
+
286
+ def get_foo
287
+ # ... finds and assigns @foo somehow
288
+ fail Found if @foo
289
+ end
290
+
291
+ def add_foo
292
+ # ...
293
+ end
294
+ end
295
+ ```
296
+
297
+ There are some helper available:
298
+ * `messages` - an array of collected service messages
299
+ * `add_message` - adds the new message to the array
300
+ * `escape` - rescues from `StandardErrors` and re-raises them as `ServiceObject::Invalid` with collection of `#messages`.
301
+
302
+ **Note** Following [command-query separation] the `#run` method (being a command) returns `self`.
303
+
304
+ [command-query separation]: http://en.wikipedia.org/wiki/Command-query_separation
305
+
306
+ ### External services
307
+
308
+ External services should be used in just the same way as in the controller example.
309
+
310
+ ```ruby
311
+ class AddFoo < ServiceObjects::Base
312
+ depends_on :find_foo, default: FindFoo
313
+
314
+ # ...
315
+
316
+ def get_foo
317
+ service = find_foo.new params
318
+ service.subscribe listener, prefix: :on
319
+ service.run
320
+
321
+ # the method runs #otherwise callback in case
322
+ # no other notificaton has been received
323
+ listener.finalize
324
+ end
325
+
326
+ # decorates the service with methods to listen to external service
327
+ def listener
328
+ @listener ||= FindListener.new self
329
+ end
330
+
331
+ class FindListener < ServiceObjects::Listener
332
+ def on_found(foo, *)
333
+ __getobj__.foo = foo
334
+ end
335
+
336
+ def on_error(*, messages)
337
+ __getobj__.messages = messages
338
+ end
339
+
340
+ def otherwise
341
+ add_message "complain", "haven't been informed"
342
+ end
343
+ end
344
+ end
345
+ ```
346
+
347
+ Here the `#get_foo` runs the external service and listens to its notifications. Instead of the long syntax above, you can use a shortcut:
348
+
349
+ ```ruby
350
+ # ...
351
+ def get_foo
352
+ run_service find_foo.new(params), listener, prefix: :on
353
+ end
354
+ ```
355
+
356
+ ## Listener
357
+
358
+ The listener is a [decorator] that:
359
+
360
+ * defines callbacks to listen to service notifications.
361
+ * delegates all undefined methods to the encapsulated object (available via [__getobj__] instance method).
362
+ * defines the `#finalize` method to run `#otherwise` callback in case no other methods has been checked (via `#respond_to?` method).
363
+
364
+ ```ruby
365
+ class FooListener < ServiceObjects::Listener
366
+ def on_success(*)
367
+ "Notified on success"
368
+ end
369
+
370
+ def otherwise
371
+ "Hasn't been notified"
372
+ end
373
+ end
374
+
375
+ listener = FooListener.new
376
+ listener.finalize
377
+ # => "Hasn't been notified"
378
+
379
+ listener.respond_to? :on_error
380
+ # => false
381
+ listener.finalize
382
+ # => "Hasn't been notified"
383
+
384
+ listener.respond_to? :on_success
385
+ # => true
386
+ listener.finalize
387
+ # => nil
388
+ ```
389
+
390
+ [decorator]: http://nithinbekal.com/posts/ruby-decorators/
391
+ [__getobj__]: http://ruby-doc.org//stdlib-2.1.0/libdoc/delegate/rdoc/SimpleDelegator.html#method-i-__getobj__
392
+
393
+ ## Message
394
+
395
+ The `ServiceObjects::Base#messages` collects messages with text, type and optional priority:
396
+
397
+ ```ruby
398
+ message = ServiceObjects::Message.new priority: 0, type: "bar", text: "foo"
399
+ message.priority # => 0.0
400
+ message.type # => "info"
401
+ message.text # => "some text"
402
+ message.to_h # => { type: "bar", type: "foo" }
403
+ message.to_json # => "{\"type\":\"bar\",\"text\":"\foo\"}"
404
+ ```
405
+
406
+ When a priority hasn't been defined explicitly, it is set to `-1.0` for errors, and to `0.0` otherwise. Messages are sorted by priority, type and text in a "natural" order.
407
+
408
+ Use the `#add_message` helper to add a message to the collection:
409
+
410
+ ```ruby
411
+ service = ServiceObjects::Base.new
412
+ service.send :add_message type: "info", text: "some text"
413
+ service.send :messages
414
+ # => [<Message type="info" text="some text" priority=0.0>]
415
+ ```
416
+
417
+ When a `text:` value is a symbol, it is translated in the scope of current service class:
418
+
419
+ ```yaml
420
+ # config/locales/en.yml
421
+ ---
422
+ en:
423
+ activemodel:
424
+ messages:
425
+ models:
426
+ foo:
427
+ excuse: # the type of the message
428
+ not_informed: "I haven't been informed on the %{subject}"
429
+ ```
430
+
431
+ ```ruby
432
+ service.send :add_message type: "excuse", text: :not_informed, subject: "issue"
433
+ # => [<Message text="I haven't been informed on the issue" ...>]
434
+ ```
435
+
436
+ ## Compatibility
437
+
438
+ Tested under MRI rubies >= 2.1
439
+
440
+ RSpec 3.0+ used for testing
441
+
442
+ Collection of testing, debugging and code metrics is defined
443
+ in the [hexx-suit](https://github.com/nepalez/hexx-suit) gem.
444
+
445
+ To run tests use `rake test`, to run code metrics use `rake check`. All the metric settings are collected in the `config/metrics` folder.
446
+
447
+ ## Contributing
448
+
449
+ * Fork the project.
450
+ * Read the [Styleguide](file:config/metrics/STYLEGUIDE).
451
+ * Make your feature addition or bug fix.
452
+ * Add tests for it. This is important so I don't break it in a
453
+ future version unintentionally.
454
+ * Commit, do not mess with Rakefile or version
455
+ (if you want to have your own version, that is fine but bump version
456
+ in a commit by itself I can ignore when I pull)
457
+ * Send me a pull request. Bonus points for topic branches.
458
+
459
+ ## License
460
+
461
+ See [MIT LICENSE][license]