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.
@@ -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