serviced 0.1.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 +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/lib/serviced/configuration.rb +31 -0
- data/lib/serviced/errors.rb +37 -0
- data/lib/serviced/flow.rb +154 -0
- data/lib/serviced/query.rb +88 -0
- data/lib/serviced/result.rb +158 -0
- data/lib/serviced/result_helpers.rb +26 -0
- data/lib/serviced/service.rb +73 -0
- data/lib/serviced/typed.rb +105 -0
- data/lib/serviced/version.rb +5 -0
- data/lib/serviced.rb +79 -0
- metadata +83 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Composes several steps into a single callable pipeline.
|
|
5
|
+
#
|
|
6
|
+
# A step is anything that responds to +call(context)+ and returns a
|
|
7
|
+
# {Serviced::Result}: usually a {Serviced::Service} subclass, but a lambda or
|
|
8
|
+
# any callable works too. Steps run in order, each receiving an immutable
|
|
9
|
+
# context hash. The first failing step halts the flow and its failure is
|
|
10
|
+
# returned. When every step succeeds, whatever hash each step returned as its
|
|
11
|
+
# success value is merged into the context, and the final context is returned
|
|
12
|
+
# as the success value.
|
|
13
|
+
#
|
|
14
|
+
# class RegisterPatient < Serviced::Flow
|
|
15
|
+
# transactional # wrap every step in one database transaction
|
|
16
|
+
#
|
|
17
|
+
# step CreatePatient # returns success(patient: patient)
|
|
18
|
+
# step CreateChart # reads :patient from the context
|
|
19
|
+
# step SendWelcome
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# result = RegisterPatient.call(name: "Ada", age: 36)
|
|
23
|
+
# result.value # => merged context hash of everything the steps produced
|
|
24
|
+
#
|
|
25
|
+
# A flow can also be built inline:
|
|
26
|
+
#
|
|
27
|
+
# RegisterPatient = Serviced::Flow.define(transaction: true) do
|
|
28
|
+
# step CreatePatient
|
|
29
|
+
# step CreateChart
|
|
30
|
+
# end
|
|
31
|
+
class Flow
|
|
32
|
+
include ResultHelpers
|
|
33
|
+
|
|
34
|
+
@steps = []
|
|
35
|
+
@transactional = false
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @return [Array<#call>] the steps registered on this flow
|
|
39
|
+
def steps
|
|
40
|
+
@steps ||= []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Registers a step. Steps run in the order they are declared.
|
|
44
|
+
# @param callable [#call] a Service subclass, lambda, or any callable
|
|
45
|
+
# that returns a Serviced::Result
|
|
46
|
+
def step(callable)
|
|
47
|
+
steps << callable
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Marks the flow as transactional: all steps run inside a single
|
|
51
|
+
# transaction that rolls back if any step fails or raises.
|
|
52
|
+
def transactional
|
|
53
|
+
@transactional = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] whether the flow runs inside a transaction
|
|
57
|
+
def transactional?
|
|
58
|
+
@transactional
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Builds an anonymous flow subclass from a block.
|
|
62
|
+
# @param transaction [Boolean] whether to wrap the steps in a transaction
|
|
63
|
+
# @return [Class] a new Serviced::Flow subclass
|
|
64
|
+
def define(transaction: false, &block)
|
|
65
|
+
Class.new(self) do
|
|
66
|
+
transactional if transaction
|
|
67
|
+
class_eval(&block) if block
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Runs the flow.
|
|
72
|
+
# @param context [Hash] initial context
|
|
73
|
+
# @return [Serviced::Result]
|
|
74
|
+
def call(context = {})
|
|
75
|
+
new(context).call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def inherited(subclass)
|
|
81
|
+
super
|
|
82
|
+
subclass.instance_variable_set(:@steps, steps.dup)
|
|
83
|
+
subclass.instance_variable_set(:@transactional, transactional?)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param context [Hash] initial context passed to the first step
|
|
88
|
+
def initialize(context = {})
|
|
89
|
+
@context = normalize(context).freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Serviced::Result]
|
|
93
|
+
def call
|
|
94
|
+
self.class.transactional? ? within_transaction { run_steps } : run_steps
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def run_steps
|
|
100
|
+
context = @context
|
|
101
|
+
self.class.steps.each do |step|
|
|
102
|
+
result = invoke(step, context)
|
|
103
|
+
return result if result.failure?
|
|
104
|
+
|
|
105
|
+
context = merge(context, result.value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
success(context)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def invoke(step, context)
|
|
112
|
+
result = step.call(context)
|
|
113
|
+
return result if result.is_a?(Result)
|
|
114
|
+
|
|
115
|
+
raise ResultTypeError,
|
|
116
|
+
"Flow step #{step_name(step)} must return a Serviced::Result, got #{result.class}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def within_transaction
|
|
120
|
+
handler = Serviced.configuration.transaction_handler
|
|
121
|
+
if handler.nil?
|
|
122
|
+
raise MissingTransactionHandler,
|
|
123
|
+
"#{self.class} is transactional but no transaction handler is configured. " \
|
|
124
|
+
"Load ActiveRecord or set Serviced.configuration.transaction_handler."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
result = nil
|
|
128
|
+
begin
|
|
129
|
+
handler.call do
|
|
130
|
+
result = yield
|
|
131
|
+
raise Rollback if result.failure?
|
|
132
|
+
end
|
|
133
|
+
rescue Rollback
|
|
134
|
+
# Expected: a step failed, the transaction rolled back, and +result+
|
|
135
|
+
# already holds that failure.
|
|
136
|
+
end
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def merge(context, value)
|
|
141
|
+
return context unless value.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
context.merge(normalize(value)).freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def normalize(hash)
|
|
147
|
+
(hash || {}).to_h.transform_keys(&:to_sym)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def step_name(step)
|
|
151
|
+
step.respond_to?(:name) ? step.name : step.class.name
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Base class for query objects: a named home for a complex read.
|
|
5
|
+
#
|
|
6
|
+
# A query has the same typed, immutable inputs as a {Serviced::Service} (see
|
|
7
|
+
# {Serviced::Typed}), but instead of a Result it returns whatever #call
|
|
8
|
+
# returns, usually an +ActiveRecord::Relation+. Returning a relation keeps the
|
|
9
|
+
# result composable: callers can still paginate, add includes, or chain more
|
|
10
|
+
# scopes. A service then consumes the query and wraps its result in a Result.
|
|
11
|
+
#
|
|
12
|
+
# class EnrolledPatientsQuery < Serviced::Query
|
|
13
|
+
# attribute :clinic
|
|
14
|
+
# attribute :program_external_id, :string
|
|
15
|
+
# attribute :sort_direction, :string, default: "asc"
|
|
16
|
+
#
|
|
17
|
+
# validates :sort_direction, inclusion: { in: %w[asc desc] }
|
|
18
|
+
#
|
|
19
|
+
# def call
|
|
20
|
+
# clinic.patients
|
|
21
|
+
# .where("enrolled_in(?)", program_external_id)
|
|
22
|
+
# .order("enrolled_at #{sort_direction}")
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# relation = EnrolledPatientsQuery.call(clinic:, program_external_id: "cardio")
|
|
27
|
+
# relation.page(params[:page]) # still a relation
|
|
28
|
+
#
|
|
29
|
+
# Invalid inputs raise {Serviced::InvalidQuery} (a query has no failure
|
|
30
|
+
# channel, so bad input is treated as a programming error).
|
|
31
|
+
#
|
|
32
|
+
# The SQL helpers (#quote, #count_of, ...) require ActiveRecord at runtime.
|
|
33
|
+
class Query
|
|
34
|
+
include Typed
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Builds the query, validates it, and runs #call.
|
|
38
|
+
# @param attributes [Hash] input values; unknown keys are ignored
|
|
39
|
+
# @return [Object] whatever #call returns (typically an ActiveRecord::Relation)
|
|
40
|
+
# @raise [Serviced::InvalidQuery] if inputs fail validation
|
|
41
|
+
def call(attributes = {})
|
|
42
|
+
query = new(attributes)
|
|
43
|
+
raise InvalidQuery, query.errors if query.invalid?
|
|
44
|
+
|
|
45
|
+
query.call
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The query body. Subclasses must implement it and return a relation (to
|
|
50
|
+
# stay composable) or a materialized value.
|
|
51
|
+
# @return [ActiveRecord::Relation, Object]
|
|
52
|
+
def call
|
|
53
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def connection
|
|
59
|
+
ActiveRecord::Base.connection
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Quotes a value for safe interpolation into raw SQL.
|
|
63
|
+
def quote(value)
|
|
64
|
+
connection.quote(value)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Quotes a column or table name for safe interpolation into raw SQL.
|
|
68
|
+
def quote_column(name)
|
|
69
|
+
connection.quote_column_name(name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Builds a sanitized SQL fragment from a statement and bind values.
|
|
73
|
+
# sanitize("state = ? AND age > ?", "active", 18)
|
|
74
|
+
def sanitize(statement, *binds)
|
|
75
|
+
ActiveRecord::Base.sanitize_sql_array([statement, *binds])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Counts the rows of a relation without loading them, wrapping it in a
|
|
79
|
+
# subquery. Replaces a hand-rolled "SELECT COUNT(*) FROM (...)" idiom.
|
|
80
|
+
# @param relation [ActiveRecord::Relation]
|
|
81
|
+
# @param cte [String] optional leading CTE (e.g. "WITH foo AS (...)")
|
|
82
|
+
# @return [Integer]
|
|
83
|
+
def count_of(relation, cte: "")
|
|
84
|
+
sql = "#{cte} SELECT COUNT(*) FROM (#{relation.to_sql}) serviced_count".strip
|
|
85
|
+
connection.select_value(sql).to_i
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Outcome of a service or flow. Abstract: instances are always either a
|
|
5
|
+
# {Serviced::Success} or a {Serviced::Failure}. Results are frozen and
|
|
6
|
+
# therefore immutable.
|
|
7
|
+
#
|
|
8
|
+
# Consume a result through predicates, callbacks, railway chaining, or
|
|
9
|
+
# pattern matching:
|
|
10
|
+
#
|
|
11
|
+
# result = CreatePatient.call(name: "Ada", age: 36)
|
|
12
|
+
#
|
|
13
|
+
# result.success? # => true / false
|
|
14
|
+
# result.value # success payload (nil on failure)
|
|
15
|
+
#
|
|
16
|
+
# result
|
|
17
|
+
# .on_success { |patient| render json: patient }
|
|
18
|
+
# .on_failure { |failure| render json: { error: failure.message } }
|
|
19
|
+
#
|
|
20
|
+
# case result
|
|
21
|
+
# in Serviced::Success(value:) then value
|
|
22
|
+
# in Serviced::Failure(reason:) then reason
|
|
23
|
+
# end
|
|
24
|
+
class Result
|
|
25
|
+
# @return [Boolean] whether the operation succeeded.
|
|
26
|
+
def success?
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #success?"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] whether the operation failed.
|
|
31
|
+
def failure?
|
|
32
|
+
!success?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The success payload. Always nil for a failure.
|
|
36
|
+
# @return [Object, nil]
|
|
37
|
+
def value
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs the block with the success value when successful. Returns self so
|
|
42
|
+
# calls can be chained with {#on_failure}.
|
|
43
|
+
# @yieldparam value [Object] the success payload
|
|
44
|
+
# @return [Serviced::Result] self
|
|
45
|
+
def on_success
|
|
46
|
+
yield(value) if success? && block_given?
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Runs the block with the failure when failed. Returns self so calls can be
|
|
51
|
+
# chained with {#on_success}.
|
|
52
|
+
# @yieldparam failure [Serviced::Failure] the failure itself
|
|
53
|
+
# @return [Serviced::Result] self
|
|
54
|
+
def on_failure
|
|
55
|
+
yield(self) if failure? && block_given?
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Railway-oriented chaining: runs the block only on success and returns the
|
|
60
|
+
# Result it produces; short-circuits (returns self) on failure. The block
|
|
61
|
+
# must return a Serviced::Result.
|
|
62
|
+
# @yieldparam value [Object] the success payload
|
|
63
|
+
# @return [Serviced::Result]
|
|
64
|
+
def and_then
|
|
65
|
+
return self unless success?
|
|
66
|
+
|
|
67
|
+
result = yield(value)
|
|
68
|
+
unless result.is_a?(Result)
|
|
69
|
+
raise ResultTypeError, "#and_then block must return a Serviced::Result, got #{result.class}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Transforms a success value into a new Success; leaves a failure untouched.
|
|
76
|
+
# @yieldparam value [Object] the success payload
|
|
77
|
+
# @return [Serviced::Result]
|
|
78
|
+
def map
|
|
79
|
+
return self unless success?
|
|
80
|
+
|
|
81
|
+
Success.new(yield(value))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A successful outcome carrying a payload.
|
|
86
|
+
class Success < Result
|
|
87
|
+
# @return [Object, nil] the success payload
|
|
88
|
+
attr_reader :value
|
|
89
|
+
|
|
90
|
+
# @param value [Object, nil] the payload the caller cares about
|
|
91
|
+
def initialize(value = nil)
|
|
92
|
+
super()
|
|
93
|
+
@value = value
|
|
94
|
+
freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def success?
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Object, nil] the payload (never raises for a success)
|
|
102
|
+
def value!
|
|
103
|
+
value
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def deconstruct
|
|
107
|
+
[value]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def deconstruct_keys(_keys)
|
|
111
|
+
{ value: value }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A failed outcome. Carries a machine-readable +reason+ for branching, an
|
|
116
|
+
# optional human-readable +message+, and an optional +error+ payload (for
|
|
117
|
+
# example an exception or an ActiveModel::Errors object).
|
|
118
|
+
class Failure < Result
|
|
119
|
+
# @return [Symbol] machine-readable reason, suitable for case/when branching
|
|
120
|
+
attr_reader :reason
|
|
121
|
+
|
|
122
|
+
# @return [String, nil] human-readable description
|
|
123
|
+
attr_reader :message
|
|
124
|
+
|
|
125
|
+
# @return [Object, nil] structured error payload (exception, error object, details)
|
|
126
|
+
attr_reader :error
|
|
127
|
+
|
|
128
|
+
# @param reason [Symbol] machine-readable reason (defaults to :error)
|
|
129
|
+
# @param message [String, nil] human-readable description
|
|
130
|
+
# @param error [Object, nil] structured error payload
|
|
131
|
+
def initialize(reason = :error, message = nil, error: nil)
|
|
132
|
+
super()
|
|
133
|
+
@reason = reason
|
|
134
|
+
@message = message
|
|
135
|
+
@error = error
|
|
136
|
+
freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def success?
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Always raises: a failure has no value to unwrap.
|
|
144
|
+
# @raise [Serviced::InvalidResultAccess]
|
|
145
|
+
def value!
|
|
146
|
+
raise InvalidResultAccess,
|
|
147
|
+
"Called #value! on a Failure (reason: #{reason.inspect}, message: #{message.inspect})"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def deconstruct
|
|
151
|
+
[reason, message]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def deconstruct_keys(_keys)
|
|
155
|
+
{ reason: reason, message: message, error: error }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Private helpers shared by services and flows for building results, so the
|
|
5
|
+
# construction of Success/Failure lives in one place.
|
|
6
|
+
module ResultHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Builds a success result. Pass a hash to contribute keys to a flow context,
|
|
10
|
+
# for example +success(patient: patient)+.
|
|
11
|
+
# @param value [Object, nil] the payload
|
|
12
|
+
# @return [Serviced::Success]
|
|
13
|
+
def success(value = nil)
|
|
14
|
+
Success.new(value)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Builds a failure result.
|
|
18
|
+
# @param reason [Symbol] machine-readable reason for branching
|
|
19
|
+
# @param message [String, nil] human-readable description
|
|
20
|
+
# @param error [Object, nil] structured error payload
|
|
21
|
+
# @return [Serviced::Failure]
|
|
22
|
+
def failure(reason = :error, message = nil, error: nil)
|
|
23
|
+
Failure.new(reason, message, error: error)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Base class for service objects.
|
|
5
|
+
#
|
|
6
|
+
# A service declares its inputs as typed attributes (see {Serviced::Typed}),
|
|
7
|
+
# which are coerced on assignment and read-only afterwards. Inputs can be
|
|
8
|
+
# validated with the full ActiveModel validation DSL. Calling a service always
|
|
9
|
+
# returns a {Serviced::Result}.
|
|
10
|
+
#
|
|
11
|
+
# class CreatePatient < Serviced::Service
|
|
12
|
+
# attribute :name, :string
|
|
13
|
+
# attribute :age, :integer
|
|
14
|
+
# attribute :active, :boolean, default: true
|
|
15
|
+
# attribute :clinic # untyped: accepts any object
|
|
16
|
+
#
|
|
17
|
+
# validates :name, presence: true
|
|
18
|
+
# validates :age, numericality: { greater_than: 0 }
|
|
19
|
+
#
|
|
20
|
+
# def call
|
|
21
|
+
# patient = Patient.create!(name:, age:, active:)
|
|
22
|
+
# success(patient)
|
|
23
|
+
# rescue ActiveRecord::RecordInvalid => e
|
|
24
|
+
# failure(:not_created, e.message, error: e)
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# result = CreatePatient.call(name: "Ada", age: 36)
|
|
29
|
+
# result.success? # => true
|
|
30
|
+
# result.value # => #<Patient ...>
|
|
31
|
+
#
|
|
32
|
+
# Invalid inputs short-circuit to +failure(:invalid)+ without running #call,
|
|
33
|
+
# exposing the ActiveModel::Errors object through +result.error+.
|
|
34
|
+
class Service
|
|
35
|
+
include Typed
|
|
36
|
+
include ResultHelpers
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Builds the service, validates it, and runs #call.
|
|
40
|
+
#
|
|
41
|
+
# Unknown keys are ignored so a service can be dropped into a {Flow}
|
|
42
|
+
# without matching the exact shape of the flow context.
|
|
43
|
+
#
|
|
44
|
+
# @param attributes [Hash] input values
|
|
45
|
+
# @return [Serviced::Result]
|
|
46
|
+
# @raise [Serviced::ResultTypeError] if #call returns a non-Result
|
|
47
|
+
def call(attributes = {})
|
|
48
|
+
service = new(attributes)
|
|
49
|
+
return service.__send__(:invalid_result) if service.invalid?
|
|
50
|
+
|
|
51
|
+
result = service.call
|
|
52
|
+
unless result.is_a?(Result)
|
|
53
|
+
raise ResultTypeError, "#{self}#call must return a Serviced::Result, got #{result.class}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The business logic. Subclasses must implement it and return a
|
|
61
|
+
# {Serviced::Result} (use the +success+ / +failure+ helpers).
|
|
62
|
+
# @return [Serviced::Result]
|
|
63
|
+
def call
|
|
64
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def invalid_result
|
|
70
|
+
failure(:invalid, "Validation failed: #{Serviced.error_summary(errors)}", error: errors)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_model"
|
|
5
|
+
|
|
6
|
+
module Serviced
|
|
7
|
+
# Shared foundation for typed, immutable, validatable inputs. Included by both
|
|
8
|
+
# {Serviced::Service} and {Serviced::Query}, and usable on its own for any
|
|
9
|
+
# plain value object that wants the same contract.
|
|
10
|
+
#
|
|
11
|
+
# class DateRange
|
|
12
|
+
# include Serviced::Typed
|
|
13
|
+
# attribute :from, :date
|
|
14
|
+
# attribute :to, :date
|
|
15
|
+
# validates :from, :to, presence: true
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Attributes are declared with the ActiveModel::Attributes DSL, so they are
|
|
19
|
+
# coerced to the declared type. Their writers are made private, so an instance
|
|
20
|
+
# cannot be rebound once built. Unknown keys are ignored, which lets these
|
|
21
|
+
# objects be fed from a wider hash (for example a {Serviced::Flow} context)
|
|
22
|
+
# without raising.
|
|
23
|
+
#
|
|
24
|
+
# Inputs are isolated by default: at construction each value is captured as an
|
|
25
|
+
# immutable snapshot (see {Serviced.snapshot}). Arrays, hashes, sets and
|
|
26
|
+
# strings are deep-copied and deep-frozen, so neither side can mutate the
|
|
27
|
+
# other. Objects with identity (ActiveRecord records and the like) are shared
|
|
28
|
+
# by reference and left alone, because a deep copy of a record is a different,
|
|
29
|
+
# non-persisted object. Pass +isolate: false+ to share a value by reference,
|
|
30
|
+
# for example a mutable accumulator you deliberately want the caller to see:
|
|
31
|
+
#
|
|
32
|
+
# attribute :filters # isolated snapshot (default)
|
|
33
|
+
# attribute :sink, isolate: false # shared by reference
|
|
34
|
+
module Typed
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
include ActiveModel::API
|
|
38
|
+
include ActiveModel::Attributes
|
|
39
|
+
|
|
40
|
+
# @param attributes [Hash] input values; unknown keys are ignored
|
|
41
|
+
def initialize(attributes = {})
|
|
42
|
+
super()
|
|
43
|
+
assign_typed_attributes(attributes)
|
|
44
|
+
capture_isolated_snapshots
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def assign_typed_attributes(attributes)
|
|
50
|
+
return if attributes.nil?
|
|
51
|
+
|
|
52
|
+
permitted = attributes.to_h.transform_keys(&:to_sym)
|
|
53
|
+
self.class.attribute_names.each do |name|
|
|
54
|
+
key = name.to_sym
|
|
55
|
+
__send__(:"#{name}=", permitted[key]) if permitted.key?(key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Forces each isolated attribute to snapshot its value now, so isolation
|
|
60
|
+
# holds against mutations made between construction and first read.
|
|
61
|
+
def capture_isolated_snapshots
|
|
62
|
+
self.class.isolated_attribute_names.each { |name| __send__(name) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class_methods do
|
|
66
|
+
# @return [Array<String>] names of attributes captured as isolated snapshots
|
|
67
|
+
def isolated_attribute_names
|
|
68
|
+
@isolated_attribute_names ||=
|
|
69
|
+
superclass.respond_to?(:isolated_attribute_names) ? superclass.isolated_attribute_names.dup : []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Declares a typed, read-only attribute. Same arguments as
|
|
73
|
+
# ActiveModel::Attributes.attribute, plus +isolate:+.
|
|
74
|
+
#
|
|
75
|
+
# @param name [Symbol] attribute name
|
|
76
|
+
# @param type [Symbol, ActiveModel::Type::Value, nil] the cast type
|
|
77
|
+
# @param isolate [Boolean] capture an immutable snapshot of the value at
|
|
78
|
+
# construction (default); pass +false+ to share it by reference
|
|
79
|
+
# @param options [Hash] forwarded to ActiveModel (e.g. +default:+)
|
|
80
|
+
def attribute(name, type = nil, isolate: true, **options)
|
|
81
|
+
if type.nil?
|
|
82
|
+
super(name, **options)
|
|
83
|
+
else
|
|
84
|
+
super(name, type, **options)
|
|
85
|
+
end
|
|
86
|
+
private(:"#{name}=")
|
|
87
|
+
return unless isolate
|
|
88
|
+
|
|
89
|
+
isolated_attribute_names << name.to_s
|
|
90
|
+
define_isolated_reader(name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def define_isolated_reader(name)
|
|
96
|
+
define_method(name) do
|
|
97
|
+
ivar = :"@__isolated_#{name}"
|
|
98
|
+
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
|
|
99
|
+
|
|
100
|
+
instance_variable_set(ivar, Serviced.snapshot(super()))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/serviced.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "serviced/version"
|
|
6
|
+
require_relative "serviced/errors"
|
|
7
|
+
require_relative "serviced/result"
|
|
8
|
+
require_relative "serviced/result_helpers"
|
|
9
|
+
require_relative "serviced/configuration"
|
|
10
|
+
require_relative "serviced/typed"
|
|
11
|
+
require_relative "serviced/service"
|
|
12
|
+
require_relative "serviced/query"
|
|
13
|
+
require_relative "serviced/flow"
|
|
14
|
+
|
|
15
|
+
# Serviced provides small, explicit service objects: typed and immutable
|
|
16
|
+
# inputs, a mandatory Success/Failure return value, and composable flows with
|
|
17
|
+
# optional transactions.
|
|
18
|
+
#
|
|
19
|
+
# See {Serviced::Service} and {Serviced::Flow}.
|
|
20
|
+
module Serviced
|
|
21
|
+
class << self
|
|
22
|
+
# @return [Serviced::Configuration] the current configuration
|
|
23
|
+
def configuration
|
|
24
|
+
@configuration ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yields the configuration for mutation.
|
|
28
|
+
# @yieldparam config [Serviced::Configuration]
|
|
29
|
+
def configure
|
|
30
|
+
yield(configuration)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resets configuration to defaults. Primarily useful in test suites.
|
|
34
|
+
# @return [Serviced::Configuration]
|
|
35
|
+
def reset_configuration!
|
|
36
|
+
@configuration = Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Renders an ActiveModel::Errors into a short human string. Falls back to
|
|
40
|
+
# the attribute names when full messages cannot be built: an anonymous
|
|
41
|
+
# class has no model_name, which ActiveModel needs to humanize messages.
|
|
42
|
+
# Building an error message must never itself raise.
|
|
43
|
+
# @param errors [ActiveModel::Errors]
|
|
44
|
+
# @return [String]
|
|
45
|
+
def error_summary(errors)
|
|
46
|
+
errors.full_messages.join(", ")
|
|
47
|
+
rescue StandardError
|
|
48
|
+
errors.attribute_names.join(", ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns an immutable snapshot of a value-like input. Arrays, hashes, sets
|
|
52
|
+
# and strings are deep-copied and frozen, so the result is isolated from the
|
|
53
|
+
# caller and cannot be mutated. Objects with identity (ActiveRecord records
|
|
54
|
+
# and other non-data objects) are returned by reference, unfrozen: a deep
|
|
55
|
+
# copy of a record is a different, non-persisted object, and freezing one in
|
|
56
|
+
# place would corrupt the caller's copy. Scalars are already immutable.
|
|
57
|
+
# @param value [Object]
|
|
58
|
+
# @return [Object] a frozen snapshot for value-like data, else the value itself
|
|
59
|
+
def snapshot(value)
|
|
60
|
+
case value
|
|
61
|
+
when Array then value.map { |element| snapshot(element) }.freeze
|
|
62
|
+
when Set then Set.new(value.map { |element| snapshot(element) }).freeze
|
|
63
|
+
when Hash then snapshot_hash(value)
|
|
64
|
+
when String then snapshot_string(value)
|
|
65
|
+
else value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def snapshot_hash(hash)
|
|
72
|
+
hash.each_with_object({}) { |(key, value), copy| copy[snapshot(key)] = snapshot(value) }.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def snapshot_string(string)
|
|
76
|
+
string.frozen? ? string : string.dup.freeze
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|