service_core 0.1.3 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +35 -2
- data/.tool-versions +1 -0
- data/Appraisals +19 -0
- data/CHANGELOG.md +79 -1
- data/README.md +227 -111
- data/Rakefile +3 -5
- data/gemfiles/rails_7.2.gemfile +18 -0
- data/gemfiles/rails_8.0.gemfile +18 -0
- data/gemfiles/rails_8.1.gemfile +18 -0
- data/lib/service_core/base.rb +38 -34
- data/lib/service_core/errors.rb +11 -0
- data/lib/service_core/field_set.rb +21 -0
- data/lib/service_core/logger.rb +0 -2
- data/lib/service_core/output.rb +45 -0
- data/lib/service_core/responder.rb +38 -0
- data/lib/service_core/response.rb +96 -20
- data/lib/service_core/step_validation.rb +24 -28
- data/lib/service_core/version.rb +1 -3
- data/lib/service_core.rb +3 -4
- metadata +23 -17
- data/.byebug_history +0 -71
- data/service_core.gemspec +0 -44
- data/sig/service_core.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9e82b7404a555e1d2b60d559e82b3f37a2cfe829ad947687f5e290252b87712
|
|
4
|
+
data.tar.gz: a170ee5ab04fe57be88786b027b07ca1db31732fa316eafb0a36b10c827c078c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2a109447da13844396e83db86186849598c0ad4b55b845d07fb6c8c1592aa7a10dc69b135cd84785b407588580871470bf94132158e60c2b237b7a05fdd099ff
|
|
7
|
+
data.tar.gz: b2a495f969e193f7f6a9403c32fd88cf7586f49150268d61bbb600ea565ed039107aa1edc119cf8c5ebc37614f303a10471230292294c420930d3f19241c6539
|
data/.rubocop.yml
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-rake
|
|
3
|
+
|
|
4
|
+
require:
|
|
5
|
+
- rubocop-rspec
|
|
6
|
+
|
|
1
7
|
AllCops:
|
|
2
|
-
TargetRubyVersion:
|
|
8
|
+
TargetRubyVersion: 3.1
|
|
9
|
+
NewCops: enable
|
|
10
|
+
SuggestExtensions: false
|
|
11
|
+
Exclude:
|
|
12
|
+
- "gemfiles/**/*"
|
|
13
|
+
- "vendor/**/*"
|
|
3
14
|
|
|
4
15
|
Style/StringLiterals:
|
|
5
16
|
Enabled: true
|
|
6
17
|
EnforcedStyle: double_quotes
|
|
7
18
|
|
|
19
|
+
# Per project style, frozen_string_literal magic comments are not used.
|
|
20
|
+
Style/FrozenStringLiteralComment:
|
|
21
|
+
Enabled: false
|
|
22
|
+
|
|
8
23
|
Style/StringLiteralsInInterpolation:
|
|
9
24
|
Enabled: true
|
|
10
25
|
EnforcedStyle: double_quotes
|
|
@@ -16,4 +31,22 @@ Style/Documentation:
|
|
|
16
31
|
Enabled: false
|
|
17
32
|
|
|
18
33
|
Metrics/BlockLength:
|
|
19
|
-
Enabled: false
|
|
34
|
+
Enabled: false
|
|
35
|
+
|
|
36
|
+
RSpec/MultipleExpectations:
|
|
37
|
+
Enabled: false
|
|
38
|
+
|
|
39
|
+
RSpec/ExampleLength:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
RSpec/MessageSpies:
|
|
43
|
+
Enabled: false
|
|
44
|
+
|
|
45
|
+
RSpec/VerifiedDoubleReference:
|
|
46
|
+
Enabled: false
|
|
47
|
+
|
|
48
|
+
# add_error_and_validate is part of the public API and intentionally returns
|
|
49
|
+
# the boolean result of valid?, but its name describes the action it performs.
|
|
50
|
+
Naming/PredicateMethod:
|
|
51
|
+
AllowedMethods:
|
|
52
|
+
- add_error_and_validate
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 3.4.9
|
data/Appraisals
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Each appraisal pins ActiveModel/ActiveSupport to a maintained Rails release.
|
|
2
|
+
# Older Rails versions (6.1, 7.0, 7.1) are still permitted by the gemspec
|
|
3
|
+
# floor but are not exercised here because their upstream support window
|
|
4
|
+
# has ended.
|
|
5
|
+
|
|
6
|
+
appraise "rails-7.2" do
|
|
7
|
+
gem("activemodel", "~> 7.2.0")
|
|
8
|
+
gem("activesupport", "~> 7.2.0")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
appraise "rails-8.0" do
|
|
12
|
+
gem("activemodel", "~> 8.0.0")
|
|
13
|
+
gem("activesupport", "~> 8.0.0")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
appraise "rails-8.1" do
|
|
17
|
+
gem("activemodel", "~> 8.1.0")
|
|
18
|
+
gem("activesupport", "~> 8.1.0")
|
|
19
|
+
end
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
1
8
|
## [Unreleased]
|
|
2
9
|
|
|
10
|
+
## [1.0.0] - 2026-05-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `ServiceCore::Response`: a Hash-compatible value object backing
|
|
15
|
+
`service.response` / `service.output` and the return of `#call`. Adds
|
|
16
|
+
named accessors (`response.status`, `response.data`, `response.message`,
|
|
17
|
+
`response.errors`), Hash-style `[]` / `[]=` / `==`, `dig`, `fetch`,
|
|
18
|
+
`each_pair`, and `as_json` / `to_json`. Callers that previously
|
|
19
|
+
reached for `service.output[:key]` on the 0.1.0 Hash output continue
|
|
20
|
+
to work unchanged.
|
|
21
|
+
- `service.response` as an alias for `service.output`.
|
|
22
|
+
- `ServiceCore::FieldSet`: an immutable snapshot for `service.fields`.
|
|
23
|
+
Each declared symbol field is exposed as a real method (e.g.
|
|
24
|
+
`service.fields.first_name`); call `to_h` for a plain Hash.
|
|
25
|
+
- `field` raises `ServiceCore::ReservedFieldName` when the declared name
|
|
26
|
+
would shadow a ServiceCore method. The reserved names are `:call`,
|
|
27
|
+
`:errors`, `:fields`, `:output`, `:perform` and `:response`. Previously
|
|
28
|
+
these names silently overrode gem internals (most dangerously
|
|
29
|
+
`:errors`, which broke `ActiveModel::Validations`).
|
|
30
|
+
- `ServiceCore::Error` hierarchy with concrete subclasses
|
|
31
|
+
`ServiceCore::InvalidKey` (raised by `Response#[]` / `[]=` on a
|
|
32
|
+
non-allowed key) and `ServiceCore::ReservedFieldName`. `Error` itself
|
|
33
|
+
existed since 0.1.0 but was never raised; `rescue ServiceCore::Error`
|
|
34
|
+
now actually catches things.
|
|
35
|
+
- Cross-Rails test matrix via `appraisal`: Rails 7.2, 8.0 and 8.1.
|
|
36
|
+
- CI matrix across Ruby 3.3, 3.4 and 4.0 (excluding Ruby 4.0 + Rails 7.2).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The mixin that provides `success_response`, `error_response` and
|
|
41
|
+
`formatted_response` is renamed from `ServiceCore::Response` to
|
|
42
|
+
`ServiceCore::Responder`. The `Response` name now refers to the value
|
|
43
|
+
object; the `Responder` mixin is the builder. Both modules are internal
|
|
44
|
+
implementation details consumed via `include ServiceCore`, so users
|
|
45
|
+
who only ever `include ServiceCore` are unaffected. Anyone including
|
|
46
|
+
`ServiceCore::Response` directly must switch to
|
|
47
|
+
`include ServiceCore::Responder`.
|
|
48
|
+
- Gemspec: `required_ruby_version` raised to `>= 3.1.0` (Ruby 2.7 and 3.0 are EOL).
|
|
49
|
+
- Gemspec: ActiveModel / ActiveSupport range widened to `>= 6.1, < 9.0`.
|
|
50
|
+
- RuboCop bumped to `~> 1.86` with `rubocop-rake` added as a plugin.
|
|
51
|
+
- Replaced `byebug` (unmaintained on Ruby 3+) with the stdlib `debug` gem.
|
|
52
|
+
- Removed `# frozen_string_literal: true` magic comments project-wide in
|
|
53
|
+
favour of the project style; `VERSION` is now explicitly `.freeze`d.
|
|
54
|
+
- `add_error_and_validate` now forwards its `options` argument to
|
|
55
|
+
`add_error` (and through to `ActiveModel::Errors#add`), matching its
|
|
56
|
+
declared signature.
|
|
57
|
+
- Internal cleanups: `Output#initialize` now calls `super()`, the
|
|
58
|
+
`StepValidation` validator helpers are private, and `auto_assign_status`
|
|
59
|
+
no longer has the redundant elsif clauses.
|
|
60
|
+
- The class-level `fields_defined` registry is renamed to `field_names`
|
|
61
|
+
and is now a `Set` of declared names rather than a Hash mapping names
|
|
62
|
+
to defaults. Typed defaults are still applied through
|
|
63
|
+
`ActiveModel::Attributes`; the old Hash was a Set with a confusing
|
|
64
|
+
name and shape.
|
|
65
|
+
- `service.fields` no longer behaves like a Hash. The new `FieldSet`
|
|
66
|
+
exposes named accessors plus `to_h`. Callers that did
|
|
67
|
+
`service.fields[:name]` should use `service.fields.name`; iteration
|
|
68
|
+
goes through `service.fields.to_h.each`.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- `Output#set_output` no longer drops legitimate falsy values. Previously
|
|
73
|
+
`false`, `0`, and `""` were silently discarded; only `nil` is now skipped.
|
|
74
|
+
- `Responder#formatted_response` no longer gates its writes on
|
|
75
|
+
ActiveSupport `present?`. `success_response(data: false)` now records
|
|
76
|
+
`data: false`.
|
|
77
|
+
- `Base.field` no longer swallows positional defaults of `false` or `nil`.
|
|
78
|
+
The arity of the positional arguments is used instead of `||`, so
|
|
79
|
+
`field :enabled, :boolean, false` actually defaults to `false`.
|
|
80
|
+
|
|
3
81
|
## [0.1.0] - 2024-07-17
|
|
4
82
|
|
|
5
|
-
- Initial release
|
|
83
|
+
- Initial release.
|
data/README.md
CHANGED
|
@@ -1,27 +1,50 @@
|
|
|
1
1
|
# ServiceCore
|
|
2
2
|
|
|
3
|
-
ServiceCore
|
|
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
|
-
|
|
16
|
+
|
|
17
|
+
Or add it to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "service_core"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If you are not using Bundler:
|
|
12
24
|
|
|
13
25
|
```sh
|
|
14
26
|
gem install service_core
|
|
15
27
|
```
|
|
16
|
-
## Usage
|
|
17
28
|
|
|
18
|
-
|
|
29
|
+
## The four-key response
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
```ruby
|
|
22
|
-
# app/services/my_service.rb
|
|
31
|
+
Every service responds with at most four keys:
|
|
23
32
|
|
|
24
|
-
|
|
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, ...). |
|
|
39
|
+
|
|
40
|
+
The shape is enforced; the value types are not. Writing a key other than these four raises `ServiceCore::InvalidKey`.
|
|
41
|
+
|
|
42
|
+
## Defining a service
|
|
43
|
+
|
|
44
|
+
Include `ServiceCore` in your class and implement `perform`:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class GreetService
|
|
25
48
|
include ServiceCore
|
|
26
49
|
|
|
27
50
|
field :first_name, :string
|
|
@@ -29,39 +52,137 @@ class MyService
|
|
|
29
52
|
field :active, :boolean, default: true
|
|
30
53
|
|
|
31
54
|
def perform
|
|
32
|
-
success_response(message: "Hello, World", data:
|
|
55
|
+
success_response(message: "Hello, World", data: full_name)
|
|
33
56
|
end
|
|
34
57
|
|
|
35
|
-
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def full_name
|
|
36
61
|
"#{first_name} #{last_name}"
|
|
37
62
|
end
|
|
38
63
|
end
|
|
39
64
|
```
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
## Calling a service
|
|
67
|
+
|
|
68
|
+
You can call a service either via `new(...).call` or via the `.call` shortcut on the class:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
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"}
|
|
78
|
+
```
|
|
79
|
+
|
|
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.
|
|
81
|
+
|
|
82
|
+
## `Response`: the value object
|
|
83
|
+
|
|
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:
|
|
85
|
+
|
|
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"}'
|
|
94
|
+
```
|
|
95
|
+
|
|
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.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class MyService
|
|
104
|
+
include ServiceCore
|
|
105
|
+
|
|
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
|
+
```
|
|
112
|
+
|
|
113
|
+
Typed fields are backed by `ActiveModel::Attributes` and inherit its casting and default support.
|
|
114
|
+
|
|
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`.
|
|
116
|
+
|
|
117
|
+
### Field snapshot via `FieldSet`
|
|
118
|
+
|
|
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.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
service = GreetService.new(first_name: "John", last_name: "Doe")
|
|
123
|
+
|
|
124
|
+
service.fields.first_name # => "John"
|
|
125
|
+
service.fields.to_h # => { first_name: "John", last_name: "Doe", active: true }
|
|
126
|
+
```
|
|
127
|
+
|
|
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`).
|
|
129
|
+
|
|
130
|
+
## Building responses
|
|
131
|
+
|
|
132
|
+
Three helpers cover almost every case.
|
|
133
|
+
|
|
134
|
+
### `success_response`
|
|
135
|
+
|
|
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
|
+
```
|
|
142
|
+
|
|
143
|
+
Accepts `message` and `data`. Status is set to `"success"`.
|
|
144
|
+
|
|
145
|
+
### `error_response`
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
def perform
|
|
149
|
+
error_response(message: "validation failure", errors: "last_name can't be blank")
|
|
150
|
+
end
|
|
151
|
+
# => {status: "error", message: "validation failure", errors: "last_name can't be blank"}
|
|
152
|
+
```
|
|
153
|
+
|
|
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.
|
|
159
|
+
|
|
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
|
+
```
|
|
166
|
+
|
|
167
|
+
Accepts `status`, `message`, `data`, and `errors`. Use this for `"pending"`, `"queued"`, `"processed"`, or any domain-specific status.
|
|
168
|
+
|
|
169
|
+
### `set_output`
|
|
170
|
+
|
|
171
|
+
For finer-grained control, write a single key at a time:
|
|
172
|
+
|
|
43
173
|
```ruby
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# {
|
|
57
|
-
# status: "success",
|
|
58
|
-
# message: "Hello, World",
|
|
59
|
-
#. data: "John Doe"
|
|
60
|
-
# }
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### Validations
|
|
64
|
-
You can define validation on the service and those will be invoked before service logic is invoked
|
|
174
|
+
def perform
|
|
175
|
+
set_output(:message, "Hello, World")
|
|
176
|
+
set_output(:data, full_name)
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
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.
|
|
185
|
+
|
|
65
186
|
```ruby
|
|
66
187
|
class MyService
|
|
67
188
|
include ServiceCore
|
|
@@ -74,51 +195,43 @@ class MyService
|
|
|
74
195
|
end
|
|
75
196
|
end
|
|
76
197
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
puts result
|
|
80
|
-
# Output:
|
|
81
|
-
#{
|
|
82
|
-
# status: "error",
|
|
83
|
-
# message: "validation failure",
|
|
84
|
-
# errors: { name: ["can't be blank"] }
|
|
85
|
-
# }
|
|
198
|
+
MyService.new(name: "").call
|
|
199
|
+
# => {status: "error", message: "validation failure", errors: {name: ["can't be blank"]}}
|
|
86
200
|
```
|
|
87
201
|
|
|
88
|
-
### Step
|
|
89
|
-
|
|
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
|
+
|
|
90
206
|
```ruby
|
|
91
207
|
class MyService
|
|
92
208
|
include ServiceCore
|
|
93
|
-
|
|
209
|
+
|
|
94
210
|
field :first_name, :string
|
|
95
211
|
field :last_name, :string
|
|
96
|
-
field :user
|
|
97
212
|
|
|
98
213
|
validates :first_name, presence: true
|
|
99
|
-
validates :user, presence: true
|
|
100
214
|
|
|
101
215
|
def perform
|
|
102
216
|
if last_name.blank?
|
|
103
217
|
add_error_and_validate(:last_name, "can't be nil")
|
|
104
218
|
return error_response(message: "validation failure", errors: errors)
|
|
105
219
|
end
|
|
106
|
-
|
|
107
|
-
|
|
220
|
+
|
|
221
|
+
success_response(data: { user: { id: 1 } })
|
|
108
222
|
end
|
|
109
223
|
end
|
|
110
224
|
|
|
111
|
-
MyService.call(first_name:
|
|
112
|
-
#
|
|
113
|
-
# {
|
|
114
|
-
# status: "error",
|
|
115
|
-
# message: "validation failure",
|
|
116
|
-
# errors: { last_name: ["can't be nil"] }
|
|
117
|
-
# }
|
|
225
|
+
MyService.call(first_name: "abc").response
|
|
226
|
+
# => {status: "error", message: "validation failure", errors: {last_name: ["can't be nil"]}}
|
|
118
227
|
```
|
|
119
228
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
|
|
122
235
|
```ruby
|
|
123
236
|
class MyService
|
|
124
237
|
include ServiceCore
|
|
@@ -126,80 +239,83 @@ class MyService
|
|
|
126
239
|
field :name, :string
|
|
127
240
|
|
|
128
241
|
def perform
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
error_response(message: "Failed", errors: { base: [e.message] })
|
|
134
|
-
end
|
|
242
|
+
raise StandardError, "Something went wrong"
|
|
243
|
+
rescue StandardError => e
|
|
244
|
+
log_error(e)
|
|
245
|
+
error_response(message: "Failed", errors: { base: [e.message] })
|
|
135
246
|
end
|
|
136
247
|
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Exceptions
|
|
137
251
|
|
|
138
|
-
|
|
139
|
-
result = service.call
|
|
140
|
-
puts result
|
|
141
|
-
# Output:
|
|
142
|
-
# {
|
|
143
|
-
# status: "error",
|
|
144
|
-
# message: "Failed",
|
|
145
|
-
# errors: { base: ["Something went wrong"] }
|
|
146
|
-
# }
|
|
252
|
+
All gem-specific exceptions inherit from `ServiceCore::Error`, so a single rescue block can catch anything ServiceCore raises:
|
|
147
253
|
|
|
254
|
+
```ruby
|
|
255
|
+
begin
|
|
256
|
+
MyService.call(...)
|
|
257
|
+
rescue ServiceCore::Error => e
|
|
258
|
+
# any gem-raised error
|
|
259
|
+
end
|
|
148
260
|
```
|
|
149
261
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
|
|
152
271
|
```ruby
|
|
153
272
|
ServiceCore.configure do |config|
|
|
154
|
-
config.logger = Logger.new(
|
|
273
|
+
config.logger = Logger.new($stdout)
|
|
155
274
|
end
|
|
156
275
|
```
|
|
157
276
|
|
|
158
|
-
|
|
159
|
-
use `formatted_response` method to return any other status other than `success` or `error`
|
|
160
|
-
```ruby
|
|
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`.
|
|
161
278
|
|
|
162
|
-
|
|
163
|
-
include ServiceCore
|
|
279
|
+
## Stability
|
|
164
280
|
|
|
165
|
-
|
|
166
|
-
field :last_name, :string
|
|
167
|
-
field :active, :boolean, default: true
|
|
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:
|
|
168
282
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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`.
|
|
172
292
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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).
|
|
177
299
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# message: "Hello, World",
|
|
185
|
-
#. data: "John Doe"
|
|
186
|
-
# }
|
|
300
|
+
## Development
|
|
301
|
+
|
|
302
|
+
```sh
|
|
303
|
+
bin/setup
|
|
304
|
+
bundle exec rspec
|
|
305
|
+
bundle exec rubocop
|
|
187
306
|
```
|
|
188
307
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
```
|
|
194
314
|
|
|
195
315
|
## Contributing
|
|
196
316
|
|
|
197
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/sehgalmayank001/service-core. This project
|
|
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).
|
|
198
318
|
|
|
199
319
|
## License
|
|
200
320
|
|
|
201
321
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
202
|
-
|
|
203
|
-
## Code of Conduct
|
|
204
|
-
|
|
205
|
-
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
|
|
|
@@ -16,6 +14,6 @@ task :release do
|
|
|
16
14
|
version = `ruby -r ./lib/service_core/version -e "puts ServiceCore::VERSION"`.strip
|
|
17
15
|
# sh "git add ."
|
|
18
16
|
# sh "git commit -m 'Prepare for version #{version} release'"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
end
|
|
17
|
+
sh "git tag v#{version}"
|
|
18
|
+
sh "git push origin main --tags"
|
|
19
|
+
end
|
|
@@ -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: "../"
|