service_core 0.2.0 → 1.0.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.
data/README.md CHANGED
@@ -1,40 +1,50 @@
1
1
  # ServiceCore
2
2
 
3
- ServiceCore provides a standardized way to define and use service objects in Ruby and Rails applications. It includes support for specifying fields, validations, responses, and error logging. This approach is inspired by the DRY (Don't Repeat Yourself) principle and Rails' convention over configuration philosophy.
3
+ ServiceCore is a small Ruby gem that gives service objects a shared shape. Every service exposes a single `call` method and returns the same four-key response, regardless of who wrote it. The idea behind the shape is unpacked in [The Shape of a Service Response](https://agnosticlogic.substack.com/p/the-shape-of-a-service-response).
4
+
5
+ - Four-key response contract: **status**, **data**, **message**, **errors**.
6
+ - Field declarations with types, defaults, and ActiveModel validations.
7
+ - Step-by-step validation that survives `valid?` calls.
8
+ - Hash-compatible value objects (`Response`, `FieldSet`) instead of raw hashes.
9
+ - Works on Ruby >= 3.1 and Rails (ActiveModel/ActiveSupport) 6.1 through 8.x.
4
10
 
5
11
  ## Installation
6
- Install the gem and add to the application's Gemfile by executing:
7
12
 
8
13
  ```sh
9
14
  bundle add service_core
10
15
  ```
11
- Or in your Gemfile:
16
+
17
+ Or add it to your Gemfile:
12
18
 
13
19
  ```ruby
14
20
  gem "service_core"
15
21
  ```
16
22
 
17
- If the bundler is not being used to manage dependencies, install the gem by executing:
23
+ If you are not using Bundler:
18
24
 
19
25
  ```sh
20
26
  gem install service_core
21
27
  ```
22
28
 
23
- ### Service Response Structure
24
- The idea is to define a convention that the response from a service can have only four keys:
25
- - status
26
- - data
27
- - message
28
- - errors
29
+ ## The four-key response
30
+
31
+ Every service responds with at most four keys:
32
+
33
+ | Key | Purpose |
34
+ | --------- | ---------------------------------------------------------------------- |
35
+ | `status` | Machine-readable signal (`"success"`, `"error"`, or any custom state). |
36
+ | `data` | The payload the caller asked for. |
37
+ | `message` | High-level human context, distinct from per-field error detail. |
38
+ | `errors` | Structured error detail (Hash, Array, ActiveModel::Errors, ...). |
29
39
 
30
- The data type of any of the above keys is not enforced, giving the developer flexibility to return based on the use case, but should follow this response structure.
31
- ## Usage
40
+ The shape is enforced; the value types are not. Writing a key other than these four raises `ServiceCore::InvalidKey`.
32
41
 
33
- ### Defining a Service
42
+ ## Defining a service
43
+
44
+ Include `ServiceCore` in your class and implement `perform`:
34
45
 
35
- To define a new service, include the `ServiceCore` module in your service class and define your fields and the `perform` method.
36
46
  ```ruby
37
- class MyService
47
+ class GreetService
38
48
  include ServiceCore
39
49
 
40
50
  field :first_name, :string
@@ -42,212 +52,137 @@ class MyService
42
52
  field :active, :boolean, default: true
43
53
 
44
54
  def perform
45
- success_response(message: "Hello, World", data: name)
55
+ success_response(message: "Hello, World", data: full_name)
46
56
  end
47
57
 
48
- def name
58
+ private
59
+
60
+ def full_name
49
61
  "#{first_name} #{last_name}"
50
62
  end
51
63
  end
52
64
  ```
53
65
 
54
- ### Using a Service
55
- Instantiate and call the service to execute it. The call method will validate the input, perform the operation, and return the output.
56
- ```ruby
57
- service = MyService.new(first_name: "John", last_name: "Doe")
58
- result = service.call
59
- puts result
60
- # Output:
61
- # {
62
- # status: "success",
63
- # message: "Hello, World",
64
- #. data: "John Doe"
65
- # }
66
-
67
- puts service.output
68
- # Output:
69
- # {
70
- # status: "success",
71
- # message: "Hello, World",
72
- #. data: "John Doe"
73
- # }
74
- ```
66
+ ## Calling a service
67
+
68
+ You can call a service either via `new(...).call` or via the `.call` shortcut on the class:
75
69
 
76
- The `call` method can be invoked on the service class and it too will return the object of the service.
77
70
  ```ruby
78
- obj = MyService.call(first_name: "John", last_name: "Doe")
79
- puts obj.output
80
- # Output:
81
- # {
82
- # status: "success",
83
- # message: "Hello, World",
84
- #. data: "John Doe"
85
- # }
71
+ response = GreetService.new(first_name: "John", last_name: "Doe").call
72
+ puts response
73
+ # => {status: "success", message: "Hello, World", data: "John Doe"}
74
+
75
+ service = GreetService.call(first_name: "John", last_name: "Doe")
76
+ service.response
77
+ # => {status: "success", message: "Hello, World", data: "John Doe"}
86
78
  ```
87
79
 
88
- ### `field` method
89
- The `field` method can define primitive types and objects, like hash/array or any object. For objects, there is no need to declare the datatype.
90
- ```ruby
91
- class MyService
92
- include ServiceCore
80
+ The instance method returns the response value object. The class-level `.call` returns the service instance, so you can also reach for `service.response` (or `service.output`, which is kept as an alias) after the fact.
93
81
 
94
- field :first_name, :string
95
- field :last_name, :string
96
- field :payload # can be object/hash/array
82
+ ## `Response`: the value object
97
83
 
98
- def perform
99
- success_response(message: "Hello, World", data: name)
100
- end
84
+ `service.response` (and the value returned from `#call`) is a `ServiceCore::Response`. It exposes both named accessors and Hash-style access, and serialises to JSON like the underlying hash:
101
85
 
102
- def name
103
- "#{first_name} #{last_name}"
104
- end
105
- end
86
+ ```ruby
87
+ response = GreetService.call(first_name: "John", last_name: "Doe").response
88
+
89
+ response.status # => "success"
90
+ response.data # => "John Doe"
91
+ response[:status] # => "success"
92
+ response == { status: "success", message: "Hello, World", data: "John Doe" } # => true
93
+ response.to_json # => '{"status":"success","message":"Hello, World","data":"John Doe"}'
106
94
  ```
107
95
 
108
- ### `set_output` method
96
+ Writing or reading a key other than the four allowed raises `ServiceCore::InvalidKey`.
97
+
98
+ ## Declaring fields
99
+
100
+ `field` supports both typed and untyped declarations.
109
101
 
110
- The `set_output` method provides a way to set output of a specific key. It is the method used by the response_setters to set specific output value
111
102
  ```ruby
112
103
  class MyService
113
104
  include ServiceCore
114
105
 
115
- field :first_name, :string
116
- field :last_name, :string
117
- field :payload # can be object/hash/array
106
+ field :first_name, :string # typed (ActiveModel::Attributes)
107
+ field :active, :boolean, default: true # typed with keyword default
108
+ field :enabled, :boolean, false # typed with positional default
109
+ field :payload # untyped, can be any object/hash/array
110
+ end
111
+ ```
118
112
 
119
- def perform
120
- set_output :message, "Hello, World"
121
- set_output :data, name
122
- end
113
+ Typed fields are backed by `ActiveModel::Attributes` and inherit its casting and default support.
123
114
 
124
- def name
125
- "#{first_name} #{last_name}"
126
- end
127
- end
115
+ The following names are reserved and cannot be used as field names because they would shadow methods the gem itself defines: `:call`, `:errors`, `:fields`, `:output`, `:perform`, `:response`. Declaring `field :errors` (for example) raises `ServiceCore::ReservedFieldName`.
128
116
 
129
- obj = MyService.call(first_name: "John", last_name: "Doe")
130
- puts obj.output
131
- # Output:
132
- # {
133
- # status: "success",
134
- # message: "Hello, World",
135
- #. data: "John Doe"
136
- # }
137
- ```
138
- *NOTE:* If `:status` is not explicitly set in the perform method, the `success` status is returned if `errors` are blank else the `error` status is returned.
117
+ ### Field snapshot via `FieldSet`
139
118
 
140
- ### Response Setters
119
+ After construction, `service.fields` exposes an immutable snapshot of the declared fields and their values as a `ServiceCore::FieldSet`. Each declared symbol field is available as a real method; call `to_h` if you need a plain Hash.
141
120
 
142
- #### `success_response`
143
- Use the `success_response` method to return the `success` status, `data` and `message`
144
121
  ```ruby
122
+ service = GreetService.new(first_name: "John", last_name: "Doe")
145
123
 
146
- class MyService
147
- include ServiceCore
124
+ service.fields.first_name # => "John"
125
+ service.fields.to_h # => { first_name: "John", last_name: "Doe", active: true }
126
+ ```
148
127
 
149
- field :first_name, :string
150
- field :last_name, :string
151
- field :active, :boolean, default: true
128
+ The snapshot is taken at `#initialize`, so it reflects the values at construction time. Live values are still available through each declared accessor (e.g. `service.first_name`).
152
129
 
153
- def perform
154
- success_response(message: "Hello, World", data: name)
155
- end
130
+ ## Building responses
156
131
 
157
- def name
158
- "#{first_name} #{last_name}"
159
- end
160
- end
161
-
162
- service = MyService.new(first_name: "John", last_name: "Doe")
163
- result = service.call
164
- puts result
165
- # Output:
166
- # {
167
- # status: "success",
168
- # message: "Hello, World",
169
- #. data: "John Doe"
170
- # }
171
- ```
132
+ Three helpers cover almost every case.
172
133
 
173
- `success_response` accepts following arguments:
174
- - message
175
- - data
134
+ ### `success_response`
176
135
 
177
- #### `error_response`
178
- Use the `error_response` method to return the `error` status, `errors` and `message`
179
136
  ```ruby
137
+ def perform
138
+ success_response(message: "Hello, World", data: full_name)
139
+ end
140
+ # => {status: "success", message: "Hello, World", data: "John Doe"}
141
+ ```
180
142
 
181
- class MyService
182
- include ServiceCore
143
+ Accepts `message` and `data`. Status is set to `"success"`.
183
144
 
184
- field :first_name, :string
185
- field :last_name, :string
186
- field :active, :boolean, default: true
145
+ ### `error_response`
187
146
 
188
- def perform
189
- error_response(message: "validation failure", errors: "last_name can't be blank")
190
- end
191
-
192
- def name
193
- "#{first_name} #{last_name}"
194
- end
147
+ ```ruby
148
+ def perform
149
+ error_response(message: "validation failure", errors: "last_name can't be blank")
195
150
  end
196
-
197
- service = MyService.new(first_name: "John")
198
- result = service.call
199
- puts result
200
- # Output:
201
- # {
202
- # status: "error",
203
- # message: "validation failure",
204
- #. errors: "last_name can't be blank"
205
- # }
151
+ # => {status: "error", message: "validation failure", errors: "last_name can't be blank"}
206
152
  ```
207
153
 
208
- `error_response` accepts following arguments:
209
- - message
210
- - errors
154
+ Accepts `message` and `errors`. Status is set to `"error"`. `errors` can be a String, Hash, Array, or `ActiveModel::Errors` (which is normalised through `messages`).
155
+
156
+ ### `formatted_response`
157
+
158
+ For any status that isn't success or error.
211
159
 
212
- #### `formatted_response`
213
- Use the `formatted_response` method to return any status other than `success` or `error`.
214
160
  ```ruby
161
+ def perform
162
+ formatted_response(status: "processed", message: "Already done", data: existing_record)
163
+ end
164
+ # => {status: "processed", message: "Already done", data: ...}
165
+ ```
215
166
 
216
- class MyService
217
- include ServiceCore
167
+ Accepts `status`, `message`, `data`, and `errors`. Use this for `"pending"`, `"queued"`, `"processed"`, or any domain-specific status.
218
168
 
219
- field :first_name, :string
220
- field :last_name, :string
221
- field :active, :boolean, default: true
169
+ ### `set_output`
222
170
 
223
- def perform
224
- formatted_response(status: 'processed', message: "Hello, World", data: name)
225
- end
171
+ For finer-grained control, write a single key at a time:
226
172
 
227
- def name
228
- "#{first_name} #{last_name}"
229
- end
173
+ ```ruby
174
+ def perform
175
+ set_output(:message, "Hello, World")
176
+ set_output(:data, full_name)
230
177
  end
231
-
232
- service = MyService.new(first_name: "John", last_name: "Doe")
233
- result = service.call
234
- puts result
235
- # Output:
236
- # {
237
- # status: "processed",
238
- # message: "Hello, World",
239
- #. data: "John Doe"
240
- # }
241
178
  ```
242
179
 
243
- `formatted_response` accepts following arguments:
244
- - status
245
- - message
246
- - data
247
- - errors
180
+ If `status` is not set explicitly, it is auto-assigned to `"success"` when `errors` is blank, and `"error"` otherwise. `nil` is the only value treated as "not set"; `false`, `0`, and `""` are stored as-is.
181
+
182
+ ## Validations
183
+
184
+ Standard ActiveModel validations run before `perform`. If they fail, the response is filled in for you.
248
185
 
249
- ### Validations
250
- Define validation on the service and those will be invoked before service logic is invoked.
251
186
  ```ruby
252
187
  class MyService
253
188
  include ServiceCore
@@ -260,52 +195,43 @@ class MyService
260
195
  end
261
196
  end
262
197
 
263
- service = MyService.new(name: "")
264
- result = service.call
265
- puts result
266
- # Output:
267
- #{
268
- # status: "error",
269
- # message: "validation failure",
270
- # errors: { name: ["can't be blank"] }
271
- # }
198
+ MyService.new(name: "").call
199
+ # => {status: "error", message: "validation failure", errors: {name: ["can't be blank"]}}
272
200
  ```
273
201
 
274
- ### Step Validation
275
- Perform validation at each step of service logic. This is helpful when the result of the previous step decides the next logic.
202
+ ### Step validation
203
+
204
+ When the result of one step decides the next, `add_error_and_validate` lets you accumulate errors mid-`perform` without `valid?` wiping them.
205
+
276
206
  ```ruby
277
207
  class MyService
278
208
  include ServiceCore
279
-
209
+
280
210
  field :first_name, :string
281
211
  field :last_name, :string
282
- field :user
283
212
 
284
213
  validates :first_name, presence: true
285
- validates :user, presence: true
286
214
 
287
215
  def perform
288
216
  if last_name.blank?
289
217
  add_error_and_validate(:last_name, "can't be nil")
290
218
  return error_response(message: "validation failure", errors: errors)
291
219
  end
292
-
293
- success_response(data: { user: { id: 1 } })
220
+
221
+ success_response(data: { user: { id: 1 } })
294
222
  end
295
223
  end
296
224
 
297
- obj = MyService.call(first_name: 'abc')
298
- obj.output
299
- # output:
300
- # {
301
- # status: "error",
302
- # message: "validation failure",
303
- # errors: { last_name: ["can't be nil"] }
304
- # }
225
+ MyService.call(first_name: "abc").response
226
+ # => {status: "error", message: "validation failure", errors: {last_name: ["can't be nil"]}}
305
227
  ```
306
228
 
307
- ### Logging Errors
308
- Log errors using the `log_error` method.
229
+ `add_error_and_validate(attribute, message, options = {})` forwards `options` to `ActiveModel::Errors#add`, so options like `strict: true` are honoured.
230
+
231
+ ## Logging errors
232
+
233
+ `log_error(exception)` writes through the configured `ServiceCore.logger` and tags the message with the service class name.
234
+
309
235
  ```ruby
310
236
  class MyService
311
237
  include ServiceCore
@@ -313,43 +239,83 @@ class MyService
313
239
  field :name, :string
314
240
 
315
241
  def perform
316
- begin
317
- raise StandardError, "Something went wrong"
318
- rescue StandardError => e
319
- log_error(e)
320
- error_response(message: "Failed", errors: { base: [e.message] })
321
- end
242
+ raise StandardError, "Something went wrong"
243
+ rescue StandardError => e
244
+ log_error(e)
245
+ error_response(message: "Failed", errors: { base: [e.message] })
322
246
  end
323
247
  end
248
+ ```
324
249
 
325
- service = MyService.new(name: "World")
326
- result = service.call
327
- puts result
328
- # Output:
329
- # {
330
- # status: "error",
331
- # message: "Failed",
332
- # errors: { base: ["Something went wrong"] }
333
- # }
250
+ ## Exceptions
334
251
 
252
+ All gem-specific exceptions inherit from `ServiceCore::Error`, so a single rescue block can catch anything ServiceCore raises:
253
+
254
+ ```ruby
255
+ begin
256
+ MyService.call(...)
257
+ rescue ServiceCore::Error => e
258
+ # any gem-raised error
259
+ end
335
260
  ```
336
261
 
337
- ### Configuring the Logger
338
- Configure the logger for the ServiceCore module.
262
+ The current concrete subclasses are:
263
+
264
+ - `ServiceCore::InvalidKey` — raised by `response[:not_allowed]` or `response[:not_allowed] = value` when the key is not one of the four allowed response keys.
265
+ - `ServiceCore::ReservedFieldName` — raised by `field :errors` (or any other reserved name) at class-definition time.
266
+
267
+ Two raises stay on stdlib classes: `Response#fetch` raises `KeyError` to match `Hash#fetch`, and the default `perform` raises `StandardError` until the service overrides it.
268
+
269
+ ## Configuration
270
+
339
271
  ```ruby
340
272
  ServiceCore.configure do |config|
341
- config.logger = Logger.new(STDOUT)
273
+ config.logger = Logger.new($stdout)
342
274
  end
343
275
  ```
344
276
 
277
+ If you do not configure a logger, `ServiceCore.logger` defaults to `Rails.logger` when available, and otherwise to an `ActiveSupport::Logger` writing to `$stdout`.
278
+
279
+ ## Stability
280
+
281
+ ServiceCore follows [Semantic Versioning](https://semver.org/). Starting with 1.0.0, the following are part of the public API and changes to them require a major version bump:
282
+
283
+ - The four-key response contract (`status`, `data`, `message`, `errors`).
284
+ - The Hash-compatible surface of `ServiceCore::Response` (`[]`, `[]=`, `==`, `to_h`, `to_s`, `inspect`, `keys`, `values`, `each`, `dig`, `fetch`, `key?` / `has_key?` / `include?`, `as_json`, `to_json`) and its named accessors (`status`, `data`, `message`, `errors`).
285
+ - The `ServiceCore::FieldSet` API (`to_h` and named accessors per declared symbol field).
286
+ - The service DSL: `include ServiceCore`, `field`, `validates`, `perform`, instance `#call` and class `.call`, `service.fields`, `service.response` / `service.output`.
287
+ - The response builders: `success_response`, `error_response`, `formatted_response`, `set_output`.
288
+ - The step-validation helpers: `add_error`, `add_error_and_validate`.
289
+ - The reserved field names: `:call`, `:errors`, `:fields`, `:output`, `:perform`, `:response`.
290
+ - The exception hierarchy under `ServiceCore::Error`.
291
+ - `ServiceCore.logger` and `ServiceCore.configure`.
292
+
293
+ The internals of `ServiceCore::Output`, the `Responder` mixin shape, and anything not listed above are implementation details and may change between any release.
294
+
295
+ ## Compatibility
296
+
297
+ - Ruby: 3.1 minimum; tested against 3.3 and 3.4 (and 4.0 against Rails 8.x).
298
+ - ActiveModel / ActiveSupport: `>= 6.1, < 9.0`; tested against Rails 7.2, 8.0, and 8.1 via [appraisal](https://github.com/thoughtbot/appraisal).
299
+
300
+ ## Development
301
+
302
+ ```sh
303
+ bin/setup
304
+ bundle exec rspec
305
+ bundle exec rubocop
306
+ ```
307
+
308
+ To run the spec suite against every supported Rails version:
309
+
310
+ ```sh
311
+ bundle exec appraisal install
312
+ bundle exec appraisal rspec
313
+ ```
314
+
345
315
  ## Contributing
346
316
 
347
- Bug reports and pull requests are welcome on GitHub at https://github.com/sehgalmayank001/service-core. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/sehgalmayank001/service-core/blob/main/CODE_OF_CONDUCT.md).
317
+ Bug reports and pull requests are welcome on GitHub at [github.com/sehgalmayank001/service-core](https://github.com/sehgalmayank001/service-core). This project follows the [Contributor Covenant code of conduct](https://github.com/sehgalmayank001/service-core/blob/main/CODE_OF_CONDUCT.md).
348
318
 
349
319
  ## License
350
320
 
351
321
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
352
-
353
- ## Code of Conduct
354
-
355
- Everyone interacting in the ServiceCore project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/sehgalmayank001/service-core/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "bundler/gem_tasks"
4
2
  require "rspec/core/rake_task"
5
3
 
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "activemodel", "~> 7.2.0"
7
+ gem "activesupport", "~> 7.2.0"
8
+
9
+ group :development, :test do
10
+ gem "appraisal", "~> 2.5"
11
+ gem "debug", ">= 1.9"
12
+ gem "rspec", "~> 3.13"
13
+ gem "rubocop", "~> 1.86"
14
+ gem "rubocop-rake", "~> 0.7"
15
+ gem "rubocop-rspec", "~> 3.0"
16
+ end
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "activemodel", "~> 8.0.0"
7
+ gem "activesupport", "~> 8.0.0"
8
+
9
+ group :development, :test do
10
+ gem "appraisal", "~> 2.5"
11
+ gem "debug", ">= 1.9"
12
+ gem "rspec", "~> 3.13"
13
+ gem "rubocop", "~> 1.86"
14
+ gem "rubocop-rake", "~> 0.7"
15
+ gem "rubocop-rspec", "~> 3.0"
16
+ end
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "activemodel", "~> 8.1.0"
7
+ gem "activesupport", "~> 8.1.0"
8
+
9
+ group :development, :test do
10
+ gem "appraisal", "~> 2.5"
11
+ gem "debug", ">= 1.9"
12
+ gem "rspec", "~> 3.13"
13
+ gem "rubocop", "~> 1.86"
14
+ gem "rubocop-rake", "~> 0.7"
15
+ gem "rubocop-rspec", "~> 3.0"
16
+ end
17
+
18
+ gemspec path: "../"