servus 0.7.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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 +58 -105
- 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 +19 -7
- 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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Schema
|
|
5
|
+
# Resolves +$ref+ pointers in a schema against the {Servus::Schema} registry,
|
|
6
|
+
# producing a self-contained schema with no refs left in it.
|
|
7
|
+
#
|
|
8
|
+
# One instance per compile. The instance carries the cycle-detection state
|
|
9
|
+
# and the context label used in error messages; the memo it consults is
|
|
10
|
+
# process-wide and lives on {Servus::Schema}.
|
|
11
|
+
#
|
|
12
|
+
# == Sibling properties
|
|
13
|
+
#
|
|
14
|
+
# Keys alongside a +$ref+ override the resolved target:
|
|
15
|
+
#
|
|
16
|
+
# { '$ref' => '#/core/$defs/amount', 'description' => 'Fee charged' }
|
|
17
|
+
#
|
|
18
|
+
# This is a template-and-override reading, which is what makes shared
|
|
19
|
+
# fragments usable in practice — you take the shape and re-describe it for
|
|
20
|
+
# the site that uses it. Note that it *differs* from JSON Schema 2019-09 and
|
|
21
|
+
# later, where properties beside a +$ref+ are an additional subschema
|
|
22
|
+
# applied as an intersection rather than an override.
|
|
23
|
+
#
|
|
24
|
+
# Siblings are compiled independently and merged *onto* an already-resolved
|
|
25
|
+
# target, rather than merged first and resolved after. That ordering is what
|
|
26
|
+
# makes the target cacheable: the memo holds a value that does not depend on
|
|
27
|
+
# the call site.
|
|
28
|
+
#
|
|
29
|
+
# @see Servus::Schema
|
|
30
|
+
# @see Servus::Schema::Ref
|
|
31
|
+
class Compiler
|
|
32
|
+
# Maximum structural nesting depth before {DepthExceededError} is raised.
|
|
33
|
+
#
|
|
34
|
+
# This is a runaway guard for pathological input, not a cycle check —
|
|
35
|
+
# cycles are caught exactly by {#resolve_ref}'s visited set, however deep
|
|
36
|
+
# or shallow they are. Keeping the two separate means a legitimately deep
|
|
37
|
+
# acyclic schema compiles instead of being misreported as circular.
|
|
38
|
+
MAX_DEPTH = 100
|
|
39
|
+
|
|
40
|
+
# Keys stripped from a fragment when it is spliced into another schema.
|
|
41
|
+
#
|
|
42
|
+
# +json-schema+ resolves a nested +$schema+ against its registered
|
|
43
|
+
# validators and raises +JSON::Schema::SchemaError+ when it does not
|
|
44
|
+
# recognize the URI — at any position in the document, not just the root.
|
|
45
|
+
# Fragments authored as standalone documents routinely carry these, so
|
|
46
|
+
# they are dropped on splice rather than left to blow up at validation time.
|
|
47
|
+
#
|
|
48
|
+
# @see https://github.com/voxpupuli/json-schema
|
|
49
|
+
METADATA_KEYS = %w[$schema $id id].freeze
|
|
50
|
+
|
|
51
|
+
# @param context [String, nil] label for the schema being compiled, used
|
|
52
|
+
# in error messages, e.g. "Treasury::TransferGold::Service arguments schema"
|
|
53
|
+
def initialize(context: nil)
|
|
54
|
+
@context = context
|
|
55
|
+
@path = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compiles a schema, replacing every +$ref+ with the fragment it names.
|
|
59
|
+
#
|
|
60
|
+
# @param schema [Object] the authored schema
|
|
61
|
+
# @return [Object] the compiled schema
|
|
62
|
+
# @raise [Error] if any ref cannot be resolved
|
|
63
|
+
def compile(schema)
|
|
64
|
+
resolve(schema, 0)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Recursively resolves a node.
|
|
70
|
+
#
|
|
71
|
+
# @param node [Object]
|
|
72
|
+
# @param depth [Integer] current structural nesting depth
|
|
73
|
+
# @return [Object]
|
|
74
|
+
# @api private
|
|
75
|
+
def resolve(node, depth)
|
|
76
|
+
raise DepthExceededError, contextualize(depth_message) if depth > MAX_DEPTH
|
|
77
|
+
|
|
78
|
+
case node
|
|
79
|
+
when Hash then resolve_hash(node, depth)
|
|
80
|
+
when Array then node.map { |item| resolve(item, depth + 1) }
|
|
81
|
+
else node
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param node [Hash]
|
|
86
|
+
# @param depth [Integer]
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
# @api private
|
|
89
|
+
def resolve_hash(node, depth)
|
|
90
|
+
return resolve_ref_node(node, depth) if node.key?('$ref') || node.key?(:$ref)
|
|
91
|
+
|
|
92
|
+
node.transform_values { |value| resolve(value, depth + 1) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Resolves a node carrying a +$ref+, merging sibling keys over the target.
|
|
96
|
+
#
|
|
97
|
+
# @param node [Hash]
|
|
98
|
+
# @param depth [Integer]
|
|
99
|
+
# @return [Hash]
|
|
100
|
+
# @api private
|
|
101
|
+
def resolve_ref_node(node, depth)
|
|
102
|
+
target = resolve_ref(node['$ref'] || node[:$ref])
|
|
103
|
+
siblings = node.reject { |key, _| key.to_s == '$ref' }
|
|
104
|
+
|
|
105
|
+
siblings.empty? ? target : target.merge(resolve_hash(siblings, depth))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Resolves a single ref to its target, guarding against cycles.
|
|
109
|
+
#
|
|
110
|
+
# @param value [Object] the raw +$ref+ value
|
|
111
|
+
# @return [Object] the resolved target
|
|
112
|
+
# @raise [Error]
|
|
113
|
+
# @api private
|
|
114
|
+
def resolve_ref(value)
|
|
115
|
+
ref = with_context { Ref.parse(value) }
|
|
116
|
+
|
|
117
|
+
raise CircularReferenceError, contextualize(circular_message(ref)) if @path.include?(ref.value)
|
|
118
|
+
|
|
119
|
+
@path.push(ref.value)
|
|
120
|
+
begin
|
|
121
|
+
Schema.cache.resolve(ref.value) { expand(ref) }
|
|
122
|
+
ensure
|
|
123
|
+
@path.pop
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Looks a ref's target up in the registry and resolves it in turn.
|
|
128
|
+
#
|
|
129
|
+
# @param ref [Ref]
|
|
130
|
+
# @return [Object]
|
|
131
|
+
# @api private
|
|
132
|
+
def expand(ref)
|
|
133
|
+
target = with_context { Schema.fetch(ref.key, *ref.segments) }
|
|
134
|
+
|
|
135
|
+
strip_metadata(resolve(target, 0))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Removes document-level metadata from a spliced fragment.
|
|
139
|
+
#
|
|
140
|
+
# @param node [Object]
|
|
141
|
+
# @return [Object]
|
|
142
|
+
# @api private
|
|
143
|
+
def strip_metadata(node)
|
|
144
|
+
return node unless node.is_a?(Hash)
|
|
145
|
+
return node unless node.keys.map(&:to_s).intersect?(METADATA_KEYS)
|
|
146
|
+
|
|
147
|
+
node.reject { |key, _| METADATA_KEYS.include?(key.to_s) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Runs a block, re-raising any schema error with this compile's context.
|
|
151
|
+
#
|
|
152
|
+
# {Ref} and {Servus::Schema} raise where the problem is detected and know
|
|
153
|
+
# nothing about which schema or ref chain led there. This attaches that.
|
|
154
|
+
#
|
|
155
|
+
# Both call sites wrap a single foreign call rather than the recursion
|
|
156
|
+
# around it, so an error is decorated exactly once on its way out.
|
|
157
|
+
#
|
|
158
|
+
# @yield the work that might raise
|
|
159
|
+
# @return [Object] the block's value
|
|
160
|
+
# @api private
|
|
161
|
+
def with_context
|
|
162
|
+
yield
|
|
163
|
+
rescue Error => e
|
|
164
|
+
raise e.class, contextualize(e.message)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Appends the schema being compiled and the ref chain that led here.
|
|
168
|
+
#
|
|
169
|
+
# @param message [String]
|
|
170
|
+
# @return [String]
|
|
171
|
+
# @api private
|
|
172
|
+
def contextualize(message)
|
|
173
|
+
parts = [message]
|
|
174
|
+
parts << "while compiling #{@context}" if @context
|
|
175
|
+
parts << "(resolution path: #{@path.join(' -> ')})" if @path.length > 1
|
|
176
|
+
|
|
177
|
+
parts.join("
|
|
178
|
+
")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @param ref [Ref]
|
|
182
|
+
# @return [String]
|
|
183
|
+
# @api private
|
|
184
|
+
def circular_message(ref)
|
|
185
|
+
"circular $ref detected: #{(@path + [ref.value]).join(' -> ')}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @return [String]
|
|
189
|
+
# @api private
|
|
190
|
+
def depth_message
|
|
191
|
+
"schema nests more than #{MAX_DEPTH} levels deep. This is a runaway guard — " \
|
|
192
|
+
'if the schema is legitimately this deep, flatten it into registered fragments.'
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Schema
|
|
5
|
+
# Provides the +schema+ DSL to a class.
|
|
6
|
+
#
|
|
7
|
+
# Extend a class with this and call {#declare_schemas} with the schema
|
|
8
|
+
# kinds it supports. {Servus::Base} declares +arguments+, +result+, and
|
|
9
|
+
# +failure+; {Servus::Event} declares +payload+. Each kind gets:
|
|
10
|
+
#
|
|
11
|
+
# * a keyword on the generated +schema+ class method
|
|
12
|
+
# * a reader returning the compiled schema, e.g. +arguments_schema+
|
|
13
|
+
#
|
|
14
|
+
# == Compiled on read
|
|
15
|
+
#
|
|
16
|
+
# The plain reader compiles, so every consumer — validation, the test
|
|
17
|
+
# example builders, the +have_schema+ matcher, application code reading a
|
|
18
|
+
# service's contract — sees resolved +$ref+s without having to know that
|
|
19
|
+
# compilation exists. Results are memoized per class against
|
|
20
|
+
# {Servus::Schema.generation}, so registering a changed fragment rebuilds
|
|
21
|
+
# dependent schemas with no dependency tracking.
|
|
22
|
+
#
|
|
23
|
+
# == Inheritance
|
|
24
|
+
#
|
|
25
|
+
# Readers walk the ancestor chain, so a subclass of a schema-bearing class
|
|
26
|
+
# inherits its contract. Without this a subclass silently validates nothing,
|
|
27
|
+
# which is the failure mode this whole subsystem is built to prevent.
|
|
28
|
+
#
|
|
29
|
+
# @see Servus::Base.schema
|
|
30
|
+
# @see Servus::Event.schema
|
|
31
|
+
module Declaration
|
|
32
|
+
# Defines the +schema+ DSL and its readers for the given kinds.
|
|
33
|
+
#
|
|
34
|
+
# @param types [Array<Symbol>] the schema kinds this class supports
|
|
35
|
+
# @return [void]
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# class Servus::Event
|
|
39
|
+
# extend Servus::Schema::Declaration
|
|
40
|
+
# declare_schemas :payload
|
|
41
|
+
# end
|
|
42
|
+
def declare_schemas(*types)
|
|
43
|
+
@schema_types = types.freeze
|
|
44
|
+
|
|
45
|
+
types.each do |type|
|
|
46
|
+
define_singleton_method(:"#{type}_schema") { compiled_schema(type) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The schema kinds this class supports.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array<Symbol>]
|
|
53
|
+
# @api private
|
|
54
|
+
def schema_types
|
|
55
|
+
@schema_types || superclass.schema_types
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Declares schemas for this class.
|
|
59
|
+
#
|
|
60
|
+
# Omitting a keyword leaves any previously declared schema of that kind
|
|
61
|
+
# in place. Passing one explicitly as +nil+ raises, rather than quietly
|
|
62
|
+
# leaving the class unvalidated — a lookup that returns nil is a bug at
|
|
63
|
+
# the call site, and swallowing it is how contracts silently disappear.
|
|
64
|
+
#
|
|
65
|
+
# @param schemas [Hash{Symbol => Hash}] schema kind to JSON Schema
|
|
66
|
+
# @return [void]
|
|
67
|
+
# @raise [ArgumentError] on an unknown kind or an explicit nil
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# schema arguments: { type: 'object', required: ['user_id'] }
|
|
71
|
+
def schema(**schemas)
|
|
72
|
+
validate_schema_kinds!(schemas.keys)
|
|
73
|
+
|
|
74
|
+
schemas.each do |type, value|
|
|
75
|
+
raise ArgumentError, nil_schema_message(type) if value.nil?
|
|
76
|
+
|
|
77
|
+
instance_variable_set(:"@raw_#{type}_schema", value.with_indifferent_access)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@compiled_schemas = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# @param types [Array<Symbol>]
|
|
86
|
+
# @return [void]
|
|
87
|
+
# @raise [ArgumentError]
|
|
88
|
+
# @api private
|
|
89
|
+
def validate_schema_kinds!(types)
|
|
90
|
+
unknown = types - schema_types
|
|
91
|
+
return if unknown.empty?
|
|
92
|
+
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"unknown schema #{'kind'.pluralize(unknown.size)} #{unknown.map(&:inspect).join(', ')} " \
|
|
95
|
+
"for #{name}. Valid: #{schema_types.map(&:inspect).join(', ')}."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param type [Symbol]
|
|
99
|
+
# @return [String]
|
|
100
|
+
# @api private
|
|
101
|
+
def nil_schema_message(type)
|
|
102
|
+
"#{name} declared a nil #{type} schema. Pass a Hash, or omit the keyword entirely — " \
|
|
103
|
+
'an explicit nil is usually a lookup that failed, and accepting it would leave this ' \
|
|
104
|
+
'class silently unvalidated.'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The schema as authored, from this class or the nearest ancestor.
|
|
108
|
+
#
|
|
109
|
+
# @param type [Symbol]
|
|
110
|
+
# @return [Hash, nil]
|
|
111
|
+
# @api private
|
|
112
|
+
def raw_schema(type)
|
|
113
|
+
klass = self
|
|
114
|
+
|
|
115
|
+
while klass.respond_to?(:raw_schema, true)
|
|
116
|
+
declared = klass.instance_variable_get(:"@raw_#{type}_schema")
|
|
117
|
+
return declared if declared
|
|
118
|
+
|
|
119
|
+
klass = klass.superclass
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The compiled schema, memoized against the registry generation.
|
|
126
|
+
#
|
|
127
|
+
# @param type [Symbol]
|
|
128
|
+
# @return [Hash, nil]
|
|
129
|
+
# @raise [Servus::Schema::Error] if a ref cannot be resolved
|
|
130
|
+
# @api private
|
|
131
|
+
def compiled_schema(type)
|
|
132
|
+
generation = Schema.generation
|
|
133
|
+
@compiled_schemas = nil unless @compiled_generation == generation
|
|
134
|
+
@compiled_generation = generation
|
|
135
|
+
@compiled_schemas ||= {}
|
|
136
|
+
|
|
137
|
+
return @compiled_schemas[type] if @compiled_schemas.key?(type)
|
|
138
|
+
|
|
139
|
+
compiled = Schema.compile(raw_schema(type), context: "#{name} #{type} schema")
|
|
140
|
+
|
|
141
|
+
# Compilation rebuilds hashes as it walks, so the indifferent access
|
|
142
|
+
# applied at declaration does not survive it. Readers are public API;
|
|
143
|
+
# restore it rather than making callers know which keys are strings.
|
|
144
|
+
@compiled_schemas[type] = compiled&.with_indifferent_access
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Schema
|
|
5
|
+
# Base class for every schema registry and compilation error.
|
|
6
|
+
#
|
|
7
|
+
# These deliberately do *not* inherit from {Servus::Support::Errors::ServiceError}.
|
|
8
|
+
# Everything in that hierarchy carries an +#http_status+ and an +#api_error+
|
|
9
|
+
# because it describes a business outcome a caller might render. A malformed
|
|
10
|
+
# +$ref+ or an unregistered fragment key is a programming error in the schema
|
|
11
|
+
# itself — there is no sensible HTTP status for it, and rescuing it would
|
|
12
|
+
# reintroduce exactly the silent non-validation this design exists to prevent.
|
|
13
|
+
#
|
|
14
|
+
# @see Servus::Schema
|
|
15
|
+
# @see Servus::Schema::Compiler
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Raised when a +$ref+ names a fragment key that is not registered.
|
|
19
|
+
#
|
|
20
|
+
# This is the error the whole registry design exists to produce. A lookup
|
|
21
|
+
# that returned nil instead would let a service that appears to declare a
|
|
22
|
+
# contract run with no validation at all, indefinitely and silently.
|
|
23
|
+
class UnknownKeyError < Error
|
|
24
|
+
# Builds the error for a missed lookup, suggesting the nearest key.
|
|
25
|
+
#
|
|
26
|
+
# @param key [String] the key that was not found
|
|
27
|
+
# @param available [Array<String>] currently registered keys
|
|
28
|
+
# @return [UnknownKeyError]
|
|
29
|
+
def self.for(key, available:)
|
|
30
|
+
return new(nothing_registered(key)) if available.empty?
|
|
31
|
+
|
|
32
|
+
new("unknown schema key #{key.inspect}.#{suggestion(key, available)}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param key [String]
|
|
36
|
+
# @return [String]
|
|
37
|
+
# @api private
|
|
38
|
+
def self.nothing_registered(key)
|
|
39
|
+
"unknown schema key #{key.inspect}: no schema fragments are registered. " \
|
|
40
|
+
'Register one with Servus::Schema.register(key, fragment).'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param key [String]
|
|
44
|
+
# @param available [Array<String>]
|
|
45
|
+
# @return [String] a " Did you mean: ..." clause, or an empty string
|
|
46
|
+
# @api private
|
|
47
|
+
def self.suggestion(key, available)
|
|
48
|
+
matches = DidYouMean::SpellChecker.new(dictionary: available).correct(key)
|
|
49
|
+
return '' if matches.empty?
|
|
50
|
+
|
|
51
|
+
" Did you mean: #{matches.map(&:inspect).join(', ')}?"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :nothing_registered, :suggestion
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raised when a +$ref+ names a registered key but the path within it is absent.
|
|
58
|
+
class RefNotFoundError < Error; end
|
|
59
|
+
|
|
60
|
+
# Raised when a +$ref+ value is not a supported ref form.
|
|
61
|
+
class InvalidRefError < Error; end
|
|
62
|
+
|
|
63
|
+
# Raised when a key passed to {Servus::Schema.register} cannot be referenced.
|
|
64
|
+
class InvalidKeyError < Error; end
|
|
65
|
+
|
|
66
|
+
# Raised when refs form a cycle.
|
|
67
|
+
class CircularReferenceError < Error; end
|
|
68
|
+
|
|
69
|
+
# Raised when a schema nests more deeply than {Servus::Schema::Compiler::MAX_DEPTH}.
|
|
70
|
+
#
|
|
71
|
+
# Distinct from {CircularReferenceError} on purpose: a deep but acyclic
|
|
72
|
+
# schema is a different problem from a cycle, and conflating them is what
|
|
73
|
+
# makes depth-counter-only implementations reject valid schemas.
|
|
74
|
+
class DepthExceededError < Error; end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Schema
|
|
5
|
+
# Walks a path of literal keys into a registered schema fragment.
|
|
6
|
+
#
|
|
7
|
+
# This is the single addressing implementation behind both
|
|
8
|
+
# {Servus::Schema.fetch} and +$ref+ resolution, so a path that misses reads
|
|
9
|
+
# the same whether application code asked for it directly or a ref led there.
|
|
10
|
+
#
|
|
11
|
+
# Segments are literal hash keys, not JSON Pointer tokens — there is no
|
|
12
|
+
# +~0+/+~1+ unescaping and no array indexing.
|
|
13
|
+
#
|
|
14
|
+
# @see Servus::Schema.fetch
|
|
15
|
+
# @see Servus::Schema::Ref
|
|
16
|
+
# @api private
|
|
17
|
+
module Path
|
|
18
|
+
class << self
|
|
19
|
+
# Walks +path+ into +fragment+.
|
|
20
|
+
#
|
|
21
|
+
# @param fragment [Hash] the registered fragment
|
|
22
|
+
# @param key [String] the fragment key, for the error message
|
|
23
|
+
# @param path [Array<String>] segments to walk
|
|
24
|
+
# @return [Object] the value at the path, or the fragment if path is empty
|
|
25
|
+
# @raise [RefNotFoundError] if a segment is not present
|
|
26
|
+
def walk(fragment, key, path)
|
|
27
|
+
path.reduce(fragment) do |current, segment|
|
|
28
|
+
unless current.is_a?(Hash) && current.key?(segment)
|
|
29
|
+
raise RefNotFoundError, message_for(key, path, segment, current)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
current[segment]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# @param key [String] the fragment key
|
|
39
|
+
# @param path [Array<String>] the full path being walked
|
|
40
|
+
# @param segment [String] the segment that was not found
|
|
41
|
+
# @param current [Object] the node the walk failed at
|
|
42
|
+
# @return [String]
|
|
43
|
+
# @api private
|
|
44
|
+
def message_for(key, path, segment, current)
|
|
45
|
+
"#{path.join('/').inspect} could not be resolved in schema fragment #{key.inspect}: " \
|
|
46
|
+
"#{segment.inspect} is not present.#{available_in(current)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param current [Object] the node the walk failed at
|
|
50
|
+
# @return [String] a clause describing what was there instead
|
|
51
|
+
# @api private
|
|
52
|
+
def available_in(current)
|
|
53
|
+
return " #{current.class} is not a Hash, so it has no keys to walk into." unless current.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
" Available keys: #{current.keys.map(&:to_s).sort.map(&:inspect).join(', ')}."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Schema
|
|
5
|
+
# A parsed +$ref+ pointing at a registered schema fragment.
|
|
6
|
+
#
|
|
7
|
+
# Servus supports exactly two ref forms:
|
|
8
|
+
#
|
|
9
|
+
# #/core # the whole fragment registered as "core"
|
|
10
|
+
# #/core/$defs/amount # a path walked within it
|
|
11
|
+
#
|
|
12
|
+
# Segments are literal hash keys, not JSON Pointer tokens — there is no
|
|
13
|
+
# +~0+/+~1+ unescaping and no array indexing. +$defs+ carries no special
|
|
14
|
+
# meaning; it is a conventional place to put definitions, and any key works.
|
|
15
|
+
#
|
|
16
|
+
# Everything else is rejected by {parse} with a message that names what was
|
|
17
|
+
# wrong. That matters most for local refs: +#/$defs/amount+ would otherwise
|
|
18
|
+
# parse as a request for a fragment registered under the key +$defs+ and
|
|
19
|
+
# fail as a confusing lookup miss rather than as the unsupported form it is.
|
|
20
|
+
#
|
|
21
|
+
# @see Servus::Schema::Compiler
|
|
22
|
+
class Ref
|
|
23
|
+
# Ref forms Servus does not implement, paired with the reason.
|
|
24
|
+
#
|
|
25
|
+
# Checked in order; the first match raises {InvalidRefError}.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
REJECTIONS = [
|
|
29
|
+
[
|
|
30
|
+
->(value) { !value.start_with?('#/') },
|
|
31
|
+
'Servus resolves refs against registered schema fragments, which always take the form ' \
|
|
32
|
+
'"#/<key>" or "#/<key>/<path>". Remote and file refs are not supported.'
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
->(value) { value == '#/' },
|
|
36
|
+
'it names no schema fragment key.'
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
->(value) { value.delete_prefix('#/').start_with?('$') },
|
|
40
|
+
'it looks like a local ref. Refs resolve against registered fragments, not against the ' \
|
|
41
|
+
'enclosing document. Register the shared definition as a fragment and reference it as ' \
|
|
42
|
+
'"#/<key>/...".'
|
|
43
|
+
]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# The original ref string.
|
|
47
|
+
#
|
|
48
|
+
# @return [String]
|
|
49
|
+
attr_reader :value
|
|
50
|
+
|
|
51
|
+
# The registry key the ref names.
|
|
52
|
+
#
|
|
53
|
+
# @return [String]
|
|
54
|
+
attr_reader :key
|
|
55
|
+
|
|
56
|
+
# Path segments to walk within the fragment. Empty for a whole-fragment ref.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<String>]
|
|
59
|
+
attr_reader :segments
|
|
60
|
+
|
|
61
|
+
# Parses a +$ref+ value.
|
|
62
|
+
#
|
|
63
|
+
# @param value [Object] the raw +$ref+ value from a schema
|
|
64
|
+
# @return [Ref]
|
|
65
|
+
# @raise [InvalidRefError] if the value is not a supported ref form
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# Servus::Schema::Ref.parse('#/core/$defs/amount').key # => "core"
|
|
69
|
+
def self.parse(value)
|
|
70
|
+
raise InvalidRefError, "$ref must be a String, got #{value.class}: #{value.inspect}" unless value.is_a?(String)
|
|
71
|
+
|
|
72
|
+
_, explanation = REJECTIONS.find { |rejects, _| rejects.call(value) }
|
|
73
|
+
|
|
74
|
+
raise InvalidRefError, "#{value.inspect} is not a supported $ref — #{explanation}" if explanation
|
|
75
|
+
|
|
76
|
+
new(value)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param value [String] a ref string already known to be well-formed
|
|
80
|
+
# @api private
|
|
81
|
+
def initialize(value)
|
|
82
|
+
@value = value
|
|
83
|
+
@key, *@segments = value.delete_prefix('#/').split('/')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|