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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/CONTRIBUTING.md +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/RELEASING.md +109 -0
- data/grape-oas.gemspec +27 -0
- data/lib/grape-oas.rb +3 -0
- data/lib/grape_oas/api_model/api.rb +42 -0
- data/lib/grape_oas/api_model/media_type.rb +22 -0
- data/lib/grape_oas/api_model/node.rb +57 -0
- data/lib/grape_oas/api_model/operation.rb +55 -0
- data/lib/grape_oas/api_model/parameter.rb +24 -0
- data/lib/grape_oas/api_model/path.rb +29 -0
- data/lib/grape_oas/api_model/request_body.rb +27 -0
- data/lib/grape_oas/api_model/response.rb +28 -0
- data/lib/grape_oas/api_model/schema.rb +60 -0
- data/lib/grape_oas/api_model_builder.rb +63 -0
- data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +93 -0
- data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +75 -0
- data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +142 -0
- data/lib/grape_oas/api_model_builders/operation.rb +168 -0
- data/lib/grape_oas/api_model_builders/path.rb +122 -0
- data/lib/grape_oas/api_model_builders/request.rb +304 -0
- data/lib/grape_oas/api_model_builders/request_params.rb +128 -0
- data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +155 -0
- data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +64 -0
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +163 -0
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +111 -0
- data/lib/grape_oas/api_model_builders/response.rb +241 -0
- data/lib/grape_oas/api_model_builders/response_parsers/base.rb +56 -0
- data/lib/grape_oas/api_model_builders/response_parsers/default_response_parser.rb +31 -0
- data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +35 -0
- data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +85 -0
- data/lib/grape_oas/constants.rb +81 -0
- data/lib/grape_oas/documentation_extension.rb +124 -0
- data/lib/grape_oas/exporter/base/operation.rb +88 -0
- data/lib/grape_oas/exporter/base/paths.rb +53 -0
- data/lib/grape_oas/exporter/concerns/schema_indexer.rb +93 -0
- data/lib/grape_oas/exporter/concerns/tag_builder.rb +55 -0
- data/lib/grape_oas/exporter/oas2/operation.rb +31 -0
- data/lib/grape_oas/exporter/oas2/parameter.rb +116 -0
- data/lib/grape_oas/exporter/oas2/paths.rb +19 -0
- data/lib/grape_oas/exporter/oas2/response.rb +74 -0
- data/lib/grape_oas/exporter/oas2/schema.rb +125 -0
- data/lib/grape_oas/exporter/oas2_schema.rb +133 -0
- data/lib/grape_oas/exporter/oas3/operation.rb +24 -0
- data/lib/grape_oas/exporter/oas3/parameter.rb +27 -0
- data/lib/grape_oas/exporter/oas3/paths.rb +21 -0
- data/lib/grape_oas/exporter/oas3/request_body.rb +54 -0
- data/lib/grape_oas/exporter/oas3/response.rb +85 -0
- data/lib/grape_oas/exporter/oas3/schema.rb +249 -0
- data/lib/grape_oas/exporter/oas30_schema.rb +13 -0
- data/lib/grape_oas/exporter/oas31/schema.rb +42 -0
- data/lib/grape_oas/exporter/oas31_schema.rb +34 -0
- data/lib/grape_oas/exporter/oas3_schema.rb +130 -0
- data/lib/grape_oas/exporter/registry.rb +82 -0
- data/lib/grape_oas/exporter.rb +16 -0
- data/lib/grape_oas/introspectors/base.rb +44 -0
- data/lib/grape_oas/introspectors/dry_introspector.rb +131 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +51 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/ast_walker.rb +125 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_applier.rb +136 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +85 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_merger.rb +47 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/contract_resolver.rb +60 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/inheritance_handler.rb +87 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +131 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb +143 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +143 -0
- data/lib/grape_oas/introspectors/entity_introspector.rb +165 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/cycle_tracker.rb +42 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +83 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +261 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +112 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +53 -0
- data/lib/grape_oas/introspectors/registry.rb +136 -0
- data/lib/grape_oas/rake/oas_tasks.rb +127 -0
- data/lib/grape_oas/version.rb +5 -0
- data/lib/grape_oas.rb +145 -0
- 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
|