structured_params-openapi 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 +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +34 -0
- data/README_ja.md +34 -0
- data/docs/drift-check.md +379 -0
- data/docs/drift-check_ja.md +379 -0
- data/lib/structured_params/openapi/configuration.rb +131 -0
- data/lib/structured_params/openapi/drift_check/issue.rb +16 -0
- data/lib/structured_params/openapi/drift_check/result.rb +61 -0
- data/lib/structured_params/openapi/drift_check.rb +531 -0
- data/lib/structured_params/openapi/registry.rb +92 -0
- data/lib/structured_params/openapi/version.rb +8 -0
- data/lib/structured_params/openapi.rb +28 -0
- data/lib/tasks/structured_params_openapi.rake +15 -0
- data/sig/structured_params/openapi/configuration.rbs +74 -0
- data/sig/structured_params/openapi/drift_check/issue.rbs +19 -0
- data/sig/structured_params/openapi/drift_check/result.rbs +40 -0
- data/sig/structured_params/openapi/drift_check.rbs +201 -0
- data/sig/structured_params/openapi/registry.rbs +46 -0
- data/sig/structured_params/openapi/version.rbs +7 -0
- data/sig/structured_params/openapi.rbs +14 -0
- metadata +82 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require_relative 'drift_check/issue'
|
|
7
|
+
require_relative 'drift_check/result'
|
|
8
|
+
|
|
9
|
+
module StructuredParams
|
|
10
|
+
# Schema comparison types for OpenAPI drift checks.
|
|
11
|
+
module OpenAPI
|
|
12
|
+
# Compares OpenAPI object schemas with StructuredParams declarations.
|
|
13
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
14
|
+
class DriftCheck
|
|
15
|
+
# @rbs @openapi_source: Hash[untyped, untyped]|String?
|
|
16
|
+
# @rbs @schema_source: Hash[untyped, untyped]|String?
|
|
17
|
+
# @rbs @parameter_class: singleton(::StructuredParams::Params)
|
|
18
|
+
# @rbs @pointer: String?
|
|
19
|
+
# @rbs @property_names: Hash[Symbol, String]
|
|
20
|
+
# @rbs @required_validation: :presence|:none
|
|
21
|
+
# @rbs @document: Hash[untyped, untyped]?
|
|
22
|
+
# @rbs @errors: Array[Issue]
|
|
23
|
+
# @rbs @skipped: Array[Issue]
|
|
24
|
+
# @rbs @ignored: Array[Issue]
|
|
25
|
+
# @rbs @active_pairs: Array[Array[untyped]]
|
|
26
|
+
|
|
27
|
+
# Finding kinds that can be raised as errors (and therefore matched by +ignore_drift+).
|
|
28
|
+
ERROR_KINDS = %i[
|
|
29
|
+
missing_attribute unexpected_attribute missing_presence_validation type_mismatch
|
|
30
|
+
missing_schema unresolved_reference
|
|
31
|
+
].freeze #: Array[Symbol]
|
|
32
|
+
|
|
33
|
+
# Options that stop a presence validator from enforcing presence on every validation.
|
|
34
|
+
CONDITIONAL_PRESENCE_OPTIONS = %i[if unless on allow_nil allow_blank].freeze #: Array[Symbol]
|
|
35
|
+
|
|
36
|
+
# Schema types that a +oneOf+/+anyOf+ of plain scalars may offer.
|
|
37
|
+
SCALAR_SCHEMA_TYPES = %i[string integer number boolean].freeze #: Array[Symbol]
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# rubocop:disable Metrics/ParameterLists
|
|
41
|
+
#: (**untyped) -> Result
|
|
42
|
+
def call(
|
|
43
|
+
parameter_class:,
|
|
44
|
+
openapi: nil,
|
|
45
|
+
schema: nil,
|
|
46
|
+
pointer: nil,
|
|
47
|
+
property_names: {},
|
|
48
|
+
required_validation: :presence
|
|
49
|
+
)
|
|
50
|
+
new(
|
|
51
|
+
openapi: openapi,
|
|
52
|
+
schema: schema,
|
|
53
|
+
parameter_class: parameter_class,
|
|
54
|
+
pointer: pointer,
|
|
55
|
+
property_names: property_names,
|
|
56
|
+
required_validation: required_validation
|
|
57
|
+
).call
|
|
58
|
+
end
|
|
59
|
+
# rubocop:enable Metrics/ParameterLists
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# rubocop:disable Metrics/ParameterLists
|
|
63
|
+
#: (**untyped) -> void
|
|
64
|
+
def initialize(
|
|
65
|
+
parameter_class:,
|
|
66
|
+
openapi: nil,
|
|
67
|
+
schema: nil,
|
|
68
|
+
pointer: nil,
|
|
69
|
+
property_names: {},
|
|
70
|
+
required_validation: :presence
|
|
71
|
+
)
|
|
72
|
+
@openapi_source = openapi || OpenAPI.configuration.document_for(parameter_class)
|
|
73
|
+
@schema_source = schema
|
|
74
|
+
@parameter_class = parameter_class
|
|
75
|
+
@pointer = pointer
|
|
76
|
+
@property_names = property_names.transform_keys(&:to_sym).transform_values(&:to_s)
|
|
77
|
+
@required_validation = required_validation
|
|
78
|
+
@errors = []
|
|
79
|
+
@skipped = []
|
|
80
|
+
@ignored = []
|
|
81
|
+
@active_pairs = [] #: Array[Array[untyped]]
|
|
82
|
+
|
|
83
|
+
unless %i[presence none].include?(required_validation)
|
|
84
|
+
raise ArgumentError, "required_validation must be :presence or :none, got #{required_validation.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return if @schema_source.is_a?(Hash) || @openapi_source
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, 'configure an OpenAPI document or pass schema as a Hash'
|
|
90
|
+
end
|
|
91
|
+
# rubocop:enable Metrics/ParameterLists
|
|
92
|
+
|
|
93
|
+
#: () -> Result
|
|
94
|
+
def call
|
|
95
|
+
@errors = []
|
|
96
|
+
@skipped = []
|
|
97
|
+
@ignored = []
|
|
98
|
+
@active_pairs = []
|
|
99
|
+
|
|
100
|
+
schema = load_root_schema
|
|
101
|
+
check_object(resolve_references(schema, ''), @parameter_class, '') if schema
|
|
102
|
+
Result.new(@parameter_class, @errors.dup.freeze, @skipped.dup.freeze, @ignored.dup.freeze)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# A missing or mistyped schema means the class silently lost its contract coverage,
|
|
108
|
+
# so it is an error (suppressible with +ignore_drift+) rather than a skip.
|
|
109
|
+
#: () -> Hash[untyped, untyped]?
|
|
110
|
+
def load_root_schema
|
|
111
|
+
schema, @document = load_schema
|
|
112
|
+
return schema if schema.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
report_missing_schema('the selected schema is not an object')
|
|
115
|
+
rescue IndexError => e # also covers KeyError
|
|
116
|
+
report_missing_schema(e.message)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (String) -> nil
|
|
120
|
+
def report_missing_schema(detail)
|
|
121
|
+
error('(root)', :missing_schema,
|
|
122
|
+
"no matching OpenAPI schema found (#{detail}); " \
|
|
123
|
+
'check the schema_name_resolver convention or register an explicit schema name')
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: () -> [Hash[untyped, untyped], Hash[untyped, untyped]]
|
|
128
|
+
def load_schema
|
|
129
|
+
if @schema_source.is_a?(Hash)
|
|
130
|
+
return [@schema_source, @schema_source] if selected_pointer.nil?
|
|
131
|
+
|
|
132
|
+
return [resolve_pointer(@schema_source, selected_pointer), @schema_source]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
document = load_document
|
|
136
|
+
pointer = selected_pointer || OpenAPI.configuration.pointer_for(@parameter_class, @schema_source)
|
|
137
|
+
[resolve_pointer(document, pointer), document]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: () -> Hash[untyped, untyped]
|
|
141
|
+
def load_document
|
|
142
|
+
return @openapi_source if @openapi_source.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
OpenAPI.configuration.load_document(@openapi_source)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
#: () -> String?
|
|
148
|
+
def selected_pointer
|
|
149
|
+
return @pointer unless @pointer.nil? || @pointer.empty?
|
|
150
|
+
return unless @schema_source.is_a?(String)
|
|
151
|
+
|
|
152
|
+
OpenAPI.configuration.pointer_for(@parameter_class, @schema_source)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: (Hash[untyped, untyped], singleton(StructuredParams::Params), String) -> void
|
|
156
|
+
def check_object(schema, parameter_class, path)
|
|
157
|
+
return if unresolved_reference?(schema) || skip_union?(schema, path)
|
|
158
|
+
|
|
159
|
+
# Only ancestors are tracked (compared by structural equality), so a schema reused by
|
|
160
|
+
# sibling properties is still compared each time while a self-referencing schema
|
|
161
|
+
# stops silently at its second visit.
|
|
162
|
+
pair = [schema, parameter_class]
|
|
163
|
+
return if @active_pairs.include?(pair)
|
|
164
|
+
|
|
165
|
+
@active_pairs.push(pair)
|
|
166
|
+
begin
|
|
167
|
+
check_object_properties(schema, parameter_class, path)
|
|
168
|
+
ensure
|
|
169
|
+
@active_pairs.pop
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
#: (Hash[untyped, untyped], singleton(StructuredParams::Params), String) -> void
|
|
174
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
175
|
+
def check_object_properties(schema, parameter_class, path)
|
|
176
|
+
properties = value_for(schema, 'properties')
|
|
177
|
+
unless properties.is_a?(Hash)
|
|
178
|
+
skip(path, 'schema has no properties; only object schemas are supported')
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
schema_attributes = properties.keys.to_h { |name| [attribute_name(name), name.to_s] }
|
|
183
|
+
attribute_types = parameter_class.attribute_types
|
|
184
|
+
compare_attribute_names(schema_attributes, attribute_types, path)
|
|
185
|
+
compare_required_attributes(schema, schema_attributes, parameter_class, path)
|
|
186
|
+
|
|
187
|
+
(schema_attributes.keys & attribute_types.keys.map(&:to_sym)).each do |attribute|
|
|
188
|
+
compare_property(value_for(properties, schema_attributes.fetch(attribute)),
|
|
189
|
+
attribute_types.fetch(attribute.to_s), join_path(path, attribute))
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
#: (untyped, ActiveModel::Type::Value, String) -> void
|
|
194
|
+
def compare_property(property_schema, attribute_type, path)
|
|
195
|
+
unless property_schema.is_a?(Hash)
|
|
196
|
+
skip(path, 'property schema is not an object')
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
compare_type(resolve_references(property_schema, path), attribute_type, path)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
#: (Hash[Symbol, String], Hash[String, ActiveModel::Type::Value], String) -> void
|
|
204
|
+
def compare_attribute_names(schema_attributes, attribute_types, path)
|
|
205
|
+
schema_attributes.each do |attribute, property|
|
|
206
|
+
next if attribute_types.key?(attribute.to_s)
|
|
207
|
+
|
|
208
|
+
error(join_path(path, property), :missing_attribute, 'schema property has no matching attribute declaration')
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
attribute_types.each_key do |attribute|
|
|
212
|
+
next if schema_attributes.key?(attribute.to_sym)
|
|
213
|
+
|
|
214
|
+
error(join_path(path, attribute), :unexpected_attribute,
|
|
215
|
+
'attribute declaration has no matching schema property')
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
#: (Hash[untyped, untyped], Hash[Symbol, String], singleton(StructuredParams::Params), String) -> void
|
|
220
|
+
def compare_required_attributes(schema, schema_attributes, parameter_class, path)
|
|
221
|
+
return if @required_validation == :none
|
|
222
|
+
|
|
223
|
+
Array(value_for(schema, 'required')).each do |property|
|
|
224
|
+
attribute = attribute_name(property)
|
|
225
|
+
next unless schema_attributes.key?(attribute)
|
|
226
|
+
next if presence_validator?(parameter_class, attribute)
|
|
227
|
+
next if nullable_property?(schema, property, join_path(path, property))
|
|
228
|
+
|
|
229
|
+
error(join_path(path, property), :missing_presence_validation,
|
|
230
|
+
'required schema property has no unconditional presence validation')
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A required property that the schema allows to be null must not demand +presence+,
|
|
235
|
+
# which would reject the null the contract permits.
|
|
236
|
+
#: (Hash[untyped, untyped], String, String) -> bool
|
|
237
|
+
def nullable_property?(schema, property, path)
|
|
238
|
+
property_schema = value_for(value_for(schema, 'properties') || {}, property)
|
|
239
|
+
return false unless property_schema.is_a?(Hash)
|
|
240
|
+
|
|
241
|
+
resolved = without_reporting { resolve_references(property_schema, path) }
|
|
242
|
+
nullable_schema?(resolved)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
#: (Hash[untyped, untyped]) -> bool
|
|
246
|
+
def nullable_schema?(schema)
|
|
247
|
+
return true if value_for(schema, 'nullable') == true
|
|
248
|
+
return true if null_type?(value_for(schema, 'type'))
|
|
249
|
+
|
|
250
|
+
%w[oneOf anyOf].any? do |key|
|
|
251
|
+
Array(value_for(schema, key)).any? { |option| option.is_a?(Hash) && nullable_schema?(option) }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
#: (untyped) -> bool
|
|
256
|
+
def null_type?(type)
|
|
257
|
+
type == 'null' || (type.is_a?(Array) && type.include?('null'))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Runs a lookup whose findings are reported elsewhere, so they are not recorded twice.
|
|
261
|
+
#: [T] () { () -> T } -> T
|
|
262
|
+
def without_reporting
|
|
263
|
+
sizes = [@errors.size, @skipped.size, @ignored.size]
|
|
264
|
+
yield
|
|
265
|
+
ensure
|
|
266
|
+
@errors.slice!(sizes[0]..)
|
|
267
|
+
@skipped.slice!(sizes[1]..)
|
|
268
|
+
@ignored.slice!(sizes[2]..)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
272
|
+
#: (Hash[untyped, untyped], ActiveModel::Type::Value, String) -> void
|
|
273
|
+
def compare_type(schema, attribute_type, path)
|
|
274
|
+
return if unresolved_reference?(schema)
|
|
275
|
+
|
|
276
|
+
scalar_types = scalar_union_types(schema, path)
|
|
277
|
+
return compare_scalar_union(scalar_types, attribute_type, path) if scalar_types
|
|
278
|
+
return if skip_union?(schema, path)
|
|
279
|
+
|
|
280
|
+
if value_for(schema, 'type').is_a?(Array)
|
|
281
|
+
skip(path, 'multiple schema types are outside the supported drift-check scope')
|
|
282
|
+
return
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
expected_type = schema_type(schema)
|
|
286
|
+
actual_type = attribute_type.type
|
|
287
|
+
unless compatible_types?(expected_type, actual_type)
|
|
288
|
+
error(path, :type_mismatch,
|
|
289
|
+
"schema type #{expected_type.inspect} does not match attribute type #{actual_type.inspect}")
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
if expected_type == :object && attribute_type.is_a?(Type::Object)
|
|
294
|
+
check_object(schema, attribute_type.value_class, path)
|
|
295
|
+
elsif expected_type == :array && attribute_type.is_a?(Type::Array)
|
|
296
|
+
compare_array_item_type(schema, attribute_type, path)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
300
|
+
|
|
301
|
+
#: (Hash[untyped, untyped], Type::Array, String) -> void
|
|
302
|
+
def compare_array_item_type(schema, attribute_type, path)
|
|
303
|
+
items = value_for(schema, 'items')
|
|
304
|
+
unless items.is_a?(Hash)
|
|
305
|
+
skip(path, 'array schema has no items declaration')
|
|
306
|
+
return
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
compare_type(resolve_references(items, "#{path}[]"), attribute_type.item_type, "#{path}[]")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
#: (Hash[untyped, untyped]) -> Symbol?
|
|
313
|
+
def schema_type(schema)
|
|
314
|
+
type = value_for(schema, 'type')
|
|
315
|
+
return type.to_sym if type
|
|
316
|
+
return :object if value_for(schema, 'properties').is_a?(Hash)
|
|
317
|
+
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
#: (Symbol?, Symbol) -> bool
|
|
322
|
+
def compatible_types?(schema_type, attribute_type)
|
|
323
|
+
return false if schema_type.nil?
|
|
324
|
+
return %i[float decimal].include?(attribute_type) if schema_type == :number
|
|
325
|
+
return %i[date datetime time string].include?(attribute_type) if schema_type == :string
|
|
326
|
+
return %i[integer big_integer].include?(attribute_type) if schema_type == :integer
|
|
327
|
+
|
|
328
|
+
schema_type == attribute_type
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
#: (singleton(StructuredParams::Params), Symbol) -> bool
|
|
332
|
+
def presence_validator?(parameter_class, attribute)
|
|
333
|
+
parameter_class._validators.fetch(attribute.to_sym, []).any? do |validator|
|
|
334
|
+
validator.kind == :presence && validator.options.slice(*CONDITIONAL_PRESENCE_OPTIONS).empty?
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A +oneOf+/+anyOf+ of plain scalars matches when the attribute fits any of them.
|
|
339
|
+
#: (Array[Symbol], ActiveModel::Type::Value, String) -> void
|
|
340
|
+
def compare_scalar_union(types, attribute_type, path)
|
|
341
|
+
actual_type = attribute_type.type
|
|
342
|
+
return if types.any? { |type| compatible_types?(type, actual_type) }
|
|
343
|
+
|
|
344
|
+
error(path, :type_mismatch, "schema types #{types.inspect} do not match attribute type #{actual_type.inspect}")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# The scalar types offered by a +oneOf+/+anyOf+, or nil when it is not a plain scalar union.
|
|
348
|
+
#: (Hash[untyped, untyped], String) -> Array[Symbol]?
|
|
349
|
+
def scalar_union_types(schema, path)
|
|
350
|
+
key = union_key(schema)
|
|
351
|
+
return unless key
|
|
352
|
+
|
|
353
|
+
options = value_for(schema, key).reject { |option| null_option?(option) }
|
|
354
|
+
types = options.map { |option| scalar_option_type(option, path) }
|
|
355
|
+
types if types.size > 1 && types.all? { |type| SCALAR_SCHEMA_TYPES.include?(type) }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
#: (untyped, String) -> Symbol?
|
|
359
|
+
def scalar_option_type(option, path)
|
|
360
|
+
return unless option.is_a?(Hash)
|
|
361
|
+
|
|
362
|
+
# Not silenced: a dangling $ref in an option must surface as :unresolved_reference.
|
|
363
|
+
resolved = resolve_references(option, path)
|
|
364
|
+
schema_type(resolved) unless value_for(resolved, 'properties')
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
#: (Hash[untyped, untyped]) -> String?
|
|
368
|
+
def union_key(schema)
|
|
369
|
+
%w[oneOf anyOf].find { |name| value_for(schema, name).is_a?(Array) }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# +oneOf: [X, {type: null}]+ and +type: [T, 'null']+ are how OpenAPI 3.1 says "X or null".
|
|
373
|
+
# Collapse them to X and mark the result +nullable+, so they are compared like a 3.0
|
|
374
|
+
# +nullable: true+ schema instead of being treated as unsupported unions.
|
|
375
|
+
#: (Hash[untyped, untyped]) -> Hash[untyped, untyped]
|
|
376
|
+
def collapse_nullable(schema)
|
|
377
|
+
collapse_null_type(schema) || collapse_null_union(schema) || schema
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
#: (Hash[untyped, untyped]) -> Hash[untyped, untyped]?
|
|
381
|
+
def collapse_null_type(schema)
|
|
382
|
+
type = value_for(schema, 'type')
|
|
383
|
+
return unless type.is_a?(Array) && type.include?('null')
|
|
384
|
+
|
|
385
|
+
others = type - ['null']
|
|
386
|
+
schema.merge('type' => others.first, 'nullable' => true) if others.size == 1
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
#: (Hash[untyped, untyped]) -> Hash[untyped, untyped]?
|
|
390
|
+
def collapse_null_union(schema)
|
|
391
|
+
key = union_key(schema)
|
|
392
|
+
return unless key
|
|
393
|
+
|
|
394
|
+
options = value_for(schema, key)
|
|
395
|
+
present = options.reject { |option| null_option?(option) }
|
|
396
|
+
return unless present.size == 1 && present.size < options.size
|
|
397
|
+
|
|
398
|
+
nullable_option(present.first, schema.reject { |name, _| name.to_s == key })
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
#: (untyped, Hash[untyped, untyped]) -> Hash[untyped, untyped]?
|
|
402
|
+
def nullable_option(option, siblings)
|
|
403
|
+
option.merge(siblings).merge('nullable' => true) if option.is_a?(Hash)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
#: (untyped) -> bool
|
|
407
|
+
def null_option?(option)
|
|
408
|
+
option.is_a?(Hash) && null_type?(value_for(option, 'type')) && !value_for(option, 'type').is_a?(Array)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
#: (Hash[untyped, untyped], String) -> bool
|
|
412
|
+
def skip_union?(schema, path)
|
|
413
|
+
union_key = %w[oneOf anyOf allOf].find { |key| value_for(schema, key) }
|
|
414
|
+
return false unless union_key
|
|
415
|
+
|
|
416
|
+
skip(path, "#{union_key} is outside the supported drift-check scope")
|
|
417
|
+
true
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
#: (Hash[untyped, untyped], String, ?Array[String]) -> Hash[untyped, untyped]
|
|
421
|
+
def resolve_references(schema, path, followed = [])
|
|
422
|
+
schema = collapse_nullable(unwrap_single_all_of(schema))
|
|
423
|
+
reference = value_for(schema, '$ref')
|
|
424
|
+
return schema unless reference
|
|
425
|
+
|
|
426
|
+
unless reference.start_with?('#/')
|
|
427
|
+
skip(path, "external reference #{reference.inspect} is outside the supported drift-check scope")
|
|
428
|
+
return schema
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
referenced_schema = fetch_reference(reference, path, followed)
|
|
432
|
+
return schema unless referenced_schema
|
|
433
|
+
|
|
434
|
+
overrides = schema.reject { |key, _| key.to_s == '$ref' }
|
|
435
|
+
resolve_references(referenced_schema.merge(overrides), path, followed + [reference])
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# A dangling or circular local reference is a broken contract, not an unsupported feature.
|
|
439
|
+
#: (String, String, Array[String]) -> Hash[untyped, untyped]?
|
|
440
|
+
def fetch_reference(reference, path, followed)
|
|
441
|
+
return unresolved_reference(path, reference, 'it refers back to itself') if followed.include?(reference)
|
|
442
|
+
|
|
443
|
+
target = resolve_pointer(@document, reference.delete_prefix('#'))
|
|
444
|
+
return target if target.is_a?(Hash)
|
|
445
|
+
|
|
446
|
+
unresolved_reference(path, reference, 'it does not point to a schema object')
|
|
447
|
+
rescue IndexError => e # also covers KeyError
|
|
448
|
+
unresolved_reference(path, reference, e.message)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
#: (String, String, String) -> nil
|
|
452
|
+
def unresolved_reference(path, reference, detail)
|
|
453
|
+
error(display_path(path), :unresolved_reference,
|
|
454
|
+
"reference #{reference.inspect} could not be resolved (#{detail})")
|
|
455
|
+
nil
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# A `$ref` left after resolve_references was already reported (skipped if external, an
|
|
459
|
+
# error if unresolvable), so comparing it further would only add misleading findings.
|
|
460
|
+
#: (Hash[untyped, untyped]) -> bool
|
|
461
|
+
def unresolved_reference?(schema)
|
|
462
|
+
!value_for(schema, '$ref').nil?
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# OpenAPI/JSON Schema forbid sibling keys next to `$ref`, so generators such as TypeSpec
|
|
466
|
+
# commonly wrap a single `$ref` in `allOf` to attach a `description`, `default`, etc.
|
|
467
|
+
# That's equivalent to the ref carrying those siblings directly, so unwrap and merge a
|
|
468
|
+
# single-entry `allOf` (typically a `$ref`, though an inline schema is unwrapped the same
|
|
469
|
+
# way) instead of treating it as unsupported schema composition. `allOf` with more than
|
|
470
|
+
# one entry is genuine composition and stays out of scope (reported via skip_union?).
|
|
471
|
+
#: (Hash[untyped, untyped]) -> Hash[untyped, untyped]
|
|
472
|
+
def unwrap_single_all_of(schema)
|
|
473
|
+
all_of = value_for(schema, 'allOf')
|
|
474
|
+
return schema unless all_of.is_a?(Array) && all_of.size == 1
|
|
475
|
+
|
|
476
|
+
overrides = schema.reject { |key, _| key.to_s == 'allOf' }
|
|
477
|
+
all_of.first.merge(overrides)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
#: (untyped, String) -> untyped
|
|
481
|
+
def resolve_pointer(document, pointer)
|
|
482
|
+
pointer.delete_prefix('#').delete_prefix('/').split('/').reduce(document) do |value, segment|
|
|
483
|
+
dig_pointer_segment(value, segment.gsub('~1', '/').gsub('~0', '~'))
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
#: (untyped, String) -> untyped
|
|
488
|
+
def dig_pointer_segment(value, segment)
|
|
489
|
+
case value
|
|
490
|
+
when Hash then value.fetch(segment)
|
|
491
|
+
when Array then value.fetch(Integer(segment))
|
|
492
|
+
else raise KeyError, "key not found: #{segment.inspect}"
|
|
493
|
+
end
|
|
494
|
+
rescue ArgumentError
|
|
495
|
+
raise KeyError, "key not found: #{segment.inspect}"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
#: (Hash[untyped, untyped], String) -> untyped
|
|
499
|
+
def value_for(hash, key)
|
|
500
|
+
hash[key] || hash[key.to_sym]
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
#: (untyped) -> Symbol
|
|
504
|
+
def attribute_name(property)
|
|
505
|
+
@property_names.invert.fetch(property.to_s, property).to_sym
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
#: (String, String) -> String
|
|
509
|
+
def join_path(path, name)
|
|
510
|
+
path.empty? ? name.to_s : "#{path}.#{name}"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
#: (String, Symbol, String) -> void
|
|
514
|
+
def error(path, kind, message)
|
|
515
|
+
issue = Issue.new(kind: kind, path: path, message: message)
|
|
516
|
+
target = OpenAPI.configuration.ignored?(kind, path, @parameter_class) ? @ignored : @errors
|
|
517
|
+
target << issue
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
#: (String) -> String
|
|
521
|
+
def display_path(path)
|
|
522
|
+
path.empty? ? '(root)' : path
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
#: (String, String) -> void
|
|
526
|
+
def skip(path, message)
|
|
527
|
+
@skipped << Issue.new(kind: :unsupported, path: display_path(path), message: message)
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module StructuredParams
|
|
5
|
+
# OpenAPI contract registration and bulk execution utilities.
|
|
6
|
+
module OpenAPI
|
|
7
|
+
# Raised by Registry.check_all! when one or more registered contracts drift.
|
|
8
|
+
class DriftError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Collects exceptional mappings and runs all configured drift checks.
|
|
11
|
+
class Registry
|
|
12
|
+
class << self
|
|
13
|
+
# @rbs self.@entries: Array[Array[untyped]]?
|
|
14
|
+
|
|
15
|
+
# +parameter_class+ may be a class name String (e.g. from a Rails initializer,
|
|
16
|
+
# before Zeitwerk autoloading is set up); it is resolved lazily in +check_all+.
|
|
17
|
+
# +openapi+ (a file path or Hash) overrides the configured document for this class, so
|
|
18
|
+
# one app can check classes against several OpenAPI files.
|
|
19
|
+
#: (singleton(StructuredParams::Params) | String, **untyped) -> void
|
|
20
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
21
|
+
def register(parameter_class, openapi: nil, schema: nil, pointer: nil, property_names: {},
|
|
22
|
+
required_validation: :presence)
|
|
23
|
+
entries << [
|
|
24
|
+
parameter_class,
|
|
25
|
+
{
|
|
26
|
+
openapi: openapi,
|
|
27
|
+
schema: schema,
|
|
28
|
+
pointer: pointer,
|
|
29
|
+
property_names: property_names,
|
|
30
|
+
required_validation: required_validation
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
#: (?auto_discover: bool) -> Array[DriftCheck::Result]
|
|
36
|
+
def check_all(auto_discover: false)
|
|
37
|
+
checks = auto_discover ? auto_discovered_checks : {}
|
|
38
|
+
entries.each { |parameter_class, options| checks[resolve(parameter_class)] = options }
|
|
39
|
+
checks.map { |parameter_class, options| DriftCheck.call(parameter_class: parameter_class, **options) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# With +strict+, a check that skipped part of a schema also fails the run.
|
|
43
|
+
#: (?auto_discover: bool, ?strict: bool) -> Array[DriftCheck::Result]
|
|
44
|
+
def check_all!(auto_discover: false, strict: false)
|
|
45
|
+
results = check_all(auto_discover: auto_discover)
|
|
46
|
+
failing = results.reject { |result| result.valid? && (!strict || result.skipped.empty?) }
|
|
47
|
+
errors = failing.map(&:to_message)
|
|
48
|
+
# Leading blank line so the first class's message doesn't get glued onto the
|
|
49
|
+
# "ClassName: " prefix Ruby/Rake print ahead of an exception's message.
|
|
50
|
+
raise DriftError, "\n\n#{errors.join("\n\n")}" unless errors.empty?
|
|
51
|
+
|
|
52
|
+
results
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Describes what passing checks skipped or ignored, plus a one-line summary.
|
|
56
|
+
#: (Array[DriftCheck::Result]) -> String
|
|
57
|
+
def report(results)
|
|
58
|
+
return 'No parameter classes were checked; set parameter_class_filter or register classes.' if results.empty?
|
|
59
|
+
|
|
60
|
+
skipped = results.count { |result| !result.skipped.empty? }
|
|
61
|
+
ignored = results.count { |result| !result.ignored.empty? }
|
|
62
|
+
sections = results.map(&:to_report).reject(&:empty?)
|
|
63
|
+
sections << "Checked #{results.size} classes: #{skipped} with skipped checks, " \
|
|
64
|
+
"#{ignored} with ignored findings."
|
|
65
|
+
sections.join("\n\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: () -> void
|
|
69
|
+
def clear
|
|
70
|
+
@entries = []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
#: () -> Hash[singleton(StructuredParams::Params), Hash[untyped, untyped]]
|
|
76
|
+
def auto_discovered_checks
|
|
77
|
+
OpenAPI.configuration.parameter_classes.to_h { |parameter_class| [parameter_class, {}] }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: () -> Array[Array[untyped]]
|
|
81
|
+
def entries
|
|
82
|
+
@entries ||= []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (singleton(StructuredParams::Params) | String) -> singleton(StructuredParams::Params)
|
|
86
|
+
def resolve(parameter_class)
|
|
87
|
+
parameter_class.is_a?(String) ? Object.const_get(parameter_class) : parameter_class
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'structured_params'
|
|
5
|
+
require_relative 'openapi/version'
|
|
6
|
+
require_relative 'openapi/configuration'
|
|
7
|
+
require_relative 'openapi/drift_check'
|
|
8
|
+
require_relative 'openapi/registry'
|
|
9
|
+
|
|
10
|
+
module StructuredParams
|
|
11
|
+
# OpenAPI drift check for StructuredParams classes.
|
|
12
|
+
module OpenAPI
|
|
13
|
+
RAKE_TASK_PATH = File.expand_path('../tasks/structured_params_openapi.rake', __dir__) #: String
|
|
14
|
+
|
|
15
|
+
if defined?(Rails::Railtie)
|
|
16
|
+
# Defers loading the drift-check rake task until Rails actually loads rake tasks,
|
|
17
|
+
# so requiring this gem never depends on the Rake DSL being mixed into +main+
|
|
18
|
+
# (e.g. `rails runner`, `rails console`, app boot).
|
|
19
|
+
class Railtie < Rails::Railtie
|
|
20
|
+
rake_tasks do
|
|
21
|
+
load StructuredParams::OpenAPI::RAKE_TASK_PATH
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
elsif defined?(Rake::DSL)
|
|
25
|
+
load RAKE_TASK_PATH
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
namespace :structured_params do
|
|
5
|
+
namespace :openapi do
|
|
6
|
+
desc 'Check registered and auto-discovered StructuredParams classes against the OpenAPI document'
|
|
7
|
+
task drift_check: :environment do
|
|
8
|
+
Rails.application&.eager_load! if defined?(Rails) && Rails.respond_to?(:application)
|
|
9
|
+
registry = StructuredParams::OpenAPI::Registry
|
|
10
|
+
# Set STRUCTURED_PARAMS_STRICT=1 to fail the task when any check was skipped.
|
|
11
|
+
results = registry.check_all!(auto_discover: true, strict: ENV['STRUCTURED_PARAMS_STRICT'] == '1')
|
|
12
|
+
puts registry.report(results)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|