servus 0.7.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.
- checksums.yaml +4 -4
- data/lib/generators/servus/event/templates/event.rb.erb +13 -10
- data/lib/generators/servus/service/service_generator.rb +7 -27
- data/lib/generators/servus/service/templates/service.rb.erb +17 -24
- data/lib/servus/base.rb +57 -103
- data/lib/servus/config.rb +8 -54
- data/lib/servus/event.rb +101 -46
- data/lib/servus/events/bus.rb +1 -1
- data/lib/servus/events/emitter.rb +48 -27
- data/lib/servus/events/errors.rb +56 -0
- data/lib/servus/events/invocation.rb +22 -28
- data/lib/servus/extensions/async/call.rb +7 -0
- data/lib/servus/helpers/controller_helpers.rb +0 -40
- data/lib/servus/railtie.rb +3 -0
- data/lib/servus/schema/cache.rb +70 -0
- data/lib/servus/schema/compiler.rb +196 -0
- data/lib/servus/schema/declaration.rb +148 -0
- data/lib/servus/schema/errors.rb +76 -0
- data/lib/servus/schema/path.rb +60 -0
- data/lib/servus/schema/ref.rb +87 -0
- data/lib/servus/schema.rb +281 -0
- data/lib/servus/support/logger.rb +10 -0
- data/lib/servus/support/validator.rb +50 -107
- data/lib/servus/testing/example_extractor.rb +4 -6
- data/lib/servus/testing/matchers.rb +1 -6
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +12 -0
- metadata +11 -5
- data/lib/generators/servus/service/templates/arguments.json.erb +0 -24
- data/lib/generators/servus/service/templates/result.json.erb +0 -10
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'did_you_mean'
|
|
4
|
+
|
|
5
|
+
module Servus
|
|
6
|
+
# Registry of reusable JSON Schema fragments, and the entry point for
|
|
7
|
+
# compiling a schema that references them.
|
|
8
|
+
#
|
|
9
|
+
# Servus services and events declare their contracts inline via the +schema+
|
|
10
|
+
# DSL. That keeps a service's inputs and outputs visible in the file that
|
|
11
|
+
# implements it. The cost of inline-only declaration is duplication: the same
|
|
12
|
+
# +amount+ or +timestamp+ shape gets copy-pasted across every service that
|
|
13
|
+
# touches it.
|
|
14
|
+
#
|
|
15
|
+
# Registered fragments close that gap without giving up explicitness. A
|
|
16
|
+
# fragment is registered under a key, and services reference into it with a
|
|
17
|
+
# standard JSON Schema +$ref+. A service that references a shared type is
|
|
18
|
+
# still explicitly declaring that type — it just names it once.
|
|
19
|
+
#
|
|
20
|
+
# @example Registering a fragment
|
|
21
|
+
# Servus::Schema.register('core', {
|
|
22
|
+
# '$defs' => {
|
|
23
|
+
# 'amount' => { 'type' => 'integer', 'minimum' => 0 }
|
|
24
|
+
# }
|
|
25
|
+
# })
|
|
26
|
+
#
|
|
27
|
+
# @example Referencing it from a service
|
|
28
|
+
# class Treasury::TransferGold::Service < Servus::Base
|
|
29
|
+
# schema arguments: {
|
|
30
|
+
# type: 'object',
|
|
31
|
+
# required: ['gold_dragons'],
|
|
32
|
+
# properties: {
|
|
33
|
+
# gold_dragons: { '$ref' => '#/core/$defs/amount' }
|
|
34
|
+
# }
|
|
35
|
+
# }
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# Lookups never return nil. An unregistered key raises {UnknownKeyError} at
|
|
39
|
+
# the point of use, because the alternative — silently skipping validation for
|
|
40
|
+
# a service that appears to declare a contract — is the worst failure mode
|
|
41
|
+
# this system has.
|
|
42
|
+
#
|
|
43
|
+
# @see Servus::Schema::Compiler
|
|
44
|
+
# @see Servus::Base.schema
|
|
45
|
+
module Schema
|
|
46
|
+
@registry = {}.freeze
|
|
47
|
+
@cache = Cache.new
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Memoized ref resolutions and the generation counter derived from them.
|
|
52
|
+
#
|
|
53
|
+
# @return [Servus::Schema::Cache]
|
|
54
|
+
# @api private
|
|
55
|
+
attr_reader :cache
|
|
56
|
+
|
|
57
|
+
# Monotonic counter bumped whenever the registry changes.
|
|
58
|
+
#
|
|
59
|
+
# Consumers memoize compiled schemas alongside the generation they were
|
|
60
|
+
# compiled under, and recompile when it moves. That makes registry
|
|
61
|
+
# updates propagate without any explicit dependency tracking.
|
|
62
|
+
#
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
def generation = cache.generation
|
|
65
|
+
|
|
66
|
+
# Registers a reusable schema fragment under +key+.
|
|
67
|
+
#
|
|
68
|
+
# Re-registering an equal value is a silent no-op, so calling this from a
|
|
69
|
+
# Rails +to_prepare+ block is safe. Re-registering a *different* value
|
|
70
|
+
# replaces it, logs an override, and bumps {generation} — which
|
|
71
|
+
# invalidates every compiled schema that referenced it.
|
|
72
|
+
#
|
|
73
|
+
# @param key [String, Symbol] the fragment key, referenced as +#/<key>/...+
|
|
74
|
+
# @param fragment [Hash] the schema fragment
|
|
75
|
+
# @return [ActiveSupport::HashWithIndifferentAccess] the normalized fragment
|
|
76
|
+
# @raise [InvalidKeyError] if the key is blank or contains a +/+
|
|
77
|
+
# @raise [ArgumentError] if the fragment is not a Hash
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# Servus::Schema.register('core', { '$defs' => { 'id' => { 'type' => 'integer' } } })
|
|
81
|
+
def register(key, fragment)
|
|
82
|
+
key = normalize_key(key)
|
|
83
|
+
normalized = normalize_fragment(key, fragment)
|
|
84
|
+
|
|
85
|
+
cache.invalidate! if store(key, normalized)
|
|
86
|
+
|
|
87
|
+
normalized
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns a registered fragment, or a definition within one.
|
|
91
|
+
#
|
|
92
|
+
# Given no path, returns the whole fragment. Given path segments, walks
|
|
93
|
+
# them as literal keys — the same addressing a +$ref+ uses, so
|
|
94
|
+
# +fetch(key, *path)+ reads exactly what +ref(key, *path)+ points at.
|
|
95
|
+
#
|
|
96
|
+
# A missing path raises rather than returning nil. Reaching for the
|
|
97
|
+
# fragment and calling +dig+ would return nil on a typo, which is the
|
|
98
|
+
# silent failure this registry exists to prevent.
|
|
99
|
+
#
|
|
100
|
+
# Fragments are returned as authored, with any +$ref+s intact. Use
|
|
101
|
+
# {compile} to resolve them.
|
|
102
|
+
#
|
|
103
|
+
# @param key [String, Symbol] the fragment key
|
|
104
|
+
# @param path [Array<String, Symbol>] segments to walk within the fragment
|
|
105
|
+
# @return [ActiveSupport::HashWithIndifferentAccess, Object] the frozen fragment or definition
|
|
106
|
+
# @raise [UnknownKeyError] if nothing is registered under +key+
|
|
107
|
+
# @raise [RefNotFoundError] if the path is not present in the fragment
|
|
108
|
+
#
|
|
109
|
+
# @example
|
|
110
|
+
# Servus::Schema.fetch('core')
|
|
111
|
+
# Servus::Schema.fetch('core', '$defs', 'amount')
|
|
112
|
+
def fetch(key, *path)
|
|
113
|
+
key = key.to_s
|
|
114
|
+
fragment = @registry.fetch(key) { raise UnknownKeyError.for(key, available: @registry.keys) }
|
|
115
|
+
|
|
116
|
+
Path.walk(fragment, key, path.map(&:to_s))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns a fragment, or a definition within one, with all +$ref+s resolved.
|
|
120
|
+
#
|
|
121
|
+
# The compiled counterpart to {fetch}: same addressing, but the result is
|
|
122
|
+
# self-contained and ready to validate against. This is usually what
|
|
123
|
+
# application code outside a service wants — a controller validating a
|
|
124
|
+
# request body, a serializer checking a response shape.
|
|
125
|
+
#
|
|
126
|
+
# Results are memoized, so asking repeatedly for the same address is cheap.
|
|
127
|
+
#
|
|
128
|
+
# @param key [String, Symbol] the fragment key
|
|
129
|
+
# @param path [Array<String, Symbol>] segments to walk within the fragment
|
|
130
|
+
# @return [Hash, Object] the compiled fragment or definition
|
|
131
|
+
# @raise [UnknownKeyError] if nothing is registered under +key+
|
|
132
|
+
# @raise [RefNotFoundError] if the path is not present in the fragment
|
|
133
|
+
# @raise [Error] if a ref within it cannot be resolved
|
|
134
|
+
#
|
|
135
|
+
# @example
|
|
136
|
+
# Servus::Schema.resolve('endpoints::trades::create', '$defs', 'request')
|
|
137
|
+
# # => { "type" => "object", "properties" => { "price" => { "type" => "integer" } } }
|
|
138
|
+
def resolve(key, *path)
|
|
139
|
+
pointer = ref(key, *path)
|
|
140
|
+
|
|
141
|
+
compile(pointer, context: "schema #{pointer['$ref']}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Compiles every registered fragment, resolving all +$ref+s.
|
|
145
|
+
#
|
|
146
|
+
# Returns a hash of key to compiled fragment, mirroring the registry's own
|
|
147
|
+
# shape so keys stay addressable and the result serializes straight to
|
|
148
|
+
# JSON. Useful for producing a single schema asset for an API description,
|
|
149
|
+
# a docs build, client codegen, or a CI freshness check.
|
|
150
|
+
#
|
|
151
|
+
# @return [Hash{String => Hash}] every fragment, refs resolved
|
|
152
|
+
# @raise [Error] if any fragment contains a ref that cannot be resolved
|
|
153
|
+
#
|
|
154
|
+
# @example
|
|
155
|
+
# File.write('schema.json', JSON.pretty_generate(Servus::Schema.compile_all))
|
|
156
|
+
def compile_all
|
|
157
|
+
keys.to_h { |key| [key, compile(fetch(key), context: "schema fragment #{key.inspect}")] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [Array<String>] registered keys, sorted
|
|
161
|
+
def keys
|
|
162
|
+
@registry.keys.sort
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Builds a +$ref+ pointing at a registered fragment.
|
|
166
|
+
#
|
|
167
|
+
# Prefer this over hand-writing ref strings — it is typo-proof in the
|
|
168
|
+
# separator and prefix, which are the parts people get wrong.
|
|
169
|
+
#
|
|
170
|
+
# @param key [String, Symbol] the fragment key
|
|
171
|
+
# @param path [Array<String, Symbol>] segments to walk within the fragment
|
|
172
|
+
# @return [Hash] a +$ref+ hash
|
|
173
|
+
#
|
|
174
|
+
# @example
|
|
175
|
+
# Servus::Schema.ref('core', '$defs', 'amount')
|
|
176
|
+
# # => { "$ref" => "#/core/$defs/amount" }
|
|
177
|
+
def ref(key, *path)
|
|
178
|
+
{ '$ref' => "#/#{[key, *path].map(&:to_s).join('/')}" }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Compiles a schema, replacing every +$ref+ with the fragment it names.
|
|
182
|
+
#
|
|
183
|
+
# @param schema [Hash, nil] the authored schema
|
|
184
|
+
# @param context [String, nil] label used in error messages, e.g.
|
|
185
|
+
# "Treasury::TransferGold::Service arguments schema"
|
|
186
|
+
# @return [Hash, nil] the compiled schema, or nil if +schema+ was nil
|
|
187
|
+
# @raise [Error] if any ref cannot be resolved
|
|
188
|
+
def compile(schema, context: nil)
|
|
189
|
+
return nil if schema.nil?
|
|
190
|
+
|
|
191
|
+
Compiler.new(context: context).compile(schema)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Clears the registry. Intended for test suites.
|
|
195
|
+
#
|
|
196
|
+
# @return [void]
|
|
197
|
+
def reset!
|
|
198
|
+
restore({}.freeze)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Captures the registry state so a test can restore it afterwards.
|
|
202
|
+
#
|
|
203
|
+
# @return [Hash] an opaque snapshot for {restore}
|
|
204
|
+
# @api private
|
|
205
|
+
def snapshot
|
|
206
|
+
@registry
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Restores a snapshot taken by {snapshot}.
|
|
210
|
+
#
|
|
211
|
+
# @param snapshot [Hash]
|
|
212
|
+
# @return [void]
|
|
213
|
+
# @api private
|
|
214
|
+
def restore(snapshot)
|
|
215
|
+
@mutex.synchronize { @registry = snapshot }
|
|
216
|
+
cache.invalidate!
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
# Writes a normalized fragment into the registry.
|
|
222
|
+
#
|
|
223
|
+
# @param key [String]
|
|
224
|
+
# @param normalized [Hash] the normalized fragment
|
|
225
|
+
# @return [Boolean] whether the registry actually changed
|
|
226
|
+
# @api private
|
|
227
|
+
def store(key, normalized)
|
|
228
|
+
@mutex.synchronize do
|
|
229
|
+
existing = @registry[key]
|
|
230
|
+
return false if existing == normalized
|
|
231
|
+
|
|
232
|
+
Support::Logger.log_schema_override(key) if existing
|
|
233
|
+
@registry = @registry.merge(key => normalized).freeze
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
true
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @param key [String, Symbol]
|
|
240
|
+
# @return [String]
|
|
241
|
+
# @raise [InvalidKeyError]
|
|
242
|
+
# @api private
|
|
243
|
+
def normalize_key(key)
|
|
244
|
+
key = key.to_s
|
|
245
|
+
|
|
246
|
+
raise InvalidKeyError, 'schema fragment key cannot be blank' if key.strip.empty?
|
|
247
|
+
|
|
248
|
+
if key.include?('/')
|
|
249
|
+
raise InvalidKeyError, "schema fragment key #{key.inspect} cannot contain '/' — " \
|
|
250
|
+
'it is the separator in $ref paths, so the key would be unreferenceable'
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
key
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# @param key [String] the key, for the error message
|
|
257
|
+
# @param fragment [Hash]
|
|
258
|
+
# @return [ActiveSupport::HashWithIndifferentAccess] deeply frozen
|
|
259
|
+
# @raise [ArgumentError] if the fragment is not a Hash
|
|
260
|
+
# @api private
|
|
261
|
+
def normalize_fragment(key, fragment)
|
|
262
|
+
unless fragment.is_a?(Hash)
|
|
263
|
+
raise ArgumentError, "schema fragment for #{key.inspect} must be a Hash, got #{fragment.class}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
deep_freeze(fragment.deep_dup.with_indifferent_access)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @param value [Object]
|
|
270
|
+
# @return [Object] the same value, frozen through nested hashes and arrays
|
|
271
|
+
# @api private
|
|
272
|
+
def deep_freeze(value)
|
|
273
|
+
case value
|
|
274
|
+
when Hash then value.each_value { |v| deep_freeze(v) }.freeze
|
|
275
|
+
when Array then value.each { |v| deep_freeze(v) }.freeze
|
|
276
|
+
else value.freeze
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -94,6 +94,16 @@ module Servus
|
|
|
94
94
|
logger.error("#{service_class.name} uncaught exception: #{exception.class} - #{exception.message}")
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Logs that a registered schema fragment was replaced with a different value.
|
|
98
|
+
#
|
|
99
|
+
# Expected during development reloads. Outside of that it usually means
|
|
100
|
+
# two libraries are claiming the same fragment key.
|
|
101
|
+
#
|
|
102
|
+
# @param key [String] The schema fragment key being overridden
|
|
103
|
+
def self.log_schema_override(key)
|
|
104
|
+
logger.warn("Schema fragment #{key.inspect} was already registered with a different value; replacing it.")
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
# Filters parameters for logging based on the configured filter list.
|
|
98
108
|
#
|
|
99
109
|
# @param params [Hash] The parameters to filter
|
|
@@ -2,42 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module Servus
|
|
4
4
|
module Support
|
|
5
|
-
#
|
|
5
|
+
# Validates service arguments and results, and event payloads, against the
|
|
6
|
+
# JSON schemas declared with the +schema+ DSL.
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# Arguments are validated before +call+ runs, so a service body can trust
|
|
9
|
+
# the shape of its inputs. Result data is validated after it returns, so a
|
|
10
|
+
# service that stops honouring its own contract fails loudly rather than
|
|
11
|
+
# passing the wrong shape to its callers. Both raise
|
|
12
|
+
# {Servus::Support::Errors::ValidationError}, which signals a bug — in the
|
|
13
|
+
# caller for arguments, in the service itself for results — and is not
|
|
14
|
+
# meant to be rescued.
|
|
10
15
|
#
|
|
11
|
-
#
|
|
16
|
+
# Schemas come from the +schema+ DSL and nowhere else. The class-level
|
|
17
|
+
# readers resolve any +$ref+s against {Servus::Schema}, so what arrives
|
|
18
|
+
# here is always a self-contained schema.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
12
21
|
# class MyService < Servus::Base
|
|
13
|
-
#
|
|
14
|
-
# type: "object",
|
|
15
|
-
# required: ["user_id"],
|
|
16
|
-
# properties: {
|
|
17
|
-
# user_id: { type: "integer" }
|
|
18
|
-
# }
|
|
19
|
-
# }
|
|
22
|
+
# schema arguments: { type: 'object', required: ['user_id'] }
|
|
20
23
|
# end
|
|
21
24
|
#
|
|
22
|
-
# @
|
|
23
|
-
#
|
|
24
|
-
# # { "type": "object", "required": ["user_id"], ... }
|
|
25
|
-
#
|
|
25
|
+
# @see Servus::Base.schema
|
|
26
|
+
# @see Servus::Schema
|
|
26
27
|
# @see https://json-schema.org/specification.html
|
|
27
28
|
class Validator
|
|
29
|
+
# Schema kinds that may be requested from {.load_schema}.
|
|
30
|
+
#
|
|
31
|
+
# @api private
|
|
32
|
+
SCHEMA_TYPES = %w[arguments result failure payload].freeze
|
|
33
|
+
|
|
28
34
|
# @api private
|
|
29
35
|
@schema_cache = {}
|
|
30
36
|
|
|
31
|
-
# Validates service arguments against the
|
|
32
|
-
#
|
|
33
|
-
# Checks arguments against either an inline ARGUMENTS_SCHEMA constant or
|
|
34
|
-
# a file-based schema at app/schemas/services/namespace/arguments.json.
|
|
35
|
-
# Validation is skipped if no schema is defined.
|
|
37
|
+
# Validates service arguments against the service's arguments schema.
|
|
36
38
|
#
|
|
37
39
|
# @param service_class [Class] the service class being validated
|
|
38
40
|
# @param args [Hash] keyword arguments passed to the service
|
|
39
41
|
# @return [Boolean] true if validation passes
|
|
40
42
|
# @raise [Servus::Support::Errors::ValidationError] if arguments fail validation
|
|
43
|
+
# @raise [Servus::Support::Errors::SchemaRequiredError] if no schema is
|
|
44
|
+
# declared and +require_service_arguments_schema+ is enabled
|
|
41
45
|
#
|
|
42
46
|
# @example
|
|
43
47
|
# Validator.validate_arguments!(MyService, { user_id: 123 })
|
|
@@ -48,11 +52,7 @@ module Servus
|
|
|
48
52
|
enforce_schema_presence!(schema, service_class, :require_service_arguments_schema)
|
|
49
53
|
return true unless schema
|
|
50
54
|
|
|
51
|
-
validate_data_against_schema!(
|
|
52
|
-
args,
|
|
53
|
-
schema,
|
|
54
|
-
"Invalid arguments for #{service_class.name}"
|
|
55
|
-
)
|
|
55
|
+
validate_data_against_schema!(args, schema, "Invalid arguments for #{service_class.name}")
|
|
56
56
|
|
|
57
57
|
true
|
|
58
58
|
end
|
|
@@ -102,7 +102,7 @@ module Servus
|
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
# Validates event payload against the
|
|
105
|
+
# Validates an event payload against the event's payload schema.
|
|
106
106
|
#
|
|
107
107
|
# @param event_class [Class] the Event subclass
|
|
108
108
|
# @param payload [Hash] the event payload to validate
|
|
@@ -114,7 +114,7 @@ module Servus
|
|
|
114
114
|
#
|
|
115
115
|
# @api private
|
|
116
116
|
def self.validate_event_payload!(event_class, payload)
|
|
117
|
-
schema = event_class
|
|
117
|
+
schema = load_schema(event_class, 'payload')
|
|
118
118
|
enforce_schema_presence!(schema, event_class, :require_event_payload_schema)
|
|
119
119
|
return true unless schema
|
|
120
120
|
|
|
@@ -127,50 +127,36 @@ module Servus
|
|
|
127
127
|
true
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
# Implements a three-tier lookup strategy:
|
|
133
|
-
# 1. Check for schema defined via DSL method (service_class.arguments_schema/result_schema)
|
|
134
|
-
# 2. Check for inline constant (ARGUMENTS_SCHEMA or RESULT_SCHEMA)
|
|
135
|
-
# 3. Fall back to JSON file in app/schemas/services/namespace/type.json
|
|
130
|
+
# Returns a class's compiled schema of the given kind.
|
|
136
131
|
#
|
|
137
|
-
#
|
|
132
|
+
# Cached per class and kind. The underlying compilation is also memoized
|
|
133
|
+
# on the class itself and rebuilds when {Servus::Schema} changes, so this
|
|
134
|
+
# cache exists to skip the lookup, not to hold compilation results.
|
|
138
135
|
#
|
|
139
|
-
# @param
|
|
140
|
-
# @param type [String]
|
|
141
|
-
# @return [Hash, nil] the schema
|
|
136
|
+
# @param klass [Class] a {Servus::Base} or {Servus::Event} subclass
|
|
137
|
+
# @param type [String, Symbol] one of {SCHEMA_TYPES}
|
|
138
|
+
# @return [Hash, nil] the compiled schema, or nil if none is declared
|
|
139
|
+
# @raise [ArgumentError] if +type+ is not a known schema kind
|
|
142
140
|
#
|
|
143
141
|
# @api private
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# Get service path based on class name (e.g., "process_payment" from "Servus::ProcessPayment::Service")
|
|
147
|
-
service_namespace = parse_service_namespace(service_class)
|
|
148
|
-
schema_path = Servus.config.schema_path_for(service_namespace, type)
|
|
149
|
-
|
|
150
|
-
# Return from cache if available
|
|
151
|
-
return @schema_cache[schema_path] if @schema_cache.key?(schema_path)
|
|
142
|
+
def self.load_schema(klass, type)
|
|
143
|
+
type = type.to_s
|
|
152
144
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
when 'result' then service_class.result_schema
|
|
157
|
-
when 'failure' then service_class.failure_schema
|
|
158
|
-
end
|
|
145
|
+
unless SCHEMA_TYPES.include?(type)
|
|
146
|
+
raise ArgumentError, "unknown schema type #{type.inspect}. Valid: #{SCHEMA_TYPES.join(', ')}."
|
|
147
|
+
end
|
|
159
148
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
Object.const_get(inline_schema_constant_name)
|
|
163
|
-
end
|
|
149
|
+
key = [klass, type]
|
|
150
|
+
return @schema_cache[key] if @schema_cache.key?(key)
|
|
164
151
|
|
|
165
|
-
@schema_cache[
|
|
166
|
-
@schema_cache[schema_path]
|
|
152
|
+
@schema_cache[key] = klass.public_send(:"#{type}_schema")
|
|
167
153
|
end
|
|
168
|
-
# rubocop:enable Metrics/MethodLength
|
|
169
154
|
|
|
170
155
|
# Clears the schema cache.
|
|
171
156
|
#
|
|
172
|
-
# Useful in
|
|
173
|
-
#
|
|
157
|
+
# Useful in tests, and in development after changing a schema. Registry
|
|
158
|
+
# changes invalidate compiled schemas on their own, so this is rarely
|
|
159
|
+
# needed in application code.
|
|
174
160
|
#
|
|
175
161
|
# @return [Hash] empty hash
|
|
176
162
|
#
|
|
@@ -184,7 +170,7 @@ module Servus
|
|
|
184
170
|
|
|
185
171
|
# Returns the current schema cache.
|
|
186
172
|
#
|
|
187
|
-
# @return [Hash] cache mapping
|
|
173
|
+
# @return [Hash] cache mapping [class, type] pairs to compiled schemas
|
|
188
174
|
# @api private
|
|
189
175
|
def self.cache
|
|
190
176
|
@schema_cache
|
|
@@ -206,12 +192,12 @@ module Servus
|
|
|
206
192
|
raise Servus::Base::ValidationError, "#{message_prefix}: #{errors.join(', ')}"
|
|
207
193
|
end
|
|
208
194
|
|
|
209
|
-
#
|
|
195
|
+
# Raises if a schema is absent and the corresponding config flag is on.
|
|
210
196
|
#
|
|
211
197
|
# @param schema [Hash, nil] the loaded schema
|
|
212
198
|
# @param klass [Class] the service or Event class
|
|
213
199
|
# @param config_flag [Symbol] the config method to check
|
|
214
|
-
# @return [Hash, nil] the schema
|
|
200
|
+
# @return [Hash, nil] the schema, unchanged
|
|
215
201
|
# @raise [Servus::Support::Errors::SchemaRequiredError] if schema is nil and enforcement is enabled
|
|
216
202
|
#
|
|
217
203
|
# @api private
|
|
@@ -223,49 +209,6 @@ module Servus
|
|
|
223
209
|
raise Servus::Support::Errors::SchemaRequiredError,
|
|
224
210
|
"#{klass.name} schema missing! #{config_flag} is set to true."
|
|
225
211
|
end
|
|
226
|
-
|
|
227
|
-
# Fetches schema from DSL, inline constant, or file.
|
|
228
|
-
#
|
|
229
|
-
# Implements the schema resolution precedence:
|
|
230
|
-
# 1. DSL-defined schema (if provided)
|
|
231
|
-
# 2. Inline constant (if provided)
|
|
232
|
-
# 3. File at schema_path (if exists)
|
|
233
|
-
# 4. nil (no schema found)
|
|
234
|
-
#
|
|
235
|
-
# @param dsl_schema [Hash, nil] schema from DSL method (e.g., schema arguments: Hash)
|
|
236
|
-
# @param inline_schema_constant [Hash, nil] inline schema constant (e.g., ARGUMENTS_SCHEMA)
|
|
237
|
-
# @param schema_path [String] file path to external schema JSON
|
|
238
|
-
# @return [Hash, nil] schema with indifferent access, or nil if not found
|
|
239
|
-
#
|
|
240
|
-
# @api private
|
|
241
|
-
def self.fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path)
|
|
242
|
-
if dsl_schema
|
|
243
|
-
dsl_schema.with_indifferent_access
|
|
244
|
-
elsif inline_schema_constant
|
|
245
|
-
inline_schema_constant.with_indifferent_access
|
|
246
|
-
elsif File.exist?(schema_path)
|
|
247
|
-
JSON.load_file(schema_path).with_indifferent_access
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# Converts service class name to file path namespace.
|
|
252
|
-
#
|
|
253
|
-
# Transforms a class name like "Services::ProcessPayment::Service" into
|
|
254
|
-
# "services/process_payment" for locating schema files.
|
|
255
|
-
#
|
|
256
|
-
# @param service_class [Class] the service class
|
|
257
|
-
# @return [String] underscored namespace path
|
|
258
|
-
#
|
|
259
|
-
# @example
|
|
260
|
-
# parse_service_namespace(Services::ProcessPayment::Service)
|
|
261
|
-
# # => "services/process_payment"
|
|
262
|
-
#
|
|
263
|
-
# @api private
|
|
264
|
-
def self.parse_service_namespace(service_class)
|
|
265
|
-
service_class.name.split('::')[..-2].map do |s|
|
|
266
|
-
s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
267
|
-
end.join('/')
|
|
268
|
-
end
|
|
269
212
|
end
|
|
270
213
|
end
|
|
271
214
|
end
|
|
@@ -286,13 +286,11 @@ module Servus
|
|
|
286
286
|
end
|
|
287
287
|
end
|
|
288
288
|
|
|
289
|
-
# Loads schema from service class using Validator.
|
|
289
|
+
# Loads a schema from a service class using the Validator.
|
|
290
290
|
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
# - File-based schemas
|
|
295
|
-
# - Schema caching
|
|
291
|
+
# The schema returned is compiled, so +example+ and +examples+ values
|
|
292
|
+
# inside +$ref+'d fragments are visible to extraction — a shared fragment
|
|
293
|
+
# can carry its own examples and every service referencing it gets them.
|
|
296
294
|
#
|
|
297
295
|
# @param service_class [Class] The service class
|
|
298
296
|
# @param schema_type [Symbol] Either :arguments or :result
|
|
@@ -94,12 +94,7 @@ end
|
|
|
94
94
|
# Matcher for asserting schema presence on a service or Event class
|
|
95
95
|
RSpec::Matchers.define :have_schema do |schema_type|
|
|
96
96
|
match do |klass|
|
|
97
|
-
|
|
98
|
-
!klass.payload_schema.nil?
|
|
99
|
-
else
|
|
100
|
-
Servus::Support::Validator.clear_cache!
|
|
101
|
-
!Servus::Support::Validator.load_schema(klass, schema_type.to_s).nil?
|
|
102
|
-
end
|
|
97
|
+
!Servus::Support::Validator.load_schema(klass, schema_type.to_s).nil?
|
|
103
98
|
end
|
|
104
99
|
|
|
105
100
|
failure_message do |klass|
|
data/lib/servus/version.rb
CHANGED
data/lib/servus.rb
CHANGED
|
@@ -5,6 +5,8 @@ require 'json-schema'
|
|
|
5
5
|
require 'active_support'
|
|
6
6
|
require 'active_support/core_ext/class/attribute'
|
|
7
7
|
require 'active_support/core_ext/hash/indifferent_access'
|
|
8
|
+
require 'active_support/core_ext/object/deep_dup'
|
|
9
|
+
require 'active_support/core_ext/string/inflections'
|
|
8
10
|
# Servus namespace
|
|
9
11
|
module Servus; end
|
|
10
12
|
|
|
@@ -17,6 +19,15 @@ require_relative 'servus/railtie' if defined?(Rails::Railtie)
|
|
|
17
19
|
# Config
|
|
18
20
|
require_relative 'servus/config'
|
|
19
21
|
|
|
22
|
+
# Schema
|
|
23
|
+
require_relative 'servus/schema/errors'
|
|
24
|
+
require_relative 'servus/schema/cache'
|
|
25
|
+
require_relative 'servus/schema/path'
|
|
26
|
+
require_relative 'servus/schema/ref'
|
|
27
|
+
require_relative 'servus/schema/declaration'
|
|
28
|
+
require_relative 'servus/schema/compiler'
|
|
29
|
+
require_relative 'servus/schema'
|
|
30
|
+
|
|
20
31
|
# Support
|
|
21
32
|
require_relative 'servus/support/logger'
|
|
22
33
|
require_relative 'servus/support/data_object'
|
|
@@ -28,6 +39,7 @@ require_relative 'servus/support/lockdown'
|
|
|
28
39
|
require_relative 'servus/support/message_resolver'
|
|
29
40
|
|
|
30
41
|
# Events
|
|
42
|
+
require_relative 'servus/events/errors'
|
|
31
43
|
require_relative 'servus/events/bus'
|
|
32
44
|
require_relative 'servus/events/emitter'
|
|
33
45
|
require_relative 'servus/event'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: servus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Scholl
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -68,8 +68,6 @@ files:
|
|
|
68
68
|
- lib/generators/servus/guard/templates/guard.rb.erb
|
|
69
69
|
- lib/generators/servus/guard/templates/guard_spec.rb.erb
|
|
70
70
|
- lib/generators/servus/service/service_generator.rb
|
|
71
|
-
- lib/generators/servus/service/templates/arguments.json.erb
|
|
72
|
-
- lib/generators/servus/service/templates/result.json.erb
|
|
73
71
|
- lib/generators/servus/service/templates/service.rb.erb
|
|
74
72
|
- lib/generators/servus/service/templates/service_spec.rb.erb
|
|
75
73
|
- lib/servus.rb
|
|
@@ -79,6 +77,7 @@ files:
|
|
|
79
77
|
- lib/servus/events/bus.rb
|
|
80
78
|
- lib/servus/events/class_router.rb
|
|
81
79
|
- lib/servus/events/emitter.rb
|
|
80
|
+
- lib/servus/events/errors.rb
|
|
82
81
|
- lib/servus/events/invocation.rb
|
|
83
82
|
- lib/servus/events/router.rb
|
|
84
83
|
- lib/servus/extensions/async/call.rb
|
|
@@ -97,6 +96,13 @@ files:
|
|
|
97
96
|
- lib/servus/guards/truthy_guard.rb
|
|
98
97
|
- lib/servus/helpers/controller_helpers.rb
|
|
99
98
|
- lib/servus/railtie.rb
|
|
99
|
+
- lib/servus/schema.rb
|
|
100
|
+
- lib/servus/schema/cache.rb
|
|
101
|
+
- lib/servus/schema/compiler.rb
|
|
102
|
+
- lib/servus/schema/declaration.rb
|
|
103
|
+
- lib/servus/schema/errors.rb
|
|
104
|
+
- lib/servus/schema/path.rb
|
|
105
|
+
- lib/servus/schema/ref.rb
|
|
100
106
|
- lib/servus/support/data_object.rb
|
|
101
107
|
- lib/servus/support/errors.rb
|
|
102
108
|
- lib/servus/support/lockdown.rb
|
|
@@ -127,7 +133,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
127
133
|
requirements:
|
|
128
134
|
- - ">="
|
|
129
135
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: 3.
|
|
136
|
+
version: 3.2.0
|
|
131
137
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
132
138
|
requirements:
|
|
133
139
|
- - ">="
|