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.
@@ -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,8 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module StructuredParams
5
+ module OpenAPI
6
+ VERSION = '0.1.0' #: String
7
+ end
8
+ 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