base-service 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80c1f1f3a7747a5b86a3acd42fcc4f80fd0690530c5bb2a5a9e8cf473b8fd103
4
+ data.tar.gz: 5a2016293ca7765ade288a34c1488e6783f793735c8d7cc7e66d66b549a4c8ad
5
+ SHA512:
6
+ metadata.gz: 96f4a2049c00564a96bd0bc934d7b89fe839481b50a5fe062e96a1a8915cee6faf0f0a831d5de5486237549516e641452f904c875a7ba17ec211b474b8af304c
7
+ data.tar.gz: 2dcbf079cb0e14de14c892c2ad5fdf048ed6b8df052d630a0be45151c5341e783f58e4e226c8ca53bf6283de18291190a028cfcd2492609fb0b93886eac6bac8
data/README.md ADDED
@@ -0,0 +1,462 @@
1
+ # Base Service
2
+
3
+ [![Build Status](https://github.com/nicolasva/base-service/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolasva/base-service/actions/workflows/ci.yml)
4
+ [![Code Climate](https://codeclimate.com/github/nicolasva/base-service.svg)](https://codeclimate.com/github/nicolasva/base-service)
5
+ [![Gem Version](https://badge.fury.io/rb/base-service.svg)](https://rubygems.org/gems/base-service)
6
+ [![Documentation Status](https://img.shields.io/badge/docs-RubyDoc.info-blue.svg)](https://www.rubydoc.info/gems/base-service)
7
+ [![Downloads](https://img.shields.io/gem/dt/base-service.svg?style=flat)](https://rubygems.org/gems/base-service)
8
+
9
+ `base-service` fournit une classe de base légère pour construire des services
10
+ Ruby avec une interface commune :
11
+
12
+ - les paramètres nommés deviennent des variables d'instance ;
13
+ - la logique métier est définie dans une méthode `call` sans argument ;
14
+ - les erreurs et les messages sont collectés pendant l'exécution ;
15
+ - chaque appel retourne un objet `Service::Result` ;
16
+ - des callbacks nommés peuvent être configurés grâce à
17
+ [`callback-collection`](https://github.com/nicolasva/callback-collection).
18
+
19
+ ## Installation
20
+
21
+ Ajoutez la gem au `Gemfile` de l'application :
22
+
23
+ ```ruby
24
+ gem "base-service"
25
+ ```
26
+
27
+ Puis installez les dépendances :
28
+
29
+ ```sh
30
+ bundle install
31
+ ```
32
+
33
+ Dans une application Ruby sans Bundler, chargez explicitement la gem :
34
+
35
+ ```ruby
36
+ require "base_service"
37
+ ```
38
+
39
+ Rails charge normalement la gem automatiquement lorsqu'elle est déclarée dans
40
+ le `Gemfile`.
41
+
42
+ ## Créer un service
43
+
44
+ Un service hérite de `Service::Base` et implémente une méthode publique `call`
45
+ sans argument :
46
+
47
+ ```ruby
48
+ class DoubleValueService < Service::Base
49
+ def call
50
+ return append_error(:missing_value, "Value is required") unless @value
51
+
52
+ append_message("Value doubled")
53
+ @value * 2
54
+ end
55
+ end
56
+ ```
57
+
58
+ Les arguments nommés transmis au service sont automatiquement disponibles sous
59
+ forme de variables d'instance. Ici, `value: 21` devient `@value`.
60
+
61
+ ```ruby
62
+ result = DoubleValueService.call(value: 21)
63
+ ```
64
+
65
+ Il n'est pas nécessaire d'écrire un constructeur dans chaque service.
66
+
67
+ ## Résultat d'un appel
68
+
69
+ `MonService.call(...)` ne retourne pas directement la valeur métier. Il
70
+ retourne toujours un `Service::Result` :
71
+
72
+ ```ruby
73
+ result = DoubleValueService.call(value: 21)
74
+
75
+ result.result # => 42
76
+ result.successful? # => true
77
+ result.errors # => []
78
+ result.messages # => ["Value doubled"]
79
+ ```
80
+
81
+ | Méthode | Description |
82
+ |---|---|
83
+ | `result.result` | Valeur retournée par la méthode `call` du service |
84
+ | `result.errors` | Tableau des erreurs ajoutées pendant l'exécution |
85
+ | `result.messages` | Tableau des messages ajoutés pendant l'exécution |
86
+ | `result.successful?` | `true` lorsque `result.errors` est vide |
87
+
88
+ L'objet `Service::Result` ainsi que ses tableaux `errors` et `messages` sont
89
+ gelés après l'exécution. La valeur métier contenue dans `result.result` n'est
90
+ pas gelée automatiquement.
91
+
92
+ ## Ajouter des erreurs
93
+
94
+ La méthode protégée `append_error(type, message)` ajoute un objet
95
+ `Service::Error` au résultat :
96
+
97
+ ```ruby
98
+ class CreateUserService < Service::Base
99
+ def call
100
+ unless @user.save
101
+ @user.errors.messages.each do |type, messages|
102
+ append_error(type, messages)
103
+ end
104
+ end
105
+
106
+ @user
107
+ end
108
+ end
109
+ ```
110
+
111
+ ```ruby
112
+ result = CreateUserService.call(user: user)
113
+
114
+ result.successful? # => false si au moins une erreur a été ajoutée
115
+
116
+ error = result.errors.first
117
+ error.type # => :email
118
+ error.message # => ["has already been taken"]
119
+ error.caller_info # => emplacement où append_error a été appelé
120
+ ```
121
+
122
+ Le type et le message ne sont pas transformés par la gem. Ils peuvent donc
123
+ reprendre directement les valeurs fournies par Rails ou par le domaine métier.
124
+
125
+ `append_error` enregistre uniquement l'erreur : il ne lève pas d'exception et
126
+ n'arrête pas automatiquement le service. Utilisez `return` lorsque l'exécution
127
+ doit s'arrêter :
128
+
129
+ ```ruby
130
+ def call
131
+ unless valid?
132
+ append_error(:invalid, "The operation is invalid")
133
+ return nil
134
+ end
135
+
136
+ perform_operation
137
+ end
138
+ ```
139
+
140
+ Les exceptions levées par la logique métier, Active Record ou un callback ne
141
+ sont pas interceptées. Elles remontent normalement à l'appelant.
142
+
143
+ ## Ajouter des messages
144
+
145
+ La méthode protégée `append_message(message)` ajoute une information non
146
+ bloquante au résultat :
147
+
148
+ ```ruby
149
+ class ImportUserService < Service::Base
150
+ def call
151
+ user = User.create!(@attributes)
152
+ append_message("User #{user.id} imported")
153
+ user
154
+ end
155
+ end
156
+ ```
157
+
158
+ ```ruby
159
+ result = ImportUserService.call(attributes: { email: "ruby@example.com" })
160
+
161
+ result.successful? # => true
162
+ result.messages # => ["User 42 imported"]
163
+ result.result # => instance de User
164
+ ```
165
+
166
+ Ajouter un message ne modifie pas la valeur de `successful?`.
167
+
168
+ ## Exemple avec un service Rails
169
+
170
+ Le service peut utiliser des modèles Active Record, des helpers Rails et des
171
+ méthodes privées comme n'importe quel objet Ruby :
172
+
173
+ ```ruby
174
+ module Billing
175
+ class DiscountedPriceService < Service::Base
176
+ include ActionView::Helpers::NumberHelper
177
+
178
+ def call
179
+ return 0 unless customer.discount_enabled?
180
+ return 0 unless customer.discount_percentage.present?
181
+
182
+ calculate_discounted_price
183
+ end
184
+
185
+ private
186
+
187
+ def calculate_discounted_price
188
+ discount =
189
+ customer.discount_percentage.to_f / 100 * @original_price
190
+
191
+ return @original_price if discount < customer.minimum_discount.to_f
192
+
193
+ unit_price = catalog.price_for(product)
194
+ return @original_price if unit_price.blank?
195
+
196
+ if product.sold_in_whole_units?
197
+ (discount / unit_price).truncate * unit_price
198
+ else
199
+ discount
200
+ end
201
+ end
202
+
203
+ def catalog
204
+ @catalog ||= @order.store.catalog
205
+ end
206
+
207
+ def customer
208
+ @customer ||= @order.customer
209
+ end
210
+
211
+ def product
212
+ @product ||= @order.products.first
213
+ end
214
+ end
215
+ end
216
+ ```
217
+
218
+ Appel du service :
219
+
220
+ ```ruby
221
+ result = Billing::DiscountedPriceService.call(
222
+ original_price: 10_000,
223
+ order: order
224
+ )
225
+
226
+ if result.successful?
227
+ discounted_price = result.result
228
+ else
229
+ Rails.logger.error(result.errors.map(&:message))
230
+ end
231
+ ```
232
+
233
+ L'instance du service reste mutable pendant l'exécution. Les mémorisations
234
+ avec `||=`, comme `@customer ||= ...`, fonctionnent donc normalement.
235
+
236
+ ## Exemple de création d'une commande
237
+
238
+ ```ruby
239
+ module Orders
240
+ class CreateOrderService < Service::Base
241
+ def call
242
+ order = create_order
243
+ create_invoice_for(order)
244
+ order
245
+ end
246
+
247
+ private
248
+
249
+ def create_order
250
+ order = Order.new(
251
+ customer_id: @customer.id,
252
+ product_id: @product.id,
253
+ quantity: @quantity
254
+ )
255
+
256
+ unless order.save
257
+ order.errors.messages.each do |type, messages|
258
+ append_error(type, messages)
259
+ end
260
+ end
261
+
262
+ order
263
+ end
264
+
265
+ def create_invoice_for(order)
266
+ return unless order.persisted?
267
+
268
+ invoice = Invoice.new(
269
+ order: order,
270
+ customer: order.customer,
271
+ total: order.total
272
+ )
273
+
274
+ return if invoice.save
275
+
276
+ append_error(
277
+ :invoice_creation_failed,
278
+ "Invoice could not be created for order #{order.id}"
279
+ )
280
+ end
281
+ end
282
+ end
283
+ ```
284
+
285
+ ```ruby
286
+ result = Orders::CreateOrderService.call(
287
+ customer: customer,
288
+ product: product,
289
+ quantity: 2
290
+ )
291
+
292
+ order = result.result
293
+
294
+ if result.successful?
295
+ redirect_to order_path(order)
296
+ else
297
+ flash.now[:alert] = result.errors.map(&:message).join(", ")
298
+ end
299
+ ```
300
+
301
+ Le service retourne la commande dans `result.result`, même si des erreurs
302
+ ont été ajoutées. C'est `result.successful?` qui indique si l'exécution est
303
+ considérée comme réussie.
304
+
305
+ ## Utilisation dans un autre service
306
+
307
+ Un service peut appeler un autre service. Il faut vérifier le
308
+ `Service::Result` retourné et récupérer explicitement sa valeur métier :
309
+
310
+ ```ruby
311
+ class CheckoutService < Service::Base
312
+ def call
313
+ order_result =
314
+ Orders::CreateOrderService.call(
315
+ customer: @customer,
316
+ product: @product,
317
+ quantity: @quantity
318
+ )
319
+
320
+ unless order_result.successful?
321
+ order_result.errors.each do |error|
322
+ append_error(error.type, error.message)
323
+ end
324
+
325
+ return nil
326
+ end
327
+
328
+ order_result.result
329
+ end
330
+ end
331
+ ```
332
+
333
+ Les erreurs d'un sous-service ne sont pas automatiquement copiées dans le
334
+ service appelant. L'exemple ci-dessus les propage explicitement.
335
+
336
+ ## Utilisation dans un contrôleur Rails
337
+
338
+ ```ruby
339
+ class OrdersController < ApplicationController
340
+ def create
341
+ result =
342
+ Orders::CreateOrderService.call(
343
+ customer: current_customer,
344
+ product: product,
345
+ quantity: params[:quantity]
346
+ )
347
+
348
+ if result.successful?
349
+ redirect_to order_path(result.result), notice: "Order created"
350
+ else
351
+ flash.now[:alert] = result.errors.map(&:message).join(", ")
352
+ render :new, status: :unprocessable_entity
353
+ end
354
+ end
355
+ end
356
+ ```
357
+
358
+ ## Callbacks
359
+
360
+ Le bloc transmis à `.call` ou `.new` construit une collection de callbacks
361
+ immuable fournie par la gem `callback-collection` :
362
+
363
+ ```ruby
364
+ class NotifyUserService < Service::Base
365
+ def call
366
+ user = User.find(@user_id)
367
+ @callbacks&.respond_with(:success, user)
368
+ user
369
+ rescue ActiveRecord::RecordNotFound => error
370
+ append_error(:not_found, error.message)
371
+ @callbacks&.respond_with(:failure, error)
372
+ nil
373
+ end
374
+ end
375
+ ```
376
+
377
+ ```ruby
378
+ result = NotifyUserService.call(user_id: 42) do |callbacks|
379
+ callbacks.success do |user|
380
+ UserMailer.notification(user).deliver_later
381
+ end
382
+
383
+ callbacks.failure do |error|
384
+ Rails.logger.warn(error.message)
385
+ end
386
+ end
387
+ ```
388
+
389
+ Les callbacks ne sont pas exécutés automatiquement par `Service::Base`. Le
390
+ service choisit quand les appeler avec :
391
+
392
+ ```ruby
393
+ @callbacks.respond_with(:nom_du_callback, argument)
394
+ ```
395
+
396
+ L'opérateur `&.` permet de rendre le bloc de callbacks facultatif. Sans `&.`,
397
+ le service doit être appelé avec le callback attendu.
398
+
399
+ La collection est gelée après sa configuration. Un callback inconnu provoque
400
+ une `NoMethodError`, et une exception levée dans un callback remonte à
401
+ l'appelant.
402
+
403
+ Consultez la documentation de
404
+ [`callback-collection`](https://github.com/nicolasva/callback-collection) pour
405
+ les callbacks enregistrés par méthode et la compatibilité avec les Ractors.
406
+
407
+ ## Instanciation manuelle
408
+
409
+ La forme recommandée est :
410
+
411
+ ```ruby
412
+ result = MyService.call(argument: value)
413
+ ```
414
+
415
+ Elle équivaut à :
416
+
417
+ ```ruby
418
+ service = MyService.new(argument: value)
419
+ result = service.execute
420
+ ```
421
+
422
+ Dans les deux cas, `execute` crée un nouveau contexte d'exécution, appelle la
423
+ méthode métier `call`, puis construit un `Service::Result`.
424
+
425
+ Évitez de réutiliser une même instance avec plusieurs appels à `execute`. La
426
+ forme `MyService.call(...)` crée une instance dédiée pour chaque exécution.
427
+
428
+ ## Développement
429
+
430
+ ```sh
431
+ bundle install
432
+ bundle exec rake
433
+ ```
434
+
435
+ La tâche par défaut exécute les tests puis construit la gem dans `pkg/`.
436
+
437
+ ## Publication sur RubyGems
438
+
439
+ Les versions sont publiées avec
440
+ [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/).
441
+ Aucune clé API RubyGems ne doit être ajoutée aux secrets GitHub.
442
+
443
+ Avant la première publication, créez un **Pending Trusted Publisher** dans
444
+ votre profil RubyGems avec les paramètres suivants :
445
+
446
+ - gem : `base-service` ;
447
+ - propriétaire du dépôt : `nicolasva` ;
448
+ - dépôt : `base-service` ;
449
+ - workflow : `release.yml` ;
450
+ - environnement GitHub : `release`.
451
+
452
+ Publiez ensuite une version en créant un tag correspondant exactement à
453
+ `Service::VERSION` :
454
+
455
+ ```sh
456
+ VERSION=$(ruby -Ilib -rbase_service/version -e 'print Service::VERSION')
457
+ git tag "v${VERSION}"
458
+ git push origin "v${VERSION}"
459
+ ```
460
+
461
+ Le workflow GitHub Actions construit alors la gem et la publie sur RubyGems.
462
+ RubyDoc génère automatiquement la documentation de la version publiée.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Service
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "callback_collection"
4
+ require_relative "base_service/version"
5
+
6
+ module Service
7
+ class Base
8
+ def initialize(**args, &block)
9
+ args.each { |key, value| instance_variable_set("@#{key}", value) }
10
+ @callbacks = ::CallbackCollection.new(&block) if block
11
+ end
12
+
13
+ def self.call(**args, &block)
14
+ new(**args, &block).execute
15
+ end
16
+
17
+ def execute
18
+ @context = Context.new
19
+ result_payload = call
20
+
21
+ Result.new(result_payload, @context.errors, @context.messages)
22
+ end
23
+
24
+ protected
25
+
26
+ def append_error(type, message)
27
+ @context.append_error(type, message, caller.first)
28
+ end
29
+
30
+ def append_message(message)
31
+ @context.append_message(message)
32
+ end
33
+ end
34
+
35
+ class Context
36
+ attr_reader :errors, :messages
37
+
38
+ def initialize
39
+ @errors = []
40
+ @messages = []
41
+ end
42
+
43
+ def append_error(type, message, caller_info = caller.first)
44
+ @errors << ::Service::Error.new(type, message, caller_info)
45
+ end
46
+
47
+ def append_message(message)
48
+ @messages << message
49
+ end
50
+ end
51
+
52
+ class Error
53
+ attr_reader :type, :message, :caller_info
54
+
55
+ def initialize(type, message, caller_info)
56
+ @type = type
57
+ @caller_info = caller_info
58
+ @message = message
59
+ end
60
+ end
61
+
62
+ class Result
63
+ attr_reader :result, :errors, :messages
64
+
65
+ def initialize(result, errors, messages)
66
+ @result = result
67
+ @errors = errors.dup.freeze
68
+ @messages = messages.dup.freeze
69
+ freeze
70
+ end
71
+
72
+ def successful?
73
+ @errors.empty?
74
+ end
75
+ end
76
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: base-service
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Nicolas Vandenbogaerde
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: callback-collection
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '7'
36
+ type: :development
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '5'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '7'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ description: Build service objects with isolated execution contexts, immutable results,
61
+ and named callbacks.
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - README.md
67
+ - lib/base_service.rb
68
+ - lib/base_service/version.rb
69
+ homepage: https://github.com/nicolasva/base-service
70
+ licenses: []
71
+ metadata:
72
+ rubygems_mfa_required: 'true'
73
+ documentation_uri: https://www.rubydoc.info/gems/base-service/0.1.1
74
+ source_code_uri: https://github.com/nicolasva/base-service
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '2.7'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.20
90
+ specification_version: 4
91
+ summary: An immutable base class for Ruby service objects
92
+ test_files: []