grape-oas 1.2.0 → 1.4.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +6 -0
  4. data/lib/grape_oas/api_model/api.rb +4 -0
  5. data/lib/grape_oas/api_model/schema.rb +18 -3
  6. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +21 -0
  7. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +3 -6
  8. data/lib/grape_oas/api_model_builders/request.rb +21 -12
  9. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +7 -2
  10. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +15 -1
  11. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +34 -50
  12. data/lib/grape_oas/constants.rb +13 -0
  13. data/lib/grape_oas/doc_key_normalizer.rb +14 -0
  14. data/lib/grape_oas/exporter/oas2/parameter.rb +1 -0
  15. data/lib/grape_oas/exporter/oas2/schema.rb +53 -19
  16. data/lib/grape_oas/exporter/oas3/parameter.rb +5 -2
  17. data/lib/grape_oas/exporter/oas3/schema.rb +81 -46
  18. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
  19. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
  20. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
  21. data/lib/grape_oas/introspectors/entity_introspector.rb +5 -1
  22. data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
  23. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
  24. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +6 -30
  25. data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
  26. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
  27. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
  28. data/lib/grape_oas/introspectors/entity_introspector_support.rb +57 -0
  29. data/lib/grape_oas/range_utils.rb +87 -0
  30. data/lib/grape_oas/schema_constraints.rb +36 -0
  31. data/lib/grape_oas/type_resolvers/array_resolver.rb +3 -5
  32. data/lib/grape_oas/values_normalizer.rb +47 -0
  33. data/lib/grape_oas/version.rb +1 -1
  34. data/lib/grape_oas.rb +27 -0
  35. metadata +9 -2
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ # Shared helpers used by multiple classes within EntityIntrospectorSupport.
6
+ # These are module-level methods to avoid copy-pasting the same logic across
7
+ # ExposureProcessor, DiscriminatorHandler, and InheritanceBuilder.
8
+ module EntityIntrospectorSupport
9
+ # Returns the raw exposure list for an entity class.
10
+ # Reads root_exposures via internal Grape::Entity ivars, which is
11
+ # unavoidable given Grape does not expose a stable public API for this.
12
+ def self.exposures(entity_class)
13
+ return [] unless entity_class.respond_to?(:root_exposures)
14
+
15
+ root = entity_class.root_exposures
16
+ list = root.instance_variable_get(:@exposures) || []
17
+ Array(list)
18
+ rescue NoMethodError
19
+ []
20
+ end
21
+
22
+ # Resolves the canonical name for an entity class, preferring entity_name
23
+ # when defined on the class itself (via def self. or extend) and non-blank,
24
+ # falling back to the Ruby class name. Inherited entity_name is ignored to
25
+ # avoid collisions between parent and child schemas.
26
+ def self.resolve_canonical_name(entity_class)
27
+ if defines_own_entity_name?(entity_class)
28
+ name = entity_class.entity_name
29
+ name.is_a?(String) && !name.strip.empty? ? name : entity_class.name
30
+ else
31
+ entity_class.name
32
+ end
33
+ end
34
+
35
+ def self.defines_own_entity_name?(entity_class)
36
+ return false unless entity_class.respond_to?(:entity_name)
37
+
38
+ parent = find_parent_entity(entity_class)
39
+ return true if parent.nil? || !parent.respond_to?(:entity_name)
40
+
41
+ entity_class.method(:entity_name).owner !=
42
+ parent.method(:entity_name).owner
43
+ end
44
+ private_class_method :defines_own_entity_name?
45
+
46
+ # Finds the parent entity class if one exists in the Grape::Entity hierarchy.
47
+ def self.find_parent_entity(entity_class)
48
+ return nil unless defined?(Grape::Entity)
49
+
50
+ parent = entity_class.superclass
51
+ return nil unless parent && parent < Grape::Entity && parent != Grape::Entity
52
+
53
+ parent
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ # Converts Range values into OpenAPI-compatible representations.
5
+ class RangeUtils
6
+ NUMERIC_TYPES = [Constants::SchemaTypes::INTEGER, Constants::SchemaTypes::NUMBER].freeze
7
+
8
+ class << self
9
+ # Expands a non-numeric bounded Range to an enum array.
10
+ # Returns nil for numeric, unbounded, empty, or oversized ranges.
11
+ def expand_range_to_enum(range)
12
+ return nil if range.begin.nil? || range.end.nil?
13
+ return nil if range.begin.is_a?(Numeric) || range.end.is_a?(Numeric)
14
+
15
+ begin
16
+ array = range.first(Constants::MAX_ENUM_RANGE_SIZE + 1)
17
+ rescue TypeError
18
+ return nil
19
+ end
20
+
21
+ return nil if array.empty? || array.size > Constants::MAX_ENUM_RANGE_SIZE
22
+
23
+ array
24
+ end
25
+
26
+ # Writes numeric range constraints directly to any object with
27
+ # minimum=/maximum=/exclusive_maximum= setters (Schema, ConstraintSet, etc).
28
+ # Skips descending and infinite bounds.
29
+ def apply_numeric_range(target, range)
30
+ return unless range
31
+
32
+ first_val = range.begin
33
+ last_val = range.end
34
+
35
+ return if descending?(first_val, last_val)
36
+
37
+ target.minimum = first_val if finite_numeric?(first_val) && target.respond_to?(:minimum=)
38
+ return unless finite_numeric?(last_val)
39
+
40
+ target.maximum = last_val if target.respond_to?(:maximum=)
41
+ target.exclusive_maximum = range.exclude_end? if target.respond_to?(:exclusive_maximum=)
42
+ end
43
+
44
+ # Returns true when all non-nil bounds are Numeric (pure numeric range).
45
+ def numeric_range?(range)
46
+ bounds = [range.begin, range.end].compact
47
+ bounds.any? && bounds.all?(Numeric)
48
+ end
49
+
50
+ # Applies a Range to a schema as min/max or enum.
51
+ # @param schema [ApiModel::Schema]
52
+ def apply_to_schema(schema, range)
53
+ bounds = [range.begin, range.end].compact
54
+ return if bounds.empty?
55
+
56
+ all_numeric = numeric_range?(range)
57
+ any_numeric = bounds.any?(Numeric)
58
+ mixed_numeric = any_numeric && !all_numeric
59
+ numeric_range = all_numeric
60
+ numeric_type = NUMERIC_TYPES.include?(schema.type)
61
+
62
+ if mixed_numeric
63
+ GrapeOAS.logger.warn("Mixed-type range #{range} ignored; endpoints must both be numeric or both non-numeric")
64
+ elsif numeric_range && numeric_type
65
+ apply_numeric_range(schema, range)
66
+ elsif numeric_range
67
+ GrapeOAS.logger.warn("Numeric range #{range} ignored on non-numeric schema type '#{schema.type}'")
68
+ elsif !numeric_type
69
+ expanded = expand_range_to_enum(range)
70
+ schema.enum = expanded if expanded
71
+ else
72
+ GrapeOAS.logger.warn("Non-numeric range #{range} ignored on numeric schema type '#{schema.type}'")
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def finite_numeric?(val)
79
+ val.is_a?(Numeric) && val.finite?
80
+ end
81
+
82
+ def descending?(first_val, last_val)
83
+ first_val.is_a?(Numeric) && last_val.is_a?(Numeric) && first_val > last_val
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ # Applies numeric and string constraints from documentation to a schema.
5
+ #
6
+ # Accepts both symbol-keyed and string-keyed doc hashes; keys are normalized
7
+ # internally so callers need not pre-convert them.
8
+ module SchemaConstraints
9
+ def self.apply(schema, doc)
10
+ doc = doc.transform_keys(&:to_sym) unless doc.empty?
11
+ if doc.key?(:minimum)
12
+ schema.minimum = doc[:minimum] if schema.respond_to?(:minimum=)
13
+ # Clear stale exclusive_minimum; re-set below if also provided
14
+ schema.exclusive_minimum = nil if schema.respond_to?(:exclusive_minimum=) && !doc.key?(:exclusive_minimum)
15
+ end
16
+ if doc.key?(:maximum)
17
+ schema.maximum = doc[:maximum] if schema.respond_to?(:maximum=)
18
+ # Clear stale exclusive_maximum; re-set below if also provided
19
+ schema.exclusive_maximum = nil if schema.respond_to?(:exclusive_maximum=) && !doc.key?(:exclusive_maximum)
20
+ end
21
+ set_if_present(schema, :exclusive_minimum=, doc, :exclusive_minimum)
22
+ set_if_present(schema, :exclusive_maximum=, doc, :exclusive_maximum)
23
+ set_if_present(schema, :min_length=, doc, :min_length)
24
+ set_if_present(schema, :max_length=, doc, :max_length)
25
+ set_if_present(schema, :pattern=, doc, :pattern)
26
+ end
27
+
28
+ def self.set_if_present(schema, setter, doc, key)
29
+ return unless doc.key?(key) && schema.respond_to?(setter)
30
+
31
+ schema.public_send(setter, doc[key])
32
+ end
33
+
34
+ private_class_method :set_if_present
35
+ end
36
+ end
@@ -20,15 +20,13 @@ module GrapeOAS
20
20
  class ArrayResolver
21
21
  extend Base
22
22
 
23
- # Pattern to match Grape's array notation: "[Type]" or "[Module::Type]"
24
- # Intentionally narrow: avoid treating multi-type strings like "[String, Integer]" as arrays.
25
- ARRAY_PATTERN = /\A\[(?<inner>(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)\]\z/
23
+ TYPED_ARRAY_PATTERN = Constants::TypePatterns::TYPED_ARRAY
26
24
 
27
25
  class << self
28
26
  def handles?(type)
29
27
  return false unless type.is_a?(String)
30
28
 
31
- type.match?(ARRAY_PATTERN)
29
+ type.match?(TYPED_ARRAY_PATTERN)
32
30
  end
33
31
 
34
32
  def build_schema(type)
@@ -53,7 +51,7 @@ module GrapeOAS
53
51
  private
54
52
 
55
53
  def extract_inner_type(type)
56
- match = type.match(ARRAY_PATTERN)
54
+ match = type.match(TYPED_ARRAY_PATTERN)
57
55
  match[:inner] if match
58
56
  end
59
57
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ # Normalizes values from Grape parameter or entity documentation into
5
+ # Array, Range, or nil for OpenAPI schema generation.
6
+ module ValuesNormalizer
7
+ # @param values [Object] raw values from spec or documentation
8
+ # @param context [String] description for warning messages
9
+ # @return [Array, Range, nil]
10
+ def self.normalize(values, context: "values")
11
+ return nil unless values
12
+
13
+ return nil if values.is_a?(Hash) && !values.key?(:value) && !values.key?("value")
14
+
15
+ values = values.key?(:value) ? values[:value] : values["value"] if values.is_a?(Hash)
16
+ return nil unless values
17
+
18
+ if values.respond_to?(:call)
19
+ # Two-stage defense for callable values:
20
+ # 1) Arity check filters out validators (arity > 0) and objects without arity.
21
+ # This is a heuristic — optional-arg procs (proc { |v = nil| ... }) report arity 0.
22
+ # 2) Post-call type check catches those false positives by verifying the return
23
+ # value is a collection type. Both guards are load-bearing; do not remove either.
24
+ return nil unless values.respond_to?(:arity) && values.arity.zero?
25
+
26
+ begin
27
+ values = values.call
28
+ rescue StandardError => e
29
+ GrapeOAS.logger.warn("Proc evaluation failed for #{context} (#{e.class}): #{e.message}")
30
+ return nil
31
+ end
32
+ return nil unless values.is_a?(Array) || values.is_a?(Range) || set_instance?(values)
33
+ end
34
+
35
+ values = values.to_a if set_instance?(values)
36
+ return nil unless values.is_a?(Array) || values.is_a?(Range)
37
+ return nil if values.is_a?(Array) && values.empty?
38
+
39
+ values
40
+ end
41
+
42
+ def self.set_instance?(value)
43
+ value.is_a?(Set)
44
+ end
45
+ private_class_method :set_instance?
46
+ end
47
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOAS
4
- VERSION = "1.2.0"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/grape_oas.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "grape"
4
+ require "logger"
4
5
  require "zeitwerk"
5
6
 
6
7
  loader = Zeitwerk::Loader.for_gem
@@ -41,6 +42,32 @@ module GrapeOAS
41
42
  end
42
43
  module_function :version
43
44
 
45
+ # Formatter that prepends [grape-oas] and suppresses Logger metadata.
46
+ # Used by the default logger and the test capture helper.
47
+ LOG_FORMATTER = proc { |_severity, _datetime, _progname, msg| "[grape-oas] #{msg}\n" }
48
+
49
+ # Configurable logger for schema generation warnings.
50
+ # Defaults to Logger on $stderr. Set to Rails.logger or Logger.new(File::NULL).
51
+ #
52
+ # @return [#warn]
53
+ def logger
54
+ @logger ||= begin
55
+ l = Logger.new($stderr, progname: "grape-oas", level: Logger::WARN)
56
+ l.formatter = LOG_FORMATTER
57
+ l
58
+ end
59
+ end
60
+
61
+ # @param value [#warn, nil] a logger-compatible object, or nil to reset
62
+ # to the default $stderr logger
63
+ def logger=(value)
64
+ raise ArgumentError, "logger must respond to :warn (got #{value.class})" if value && !value.respond_to?(:warn)
65
+
66
+ @logger = value
67
+ end
68
+
69
+ module_function :logger, :logger=
70
+
44
71
  # Returns the global introspector registry.
45
72
  #
46
73
  # The registry manages introspectors that build schemas from various sources
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-oas
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Subbota
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-02 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -81,6 +81,7 @@ files:
81
81
  - lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb
82
82
  - lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb
83
83
  - lib/grape_oas/constants.rb
84
+ - lib/grape_oas/doc_key_normalizer.rb
84
85
  - lib/grape_oas/documentation_extension.rb
85
86
  - lib/grape_oas/exporter.rb
86
87
  - lib/grape_oas/exporter/base/operation.rb
@@ -118,18 +119,24 @@ files:
118
119
  - lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb
119
120
  - lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb
120
121
  - lib/grape_oas/introspectors/entity_introspector.rb
122
+ - lib/grape_oas/introspectors/entity_introspector_support.rb
121
123
  - lib/grape_oas/introspectors/entity_introspector_support/cycle_tracker.rb
122
124
  - lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb
123
125
  - lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb
124
126
  - lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb
127
+ - lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb
125
128
  - lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb
129
+ - lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb
126
130
  - lib/grape_oas/introspectors/registry.rb
127
131
  - lib/grape_oas/rake/oas_tasks.rb
132
+ - lib/grape_oas/range_utils.rb
133
+ - lib/grape_oas/schema_constraints.rb
128
134
  - lib/grape_oas/type_resolvers/array_resolver.rb
129
135
  - lib/grape_oas/type_resolvers/base.rb
130
136
  - lib/grape_oas/type_resolvers/dry_type_resolver.rb
131
137
  - lib/grape_oas/type_resolvers/primitive_resolver.rb
132
138
  - lib/grape_oas/type_resolvers/registry.rb
139
+ - lib/grape_oas/values_normalizer.rb
133
140
  - lib/grape_oas/version.rb
134
141
  homepage: https://github.com/numbata/grape-oas
135
142
  licenses: