grape-oas 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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/CONTRIBUTING.md +87 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +184 -0
  6. data/RELEASING.md +109 -0
  7. data/grape-oas.gemspec +27 -0
  8. data/lib/grape-oas.rb +3 -0
  9. data/lib/grape_oas/api_model/api.rb +42 -0
  10. data/lib/grape_oas/api_model/media_type.rb +22 -0
  11. data/lib/grape_oas/api_model/node.rb +57 -0
  12. data/lib/grape_oas/api_model/operation.rb +55 -0
  13. data/lib/grape_oas/api_model/parameter.rb +24 -0
  14. data/lib/grape_oas/api_model/path.rb +29 -0
  15. data/lib/grape_oas/api_model/request_body.rb +27 -0
  16. data/lib/grape_oas/api_model/response.rb +28 -0
  17. data/lib/grape_oas/api_model/schema.rb +60 -0
  18. data/lib/grape_oas/api_model_builder.rb +63 -0
  19. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +93 -0
  20. data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +75 -0
  21. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +142 -0
  22. data/lib/grape_oas/api_model_builders/operation.rb +168 -0
  23. data/lib/grape_oas/api_model_builders/path.rb +122 -0
  24. data/lib/grape_oas/api_model_builders/request.rb +304 -0
  25. data/lib/grape_oas/api_model_builders/request_params.rb +128 -0
  26. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +155 -0
  27. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +64 -0
  28. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +163 -0
  29. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +111 -0
  30. data/lib/grape_oas/api_model_builders/response.rb +241 -0
  31. data/lib/grape_oas/api_model_builders/response_parsers/base.rb +56 -0
  32. data/lib/grape_oas/api_model_builders/response_parsers/default_response_parser.rb +31 -0
  33. data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +35 -0
  34. data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +85 -0
  35. data/lib/grape_oas/constants.rb +81 -0
  36. data/lib/grape_oas/documentation_extension.rb +124 -0
  37. data/lib/grape_oas/exporter/base/operation.rb +88 -0
  38. data/lib/grape_oas/exporter/base/paths.rb +53 -0
  39. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +93 -0
  40. data/lib/grape_oas/exporter/concerns/tag_builder.rb +55 -0
  41. data/lib/grape_oas/exporter/oas2/operation.rb +31 -0
  42. data/lib/grape_oas/exporter/oas2/parameter.rb +116 -0
  43. data/lib/grape_oas/exporter/oas2/paths.rb +19 -0
  44. data/lib/grape_oas/exporter/oas2/response.rb +74 -0
  45. data/lib/grape_oas/exporter/oas2/schema.rb +125 -0
  46. data/lib/grape_oas/exporter/oas2_schema.rb +133 -0
  47. data/lib/grape_oas/exporter/oas3/operation.rb +24 -0
  48. data/lib/grape_oas/exporter/oas3/parameter.rb +27 -0
  49. data/lib/grape_oas/exporter/oas3/paths.rb +21 -0
  50. data/lib/grape_oas/exporter/oas3/request_body.rb +54 -0
  51. data/lib/grape_oas/exporter/oas3/response.rb +85 -0
  52. data/lib/grape_oas/exporter/oas3/schema.rb +249 -0
  53. data/lib/grape_oas/exporter/oas30_schema.rb +13 -0
  54. data/lib/grape_oas/exporter/oas31/schema.rb +42 -0
  55. data/lib/grape_oas/exporter/oas31_schema.rb +34 -0
  56. data/lib/grape_oas/exporter/oas3_schema.rb +130 -0
  57. data/lib/grape_oas/exporter/registry.rb +82 -0
  58. data/lib/grape_oas/exporter.rb +16 -0
  59. data/lib/grape_oas/introspectors/base.rb +44 -0
  60. data/lib/grape_oas/introspectors/dry_introspector.rb +131 -0
  61. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +51 -0
  62. data/lib/grape_oas/introspectors/dry_introspector_support/ast_walker.rb +125 -0
  63. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_applier.rb +136 -0
  64. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +85 -0
  65. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_merger.rb +47 -0
  66. data/lib/grape_oas/introspectors/dry_introspector_support/contract_resolver.rb +60 -0
  67. data/lib/grape_oas/introspectors/dry_introspector_support/inheritance_handler.rb +87 -0
  68. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +131 -0
  69. data/lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb +143 -0
  70. data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +143 -0
  71. data/lib/grape_oas/introspectors/entity_introspector.rb +165 -0
  72. data/lib/grape_oas/introspectors/entity_introspector_support/cycle_tracker.rb +42 -0
  73. data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +83 -0
  74. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +261 -0
  75. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +112 -0
  76. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +53 -0
  77. data/lib/grape_oas/introspectors/registry.rb +136 -0
  78. data/lib/grape_oas/rake/oas_tasks.rb +127 -0
  79. data/lib/grape_oas/version.rb +5 -0
  80. data/lib/grape_oas.rb +145 -0
  81. metadata +152 -0
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request_params_support/param_location_resolver"
4
+ require_relative "request_params_support/param_schema_builder"
5
+ require_relative "request_params_support/schema_enhancer"
6
+ require_relative "request_params_support/nested_params_builder"
7
+
8
+ module GrapeOAS
9
+ module ApiModelBuilders
10
+ class RequestParams
11
+ ROUTE_PARAM_REGEX = /(?<=:)\w+/
12
+
13
+ attr_reader :api, :route, :path_param_name_map
14
+
15
+ def initialize(api:, route:, path_param_name_map: nil)
16
+ @api = api
17
+ @route = route
18
+ @path_param_name_map = path_param_name_map || {}
19
+ end
20
+
21
+ def build
22
+ route_params = route.path.scan(ROUTE_PARAM_REGEX)
23
+ all_params = route.options[:params] || {}
24
+
25
+ # Check if we have nested params (bracket notation)
26
+ has_nested = all_params.keys.any? { |k| k.include?("[") }
27
+
28
+ if has_nested
29
+ build_with_nested_params(all_params, route_params)
30
+ else
31
+ build_flat_params(all_params, route_params)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Builds params when nested structures are detected.
38
+ def build_with_nested_params(all_params, route_params)
39
+ body_schema = nested_params_builder.build(all_params, path_params: route_params)
40
+ non_body_params = extract_non_body_params(all_params, route_params)
41
+
42
+ [body_schema, non_body_params]
43
+ end
44
+
45
+ # Builds params for flat (non-nested) structures.
46
+ def build_flat_params(all_params, route_params)
47
+ body_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
48
+ path_params = []
49
+
50
+ all_params.each do |name, spec|
51
+ next if location_resolver.hidden_parameter?(spec)
52
+
53
+ location = location_resolver.resolve(
54
+ name: name,
55
+ spec: spec,
56
+ route_params: route_params,
57
+ route: route,
58
+ )
59
+ required = spec[:required] || false
60
+ schema = schema_builder.build(spec)
61
+ mapped_name = path_param_name_map.fetch(name, name)
62
+
63
+ if location == "body"
64
+ body_schema.add_property(name, schema, required: required)
65
+ else
66
+ path_params << build_parameter(mapped_name, location, required, schema, spec)
67
+ end
68
+ end
69
+
70
+ [body_schema, path_params]
71
+ end
72
+
73
+ # Extracts non-body params (path, query, header) from flat params.
74
+ def extract_non_body_params(all_params, route_params)
75
+ params = []
76
+
77
+ all_params.each do |name, spec|
78
+ # Skip nested params (they go into body)
79
+ next if name.include?("[")
80
+ # Skip Hash/body params
81
+ next if location_resolver.body_param?(spec)
82
+ # Skip hidden params
83
+ next if location_resolver.hidden_parameter?(spec)
84
+
85
+ location = location_resolver.resolve(
86
+ name: name,
87
+ spec: spec,
88
+ route_params: route_params,
89
+ route: route,
90
+ )
91
+ next if location == "body"
92
+
93
+ mapped_name = path_param_name_map.fetch(name, name)
94
+ params << build_parameter(mapped_name, location, spec[:required] || false, schema_builder.build(spec), spec)
95
+ end
96
+
97
+ params
98
+ end
99
+
100
+ def build_parameter(name, location, required, schema, spec)
101
+ ApiModel::Parameter.new(
102
+ location: location,
103
+ name: name,
104
+ required: required,
105
+ schema: schema,
106
+ description: spec[:documentation]&.dig(:desc),
107
+ collection_format: extract_collection_format(spec),
108
+ )
109
+ end
110
+
111
+ def extract_collection_format(spec)
112
+ spec.dig(:documentation, :collectionFormat) || spec.dig(:documentation, :collection_format)
113
+ end
114
+
115
+ def location_resolver
116
+ RequestParamsSupport::ParamLocationResolver
117
+ end
118
+
119
+ def schema_builder
120
+ @schema_builder ||= RequestParamsSupport::ParamSchemaBuilder.new
121
+ end
122
+
123
+ def nested_params_builder
124
+ @nested_params_builder ||= RequestParamsSupport::NestedParamsBuilder.new(schema_builder: schema_builder)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ module RequestParamsSupport
6
+ # Reconstructs nested parameter structures from Grape's flat bracket notation.
7
+ # Grape exposes nested params as flat keys like "address[street]", "address[city]".
8
+ # This class converts them back to proper nested schemas.
9
+ class NestedParamsBuilder
10
+ BRACKET_PATTERN = /\[([^\]]+)\]/
11
+ MAX_NESTING_DEPTH = 10
12
+
13
+ def initialize(schema_builder:)
14
+ @schema_builder = schema_builder
15
+ end
16
+
17
+ # Builds a nested schema from flat bracket-notation params.
18
+ #
19
+ # @param flat_params [Hash] the flat params from Grape route (name => spec)
20
+ # @param path_params [Array<String>] names of path parameters to exclude
21
+ # @return [ApiModel::Schema] the reconstructed nested schema
22
+ def build(flat_params, path_params: [])
23
+ schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
24
+
25
+ # Separate top-level params from nested bracket params
26
+ top_level, nested = partition_params(flat_params)
27
+
28
+ # Group nested params by their root key
29
+ nested_groups = group_nested_params(nested)
30
+
31
+ top_level.each do |name, spec|
32
+ next if path_params.include?(name)
33
+ next if ParamLocationResolver.explicit_non_body_param?(spec)
34
+ next if ParamLocationResolver.hidden_parameter?(spec)
35
+
36
+ child_schema = @schema_builder.build(spec)
37
+
38
+ # Check if this param has nested children
39
+ if nested_groups.key?(name)
40
+ nested_children = nested_groups[name]
41
+ child_schema = build_nested_children(spec, nested_children, depth: 0)
42
+ end
43
+
44
+ required = spec[:required] || false
45
+ schema.add_property(name, child_schema, required: required)
46
+ end
47
+
48
+ schema
49
+ end
50
+
51
+ private
52
+
53
+ # Partitions params into top-level (no brackets) and nested (with brackets).
54
+ def partition_params(flat_params)
55
+ top_level = {}
56
+ nested = {}
57
+
58
+ flat_params.each do |name, spec|
59
+ if name.include?("[")
60
+ nested[name] = spec
61
+ else
62
+ top_level[name] = spec
63
+ end
64
+ end
65
+
66
+ [top_level, nested]
67
+ end
68
+
69
+ # Groups nested params by their root key.
70
+ # "address[street]" => { "address" => { "street" => spec } }
71
+ def group_nested_params(nested_params)
72
+ groups = Hash.new { |h, k| h[k] = {} }
73
+
74
+ nested_params.each do |name, spec|
75
+ root, rest = parse_bracket_key(name)
76
+ groups[root][rest] = spec
77
+ end
78
+
79
+ groups
80
+ end
81
+
82
+ # Parses a bracket-notation key into root and remaining path.
83
+ # "address[street]" => ["address", "street"]
84
+ # "company[address][street]" => ["company", "address[street]"]
85
+ def parse_bracket_key(name)
86
+ match = name.match(/^([^\[]+)\[([^\]]+)\](.*)$/)
87
+ return [name, nil] unless match
88
+
89
+ root = match[1]
90
+ first_key = match[2]
91
+ remainder = match[3]
92
+
93
+ rest = remainder.empty? ? first_key : "#{first_key}#{remainder}"
94
+ [root, rest]
95
+ end
96
+
97
+ # Builds nested children schema recursively.
98
+ # @raise [ArgumentError] if nesting exceeds MAX_NESTING_DEPTH
99
+ def build_nested_children(parent_spec, nested_children, depth: 0)
100
+ raise ArgumentError, "Parameter nesting too deep (max #{MAX_NESTING_DEPTH} levels)" if depth > MAX_NESTING_DEPTH
101
+
102
+ parent_type = parent_spec[:type]
103
+
104
+ if array_type?(parent_type)
105
+ build_array_with_children(parent_spec, nested_children, depth: depth)
106
+ else
107
+ build_hash_with_children(parent_spec, nested_children, depth: depth)
108
+ end
109
+ end
110
+
111
+ # Checks if type represents an array (class or string "Array")
112
+ def array_type?(type)
113
+ type == Array || type.to_s == "Array"
114
+ end
115
+
116
+ # Builds an array schema with nested item properties.
117
+ def build_array_with_children(parent_spec, nested_children, depth: 0)
118
+ items_schema = build_hash_with_children(parent_spec, nested_children, depth: depth)
119
+ ApiModel::Schema.new(
120
+ type: Constants::SchemaTypes::ARRAY,
121
+ items: items_schema,
122
+ )
123
+ end
124
+
125
+ # Builds a hash/object schema with nested properties.
126
+ def build_hash_with_children(parent_spec, nested_children, depth: 0)
127
+ schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
128
+ top_level, deeply_nested = partition_params(nested_children)
129
+ nested_groups = group_nested_params(deeply_nested)
130
+
131
+ top_level.each do |name, spec|
132
+ child_schema = if nested_groups.key?(name)
133
+ build_nested_children(spec, nested_groups[name], depth: depth + 1)
134
+ else
135
+ @schema_builder.build(spec)
136
+ end
137
+ schema.add_property(name, child_schema, required: spec[:required] || false)
138
+ end
139
+
140
+ apply_documentation_extensions(schema, parent_spec)
141
+ schema
142
+ end
143
+
144
+ def apply_documentation_extensions(schema, parent_spec)
145
+ doc = parent_spec[:documentation] || {}
146
+ schema.description = doc[:desc] if doc[:desc]
147
+ schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
148
+ schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
149
+ schema.format = doc[:format] if doc[:format]
150
+ schema.examples = doc[:example] if doc[:example]
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ module RequestParamsSupport
6
+ # Resolves the location (path, query, body, header) for a parameter.
7
+ class ParamLocationResolver
8
+ # Determines the location for a parameter.
9
+ #
10
+ # @param name [String] the parameter name
11
+ # @param spec [Hash] the parameter specification
12
+ # @param route_params [Array<String>] list of path parameter names
13
+ # @param route [Object] the Grape route object
14
+ # @return [String] the parameter location ("path", "query", "body", "header")
15
+ def self.resolve(name:, spec:, route_params:, route:)
16
+ return "path" if route_params.include?(name)
17
+
18
+ extract_from_spec(spec, route)
19
+ end
20
+
21
+ # Checks if a parameter should be in the request body.
22
+ #
23
+ # @param spec [Hash] the parameter specification
24
+ # @return [Boolean] true if it's a body parameter
25
+ def self.body_param?(spec)
26
+ spec.dig(:documentation, :param_type) == "body" || [Hash, "Hash"].include?(spec[:type])
27
+ end
28
+
29
+ # Checks if a parameter is explicitly marked as NOT a body param.
30
+ #
31
+ # @param spec [Hash] the parameter specification
32
+ # @return [Boolean] true if explicitly non-body
33
+ def self.explicit_non_body_param?(spec)
34
+ param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
35
+ param_type && %w[query header path].include?(param_type)
36
+ end
37
+
38
+ # Checks if a parameter should be hidden from documentation.
39
+ # Required parameters are never hidden (matching grape-swagger behavior).
40
+ #
41
+ # @param spec [Hash] the parameter specification
42
+ # @return [Boolean] true if hidden
43
+ def self.hidden_parameter?(spec)
44
+ return false if spec[:required]
45
+
46
+ hidden = spec.dig(:documentation, :hidden)
47
+ hidden = hidden.call if hidden.respond_to?(:call)
48
+ hidden
49
+ end
50
+
51
+ class << self
52
+ private
53
+
54
+ def extract_from_spec(spec, route)
55
+ # If body_name is set on the route, treat non-path params as body by default
56
+ return "body" if route.options[:body_name] && !spec.dig(:documentation, :param_type)
57
+
58
+ spec.dig(:documentation, :param_type)&.downcase || "query"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ module RequestParamsSupport
6
+ # Builds OpenAPI schemas from Grape parameter specifications.
7
+ class ParamSchemaBuilder
8
+ include Concerns::TypeResolver
9
+ include Concerns::OasUtilities
10
+
11
+ # Builds a schema for a parameter specification.
12
+ #
13
+ # @param spec [Hash] the parameter specification
14
+ # @return [ApiModel::Schema] the built schema
15
+ def self.build(spec)
16
+ new.build(spec)
17
+ end
18
+
19
+ def build(spec)
20
+ doc = spec[:documentation] || {}
21
+ raw_type = spec[:type] || doc[:type]
22
+
23
+ schema = build_base_schema(spec, doc, raw_type)
24
+ SchemaEnhancer.apply(schema, spec, doc)
25
+ schema
26
+ end
27
+
28
+ private
29
+
30
+ def build_base_schema(spec, doc, raw_type)
31
+ type_source = spec[:type]
32
+ doc_type = doc[:type]
33
+
34
+ return build_entity_array_schema(spec, raw_type, doc_type) if entity_array_type?(type_source, doc_type, spec)
35
+ return build_doc_entity_array_schema(doc_type) if doc[:is_array] && grape_entity?(doc_type)
36
+ return build_entity_schema(doc_type) if grape_entity?(doc_type)
37
+ return build_entity_schema(raw_type) if grape_entity?(raw_type)
38
+ return build_elements_array_schema(spec) if array_with_elements?(raw_type, spec)
39
+ return build_multi_type_schema(raw_type) if multi_type?(raw_type)
40
+ return build_typed_array_schema(raw_type) if typed_array?(raw_type)
41
+ return build_simple_array_schema if simple_array?(raw_type)
42
+
43
+ build_primitive_schema(raw_type, doc)
44
+ end
45
+
46
+ def entity_array_type?(type_source, doc_type, spec)
47
+ (type_source == Array || type_source.to_s == "Array") &&
48
+ grape_entity?(doc_type || spec[:elements] || spec[:of])
49
+ end
50
+
51
+ def array_with_elements?(raw_type, spec)
52
+ (raw_type == Array || raw_type.to_s == "Array") && spec[:elements]
53
+ end
54
+
55
+ def build_entity_array_schema(spec, raw_type, doc_type)
56
+ entity_type = resolve_entity_class(extract_entity_type_from_array(spec, raw_type, doc_type))
57
+ items = entity_type ? Introspectors::EntityIntrospector.new(entity_type).build_schema : nil
58
+ items ||= ApiModel::Schema.new(type: sanitize_type(extract_entity_type_from_array(spec, raw_type)))
59
+ ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items)
60
+ end
61
+
62
+ def build_doc_entity_array_schema(doc_type)
63
+ entity_class = resolve_entity_class(doc_type)
64
+ items = Introspectors::EntityIntrospector.new(entity_class).build_schema
65
+ ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items)
66
+ end
67
+
68
+ def build_entity_schema(type)
69
+ entity_class = resolve_entity_class(type)
70
+ Introspectors::EntityIntrospector.new(entity_class).build_schema
71
+ end
72
+
73
+ def build_elements_array_schema(spec)
74
+ items_type = spec[:elements]
75
+ entity = resolve_entity_class(items_type)
76
+ items_schema = if entity
77
+ Introspectors::EntityIntrospector.new(entity).build_schema
78
+ else
79
+ ApiModel::Schema.new(type: sanitize_type(items_type))
80
+ end
81
+ ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
82
+ end
83
+
84
+ def build_simple_array_schema
85
+ ApiModel::Schema.new(
86
+ type: Constants::SchemaTypes::ARRAY,
87
+ items: ApiModel::Schema.new(type: Constants::SchemaTypes::STRING),
88
+ )
89
+ end
90
+
91
+ # Builds oneOf schema for Grape's multi-type notation like "[String, Integer]"
92
+ def build_multi_type_schema(type)
93
+ type_names = extract_multi_types(type)
94
+ schemas = type_names.map do |type_name|
95
+ ApiModel::Schema.new(type: resolve_schema_type(type_name))
96
+ end
97
+ ApiModel::Schema.new(one_of: schemas)
98
+ end
99
+
100
+ def build_primitive_schema(raw_type, doc)
101
+ ApiModel::Schema.new(
102
+ type: sanitize_type(raw_type),
103
+ description: doc[:desc],
104
+ )
105
+ end
106
+
107
+ def extract_entity_type_from_array(spec, raw_type, doc_type = nil)
108
+ return spec[:elements] if grape_entity?(spec[:elements])
109
+ return spec[:of] if grape_entity?(spec[:of])
110
+ return doc_type if grape_entity?(doc_type)
111
+
112
+ raw_type
113
+ end
114
+
115
+ def sanitize_type(type)
116
+ return Constants::SchemaTypes::OBJECT if grape_entity?(type)
117
+
118
+ resolve_schema_type(type)
119
+ end
120
+
121
+ def grape_entity?(type)
122
+ !!resolve_entity_class(type)
123
+ end
124
+
125
+ # Checks if type is a Grape typed array notation like "[String]"
126
+ def typed_array?(type)
127
+ type.is_a?(String) && type.match?(TYPED_ARRAY_PATTERN)
128
+ end
129
+
130
+ # Checks if type is a simple Array (class or string)
131
+ def simple_array?(type)
132
+ type == Array || type.to_s == "Array"
133
+ end
134
+
135
+ # Builds schema for Grape's typed array notation like "[String]", "[Integer]"
136
+ def build_typed_array_schema(type)
137
+ member_type = extract_typed_array_member(type)
138
+ items_type = resolve_schema_type(member_type)
139
+ ApiModel::Schema.new(
140
+ type: Constants::SchemaTypes::ARRAY,
141
+ items: ApiModel::Schema.new(type: items_type),
142
+ )
143
+ end
144
+
145
+ def resolve_entity_class(type)
146
+ return nil unless defined?(Grape::Entity)
147
+ return type if type.is_a?(Class) && type <= Grape::Entity
148
+ return nil unless type.is_a?(String) || type.is_a?(Symbol)
149
+
150
+ const_name = type.to_s
151
+ return nil unless valid_constant_name?(const_name)
152
+ return nil unless Object.const_defined?(const_name, false)
153
+
154
+ klass = Object.const_get(const_name, false)
155
+ klass if klass.is_a?(Class) && klass <= Grape::Entity
156
+ rescue NameError => e
157
+ warn "[grape-oas] Could not resolve entity constant '#{const_name}': #{e.message}"
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ module RequestParamsSupport
6
+ # Applies enhancements (constraints, format, examples, etc.) to a schema.
7
+ class SchemaEnhancer
8
+ # Applies all enhancements to a schema based on spec and documentation.
9
+ #
10
+ # @param schema [ApiModel::Schema] the schema to enhance
11
+ # @param spec [Hash] the parameter specification
12
+ # @param doc [Hash] the documentation hash
13
+ def self.apply(schema, spec, doc)
14
+ nullable = extract_nullable(spec, doc)
15
+
16
+ schema.description ||= doc[:desc]
17
+ schema.nullable = nullable if schema.respond_to?(:nullable=)
18
+
19
+ apply_additional_properties(schema, doc)
20
+ apply_format_and_example(schema, doc)
21
+ apply_constraints(schema, doc)
22
+ apply_values(schema, spec)
23
+ end
24
+
25
+ # Extracts nullable flag from spec and documentation.
26
+ #
27
+ # @param spec [Hash] the parameter specification
28
+ # @param doc [Hash] the documentation hash
29
+ # @return [Boolean] true if nullable
30
+ def self.extract_nullable(spec, doc)
31
+ spec[:allow_nil] || spec[:nullable] || doc[:nullable] || false
32
+ end
33
+
34
+ class << self
35
+ private
36
+
37
+ def apply_additional_properties(schema, doc)
38
+ if doc.key?(:additional_properties) && schema.respond_to?(:additional_properties=)
39
+ schema.additional_properties = doc[:additional_properties]
40
+ end
41
+ if doc.key?(:unevaluated_properties) && schema.respond_to?(:unevaluated_properties=)
42
+ schema.unevaluated_properties = doc[:unevaluated_properties]
43
+ end
44
+ defs = extract_defs(doc)
45
+ schema.defs = defs if defs.is_a?(Hash) && schema.respond_to?(:defs=)
46
+ end
47
+
48
+ def apply_format_and_example(schema, doc)
49
+ schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
50
+ schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
51
+ end
52
+
53
+ def apply_constraints(schema, doc)
54
+ schema.minimum = doc[:minimum] if doc.key?(:minimum) && schema.respond_to?(:minimum=)
55
+ schema.maximum = doc[:maximum] if doc.key?(:maximum) && schema.respond_to?(:maximum=)
56
+ schema.min_length = doc[:min_length] if doc.key?(:min_length) && schema.respond_to?(:min_length=)
57
+ schema.max_length = doc[:max_length] if doc.key?(:max_length) && schema.respond_to?(:max_length=)
58
+ schema.pattern = doc[:pattern] if doc.key?(:pattern) && schema.respond_to?(:pattern=)
59
+ end
60
+
61
+ # Applies values from spec[:values] - converts Range to min/max,
62
+ # evaluates Proc (arity 0), and sets enum for arrays.
63
+ # Skips Proc/Lambda validators (arity > 0) used for custom validation.
64
+ def apply_values(schema, spec)
65
+ values = spec[:values]
66
+ return unless values
67
+
68
+ # Handle Hash format { value: ..., message: ... } - extract the value
69
+ values = values[:value] if values.is_a?(Hash) && values.key?(:value)
70
+
71
+ # Handle Proc/Lambda
72
+ if values.respond_to?(:call)
73
+ # Skip validators (arity > 0) - they validate individual values
74
+ return if values.arity != 0
75
+
76
+ # Evaluate arity-0 procs - they return enum arrays
77
+ values = values.call
78
+ end
79
+
80
+ if values.is_a?(Range)
81
+ apply_range_values(schema, values)
82
+ elsif values.is_a?(Array) && values.any?
83
+ schema.enum = values if schema.respond_to?(:enum=)
84
+ end
85
+ end
86
+
87
+ # Converts a Range to minimum/maximum constraints.
88
+ # For numeric ranges (Integer, Float), uses min/max.
89
+ # For other ranges (e.g., 'a'..'z'), expands to enum array.
90
+ # Handles endless/beginless ranges (e.g., 1.., ..10).
91
+ def apply_range_values(schema, range)
92
+ first_val = range.begin
93
+ last_val = range.end
94
+
95
+ if first_val.is_a?(Numeric) || last_val.is_a?(Numeric)
96
+ schema.minimum = first_val if first_val && schema.respond_to?(:minimum=)
97
+ schema.maximum = last_val if last_val && schema.respond_to?(:maximum=)
98
+ elsif first_val && last_val && schema.respond_to?(:enum=)
99
+ # Non-numeric bounded range (e.g., 'a'..'z') - expand to enum
100
+ schema.enum = range.to_a
101
+ end
102
+ end
103
+
104
+ def extract_defs(doc)
105
+ doc[:defs] || doc[:$defs]
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end