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,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module ApiModelBuilders
|
|
5
|
+
class Response
|
|
6
|
+
include Concerns::ContentTypeResolver
|
|
7
|
+
include Concerns::OasUtilities
|
|
8
|
+
|
|
9
|
+
# Default response parsers in priority order
|
|
10
|
+
# DocumentationResponsesParser has highest priority (most comprehensive)
|
|
11
|
+
# HttpCodesParser handles legacy grape-swagger formats
|
|
12
|
+
# DefaultResponseParser is the fallback
|
|
13
|
+
DEFAULT_PARSERS = [
|
|
14
|
+
ResponseParsers::DocumentationResponsesParser,
|
|
15
|
+
ResponseParsers::HttpCodesParser,
|
|
16
|
+
ResponseParsers::DefaultResponseParser
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
attr_writer :parsers
|
|
21
|
+
|
|
22
|
+
def parsers
|
|
23
|
+
@parsers ||= DEFAULT_PARSERS.dup
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset_parsers!
|
|
27
|
+
@parsers = DEFAULT_PARSERS.dup
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :api, :route, :app
|
|
32
|
+
|
|
33
|
+
def initialize(api:, route:, app: nil)
|
|
34
|
+
@api = api
|
|
35
|
+
@route = route
|
|
36
|
+
@app = app
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build
|
|
40
|
+
specs = response_specs
|
|
41
|
+
grouped = group_specs_by_status(specs)
|
|
42
|
+
grouped.map { |_code, group_specs| build_response_from_group(group_specs) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Use Strategy pattern to parse responses
|
|
48
|
+
# Parsers are tried in order of priority
|
|
49
|
+
def response_specs
|
|
50
|
+
parser = parsers.find { |p| p.applicable?(route) }
|
|
51
|
+
parser ? parser.parse(route) : []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parsers
|
|
55
|
+
@parsers ||= self.class.parsers.map(&:new)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Groups specs by status code to support multiple present responses
|
|
59
|
+
def group_specs_by_status(specs)
|
|
60
|
+
specs.group_by { |s| s[:code].to_s }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds a response from a group of specs with the same status code
|
|
64
|
+
# If multiple specs have `as:` keys, they are merged into a single object schema
|
|
65
|
+
def build_response_from_group(group_specs)
|
|
66
|
+
if group_specs.any? { |s| s[:as] }
|
|
67
|
+
build_merged_response(group_specs)
|
|
68
|
+
else
|
|
69
|
+
build_response_from_spec(group_specs.first)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Builds a merged response for multiple present with `as:` keys
|
|
74
|
+
def build_merged_response(specs)
|
|
75
|
+
first_spec = specs.first
|
|
76
|
+
schema = build_merged_schema(specs)
|
|
77
|
+
media_types = Array(response_content_types).map do |mime|
|
|
78
|
+
build_media_type(mime_type: mime, schema: schema)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
description = first_spec[:message].is_a?(String) ? first_spec[:message] : first_spec[:message].to_s
|
|
82
|
+
|
|
83
|
+
GrapeOAS::ApiModel::Response.new(
|
|
84
|
+
http_status: first_spec[:code].to_s,
|
|
85
|
+
description: description || "Success",
|
|
86
|
+
media_types: media_types,
|
|
87
|
+
headers: normalize_headers(first_spec[:headers]) || headers_from_route,
|
|
88
|
+
extensions: first_spec[:extensions] || extensions_from_route,
|
|
89
|
+
examples: merge_examples(specs),
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Builds an object schema with properties from each `as:` keyed spec
|
|
94
|
+
def build_merged_schema(specs)
|
|
95
|
+
properties = {}
|
|
96
|
+
required = []
|
|
97
|
+
|
|
98
|
+
specs.each do |spec|
|
|
99
|
+
key = spec[:as].to_s
|
|
100
|
+
entity_schema = build_schema(spec[:entity])
|
|
101
|
+
|
|
102
|
+
properties[key] = if spec[:is_array]
|
|
103
|
+
GrapeOAS::ApiModel::Schema.new(
|
|
104
|
+
type: Constants::SchemaTypes::ARRAY,
|
|
105
|
+
items: entity_schema,
|
|
106
|
+
)
|
|
107
|
+
else
|
|
108
|
+
entity_schema
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
required << key if spec[:required]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
GrapeOAS::ApiModel::Schema.new(
|
|
115
|
+
type: Constants::SchemaTypes::OBJECT,
|
|
116
|
+
properties: properties,
|
|
117
|
+
required: required.empty? ? nil : required,
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Merges examples from multiple specs
|
|
122
|
+
def merge_examples(specs)
|
|
123
|
+
examples = specs.map { |s| s[:examples] }.compact
|
|
124
|
+
return nil if examples.empty?
|
|
125
|
+
|
|
126
|
+
examples.reduce({}, :merge)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_response_from_spec(spec)
|
|
130
|
+
entity_schema = build_schema(spec[:entity])
|
|
131
|
+
schema = wrap_with_root(entity_schema, spec[:entity], is_array: spec[:is_array])
|
|
132
|
+
media_types = Array(response_content_types).map do |mime|
|
|
133
|
+
build_media_type(
|
|
134
|
+
mime_type: mime,
|
|
135
|
+
schema: schema,
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
description = spec[:message].is_a?(String) ? spec[:message] : spec[:message].to_s
|
|
140
|
+
|
|
141
|
+
GrapeOAS::ApiModel::Response.new(
|
|
142
|
+
http_status: spec[:code].to_s,
|
|
143
|
+
description: description || "Success",
|
|
144
|
+
media_types: media_types,
|
|
145
|
+
headers: normalize_headers(spec[:headers]) || headers_from_route,
|
|
146
|
+
extensions: spec[:extensions] || extensions_from_route,
|
|
147
|
+
examples: spec[:examples],
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extensions_from_route
|
|
152
|
+
extract_extensions(route.options[:documentation])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_headers(hdrs)
|
|
156
|
+
return nil if hdrs.nil?
|
|
157
|
+
return hdrs if hdrs.is_a?(Array)
|
|
158
|
+
return nil unless hdrs.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
hdrs.map { |name, h| build_header_schema(name, h) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def headers_from_route
|
|
164
|
+
hdrs = route.options.dig(:documentation, :headers) || route.settings.dig(:documentation, :headers)
|
|
165
|
+
return [] unless hdrs.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
hdrs.map { |name, h| build_header_schema(name, h) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Build a header schema, normalizing field names
|
|
171
|
+
def build_header_schema(name, header_spec)
|
|
172
|
+
{
|
|
173
|
+
name: name,
|
|
174
|
+
schema: {
|
|
175
|
+
"type" => header_spec[:type] || header_spec["type"] || Constants::SchemaTypes::STRING,
|
|
176
|
+
"description" => header_spec[:description] || header_spec["description"] || header_spec[:desc]
|
|
177
|
+
}.compact
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Build schema for response body
|
|
182
|
+
# Delegates to EntityIntrospector when entity is present
|
|
183
|
+
def build_schema(entity_class)
|
|
184
|
+
return GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::STRING) unless entity_class
|
|
185
|
+
|
|
186
|
+
GrapeOAS::Introspectors::EntityIntrospector.new(entity_class).build_schema
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Wraps schema with root element if configured via route_setting :swagger, root: true/'name'
|
|
190
|
+
def wrap_with_root(schema, entity_class, is_array: false)
|
|
191
|
+
root_setting = route.settings.dig(:swagger, :root)
|
|
192
|
+
return schema unless root_setting
|
|
193
|
+
|
|
194
|
+
root_key = derive_root_key(root_setting, entity_class, is_array)
|
|
195
|
+
GrapeOAS::ApiModel::Schema.new(
|
|
196
|
+
type: Constants::SchemaTypes::OBJECT,
|
|
197
|
+
properties: { root_key => schema },
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Derives the root key name based on the setting
|
|
202
|
+
def derive_root_key(root_setting, entity_class, is_array)
|
|
203
|
+
case root_setting
|
|
204
|
+
when true
|
|
205
|
+
key = entity_name_to_key(entity_class)
|
|
206
|
+
is_array ? pluralize_key(key) : key
|
|
207
|
+
when String, Symbol
|
|
208
|
+
root_setting.to_s
|
|
209
|
+
else
|
|
210
|
+
entity_name_to_key(entity_class)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Converts entity class name to underscored key
|
|
215
|
+
def entity_name_to_key(entity_class)
|
|
216
|
+
return "data" unless entity_class
|
|
217
|
+
|
|
218
|
+
name = entity_class.is_a?(Class) ? entity_class.name : entity_class.to_s
|
|
219
|
+
# Remove common suffixes like Entity, Serializer
|
|
220
|
+
name = name.split("::").last || name
|
|
221
|
+
name = name.sub(/Entity$/, "").sub(/Serializer$/, "")
|
|
222
|
+
underscore(name)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def pluralize_key(key)
|
|
226
|
+
pluralize(key)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_media_type(mime_type:, schema:)
|
|
230
|
+
GrapeOAS::ApiModel::MediaType.new(
|
|
231
|
+
mime_type: mime_type,
|
|
232
|
+
schema: schema,
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def response_content_types
|
|
237
|
+
resolve_content_types
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module ApiModelBuilders
|
|
5
|
+
module ResponseParsers
|
|
6
|
+
# Base module for response parser strategies
|
|
7
|
+
# Each parser is responsible for extracting response specifications
|
|
8
|
+
# from a specific format (e.g., :http_codes, documentation: { responses: ... })
|
|
9
|
+
module Base
|
|
10
|
+
# Parse response specifications from the route
|
|
11
|
+
# @param route [Grape::Route] The route to parse
|
|
12
|
+
# @return [Array<Hash>] Array of normalized response specs with keys:
|
|
13
|
+
# - code: HTTP status code
|
|
14
|
+
# - message: Response description
|
|
15
|
+
# - entity: Entity class for response schema
|
|
16
|
+
# - headers: Response headers
|
|
17
|
+
# - extensions: Custom x- extensions (optional)
|
|
18
|
+
# - examples: Response examples (optional)
|
|
19
|
+
def parse(route)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #parse"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check if this parser can handle the given route
|
|
24
|
+
# @param route [Grape::Route] The route to check
|
|
25
|
+
# @return [Boolean] true if this parser can parse the route
|
|
26
|
+
def applicable?(route)
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #applicable?"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Extract status code from hash, supporting multiple key names
|
|
33
|
+
def extract_status_code(hash, default_code)
|
|
34
|
+
hash[:code] || hash[:status] || hash[:http_status] || default_code
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Extract description from hash, supporting multiple key names
|
|
38
|
+
def extract_description(hash)
|
|
39
|
+
hash[:message] || hash[:description] || hash[:desc]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Extract entity from hash, supporting multiple key names
|
|
43
|
+
def extract_entity(hash, fallback_entity)
|
|
44
|
+
hash[:model] || hash[:entity] || fallback_entity
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Normalize hash keys (string -> symbol)
|
|
48
|
+
def normalize_hash_keys(hash)
|
|
49
|
+
return hash unless hash.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module ApiModelBuilders
|
|
5
|
+
module ResponseParsers
|
|
6
|
+
# Parser that creates a default 200 response when no responses are defined
|
|
7
|
+
# This is the fallback parser used when no other parsers are applicable
|
|
8
|
+
class DefaultResponseParser
|
|
9
|
+
include Base
|
|
10
|
+
|
|
11
|
+
def applicable?(_route)
|
|
12
|
+
# Always applicable as a fallback
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(route)
|
|
17
|
+
inferred = route.options[:default_status]
|
|
18
|
+
inferred ||= route.request_method.to_s.upcase == "POST" ? 201 : 200
|
|
19
|
+
default_code = inferred.to_s
|
|
20
|
+
|
|
21
|
+
[{
|
|
22
|
+
code: default_code,
|
|
23
|
+
message: "Success",
|
|
24
|
+
entity: route.options[:entity],
|
|
25
|
+
headers: nil
|
|
26
|
+
}]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module ApiModelBuilders
|
|
5
|
+
module ResponseParsers
|
|
6
|
+
# Parser for responses defined in documentation: { responses: { ... } }
|
|
7
|
+
# This is the most comprehensive format and aligns with OpenAPI specification
|
|
8
|
+
class DocumentationResponsesParser
|
|
9
|
+
include Base
|
|
10
|
+
include Concerns::OasUtilities
|
|
11
|
+
|
|
12
|
+
def applicable?(route)
|
|
13
|
+
route.options.dig(:documentation, :responses).is_a?(Hash)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(route)
|
|
17
|
+
doc_resps = route.options.dig(:documentation, :responses)
|
|
18
|
+
return [] unless doc_resps.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
doc_resps.map do |code, doc|
|
|
21
|
+
doc = normalize_hash_keys(doc)
|
|
22
|
+
{
|
|
23
|
+
code: code,
|
|
24
|
+
message: extract_description(doc),
|
|
25
|
+
headers: doc[:headers],
|
|
26
|
+
entity: extract_entity(doc, route.options[:entity]),
|
|
27
|
+
extensions: extract_extensions(doc),
|
|
28
|
+
examples: doc[:examples]
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module ApiModelBuilders
|
|
5
|
+
module ResponseParsers
|
|
6
|
+
# Parser for responses defined via :http_codes, :failure, or :success options
|
|
7
|
+
# These are legacy grape-swagger formats that we support for compatibility
|
|
8
|
+
class HttpCodesParser
|
|
9
|
+
include Base
|
|
10
|
+
|
|
11
|
+
def applicable?(route)
|
|
12
|
+
route.options[:http_codes] || route.options[:failure] || route.options[:success]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse(route)
|
|
16
|
+
specs = []
|
|
17
|
+
|
|
18
|
+
specs.concat(parse_option(route, :http_codes)) if route.options[:http_codes]
|
|
19
|
+
specs.concat(parse_option(route, :failure)) if route.options[:failure]
|
|
20
|
+
specs.concat(parse_option(route, :success)) if route.options[:success]
|
|
21
|
+
|
|
22
|
+
specs
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_option(route, option_key)
|
|
28
|
+
value = route.options[option_key]
|
|
29
|
+
return [] unless value
|
|
30
|
+
|
|
31
|
+
items = value.is_a?(Hash) ? [value] : Array(value)
|
|
32
|
+
items.map { |entry| normalize_entry(entry, route) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_entry(entry, route)
|
|
36
|
+
case entry
|
|
37
|
+
when Hash
|
|
38
|
+
normalize_hash_entry(entry, route)
|
|
39
|
+
when Array
|
|
40
|
+
normalize_array_entry(entry, route)
|
|
41
|
+
else
|
|
42
|
+
normalize_plain_entry(entry, route)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def normalize_hash_entry(entry, route)
|
|
47
|
+
default_code = (route.options[:default_status] || 200).to_s
|
|
48
|
+
{
|
|
49
|
+
code: extract_status_code(entry, default_code),
|
|
50
|
+
message: extract_description(entry),
|
|
51
|
+
entity: extract_entity(entry, route.options[:entity]),
|
|
52
|
+
headers: entry[:headers],
|
|
53
|
+
examples: entry[:examples],
|
|
54
|
+
as: entry[:as],
|
|
55
|
+
is_array: entry[:is_array] || route.options[:is_array],
|
|
56
|
+
required: entry[:required]
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_array_entry(entry, route)
|
|
61
|
+
return normalize_plain_entry(nil, route) if entry.empty?
|
|
62
|
+
|
|
63
|
+
code, message, entity, examples = entry
|
|
64
|
+
{
|
|
65
|
+
code: code,
|
|
66
|
+
message: message,
|
|
67
|
+
entity: entity || route.options[:entity],
|
|
68
|
+
headers: nil,
|
|
69
|
+
examples: examples
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_plain_entry(entry, route)
|
|
74
|
+
# Plain status code (e.g., 404)
|
|
75
|
+
{
|
|
76
|
+
code: entry,
|
|
77
|
+
message: nil,
|
|
78
|
+
entity: route.options[:entity],
|
|
79
|
+
headers: nil
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
# Central location for constants used throughout the gem
|
|
5
|
+
module Constants
|
|
6
|
+
# OpenAPI/JSON Schema type strings
|
|
7
|
+
module SchemaTypes
|
|
8
|
+
STRING = "string"
|
|
9
|
+
INTEGER = "integer"
|
|
10
|
+
NUMBER = "number"
|
|
11
|
+
BOOLEAN = "boolean"
|
|
12
|
+
OBJECT = "object"
|
|
13
|
+
ARRAY = "array"
|
|
14
|
+
FILE = "file"
|
|
15
|
+
|
|
16
|
+
ALL = [STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY, FILE].freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Common MIME types
|
|
20
|
+
module MimeTypes
|
|
21
|
+
JSON = "application/json"
|
|
22
|
+
XML = "application/xml"
|
|
23
|
+
FORM_URLENCODED = "application/x-www-form-urlencoded"
|
|
24
|
+
MULTIPART_FORM = "multipart/form-data"
|
|
25
|
+
|
|
26
|
+
ALL = [JSON, XML, FORM_URLENCODED, MULTIPART_FORM].freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Default values for OpenAPI spec when not provided by user
|
|
30
|
+
module Defaults
|
|
31
|
+
LICENSE_NAME = "Proprietary"
|
|
32
|
+
LICENSE_URL = "https://example.com/license"
|
|
33
|
+
LICENSE_IDENTIFIER = "UNLICENSED"
|
|
34
|
+
SERVER_URL = "https://api.example.com"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ruby class to schema type mapping.
|
|
38
|
+
# Used for automatic type inference from parameter declarations.
|
|
39
|
+
# Note: String is not included as it's the default fallback.
|
|
40
|
+
RUBY_TYPE_MAPPING = {
|
|
41
|
+
Integer => SchemaTypes::INTEGER,
|
|
42
|
+
Float => SchemaTypes::NUMBER,
|
|
43
|
+
BigDecimal => SchemaTypes::NUMBER,
|
|
44
|
+
TrueClass => SchemaTypes::BOOLEAN,
|
|
45
|
+
FalseClass => SchemaTypes::BOOLEAN,
|
|
46
|
+
Array => SchemaTypes::ARRAY,
|
|
47
|
+
Hash => SchemaTypes::OBJECT,
|
|
48
|
+
File => SchemaTypes::FILE
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# String type name to schema type mapping (lowercase).
|
|
52
|
+
# Supports lookup with any case via primitive_type helper.
|
|
53
|
+
# Note: float and bigdecimal both map to NUMBER as they represent
|
|
54
|
+
# the same OpenAPI numeric type.
|
|
55
|
+
PRIMITIVE_TYPE_MAPPING = {
|
|
56
|
+
"float" => SchemaTypes::NUMBER,
|
|
57
|
+
"bigdecimal" => SchemaTypes::NUMBER,
|
|
58
|
+
"string" => SchemaTypes::STRING,
|
|
59
|
+
"integer" => SchemaTypes::INTEGER,
|
|
60
|
+
"number" => SchemaTypes::NUMBER,
|
|
61
|
+
"boolean" => SchemaTypes::BOOLEAN,
|
|
62
|
+
"grape::api::boolean" => SchemaTypes::BOOLEAN,
|
|
63
|
+
"trueclass" => SchemaTypes::BOOLEAN,
|
|
64
|
+
"falseclass" => SchemaTypes::BOOLEAN,
|
|
65
|
+
"array" => SchemaTypes::ARRAY,
|
|
66
|
+
"hash" => SchemaTypes::OBJECT,
|
|
67
|
+
"object" => SchemaTypes::OBJECT,
|
|
68
|
+
"file" => SchemaTypes::FILE,
|
|
69
|
+
"rack::multipart::uploadedfile" => SchemaTypes::FILE
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
# Resolves a primitive type name to its OpenAPI schema type.
|
|
73
|
+
# Normalizes the key to lowercase for consistent lookup.
|
|
74
|
+
#
|
|
75
|
+
# @param key [String, Symbol] The type name to resolve
|
|
76
|
+
# @return [String, nil] The OpenAPI schema type or nil if not found
|
|
77
|
+
def self.primitive_type(key)
|
|
78
|
+
PRIMITIVE_TYPE_MAPPING[key.to_s.downcase]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module DocumentationExtension
|
|
5
|
+
# Primary entry for grape-oas documentation
|
|
6
|
+
def add_oas_documentation(**options)
|
|
7
|
+
return if options.delete(:hide_documentation_path)
|
|
8
|
+
|
|
9
|
+
# Prefer grape-oas namespaced options to avoid clashing with grape-swagger
|
|
10
|
+
default_mount_path = options.delete(:oas_mount_path) ||
|
|
11
|
+
options.delete(:mount_path) ||
|
|
12
|
+
"/swagger_doc"
|
|
13
|
+
default_format = options.delete(:oas_doc_version) ||
|
|
14
|
+
options.delete(:doc_version) ||
|
|
15
|
+
:oas3
|
|
16
|
+
cache_control = options.delete(:cache_control)
|
|
17
|
+
etag_value = options.delete(:etag)
|
|
18
|
+
|
|
19
|
+
mount_paths = {
|
|
20
|
+
default: default_mount_path,
|
|
21
|
+
oas2: options.delete(:oas_mount_path_v2) || options.delete(:mount_path_v2),
|
|
22
|
+
oas3: options.delete(:oas_mount_path_v3) || options.delete(:mount_path_v3)
|
|
23
|
+
}.compact
|
|
24
|
+
|
|
25
|
+
api = self
|
|
26
|
+
|
|
27
|
+
mount_paths.each do |key, path|
|
|
28
|
+
add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api)
|
|
33
|
+
# Main route without namespace filter
|
|
34
|
+
add_route(path) do
|
|
35
|
+
GrapeOAS::DocumentationExtension.generate_documentation(
|
|
36
|
+
self, api, key, default_format, cache_control, etag_value, options, nil,
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Route with namespace filter: /swagger_doc/:namespace
|
|
41
|
+
# Supports nested namespaces via *namespace (catches slashes)
|
|
42
|
+
add_route("#{path}/*namespace") do
|
|
43
|
+
namespace_filter = params[:namespace]&.sub(/\.json$/, "")
|
|
44
|
+
GrapeOAS::DocumentationExtension.generate_documentation(
|
|
45
|
+
self, api, key, default_format, cache_control, etag_value, options, namespace_filter,
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.generate_documentation(endpoint, api, key, default_format, cache_control, etag_value, options, namespace_filter)
|
|
51
|
+
schema_type = if key == :oas2
|
|
52
|
+
:oas2
|
|
53
|
+
elsif key == :oas3
|
|
54
|
+
:oas3
|
|
55
|
+
else
|
|
56
|
+
parse_schema_type(endpoint.params[:oas]) || default_format
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
endpoint.header("Cache-Control", cache_control) if cache_control
|
|
60
|
+
endpoint.header("ETag", etag_value) if etag_value
|
|
61
|
+
|
|
62
|
+
# Resolve runtime options (like grape-swagger's OptionalObject)
|
|
63
|
+
runtime_options = options.dup
|
|
64
|
+
runtime_options[:host] = resolve_option(
|
|
65
|
+
runtime_options[:host], endpoint.request, :host_with_port,
|
|
66
|
+
)
|
|
67
|
+
runtime_options[:base_path] = resolve_option(
|
|
68
|
+
runtime_options[:base_path], endpoint.request, :script_name,
|
|
69
|
+
)
|
|
70
|
+
runtime_options[:namespace] = namespace_filter if namespace_filter
|
|
71
|
+
|
|
72
|
+
GrapeOAS.generate(app: api, schema_type: schema_type, **runtime_options)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Compatibility shim for apps calling grape-swagger's add_swagger_documentation.
|
|
76
|
+
#
|
|
77
|
+
# If grape-swagger is loaded we defer to its implementation to keep legacy
|
|
78
|
+
# behaviour untouched. Only when grape-swagger is absent do we fall back to
|
|
79
|
+
# grape-oas.
|
|
80
|
+
def add_swagger_documentation(**options)
|
|
81
|
+
return super if defined?(::GrapeSwagger)
|
|
82
|
+
|
|
83
|
+
options = options.dup
|
|
84
|
+
options[:oas_doc_version] ||= :oas2
|
|
85
|
+
options[:oas_mount_path] ||= options[:mount_path] || "/swagger_doc.json"
|
|
86
|
+
add_oas_documentation(**options)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Minimal route mounting helper
|
|
92
|
+
def add_route(path, &block)
|
|
93
|
+
api_class = self
|
|
94
|
+
namespace do
|
|
95
|
+
get(path) { instance_exec(api_class, &block) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_schema_type(value)
|
|
100
|
+
case value&.to_s
|
|
101
|
+
when "2", "oas2", "swagger"
|
|
102
|
+
:oas2
|
|
103
|
+
when "3.1", "31", "oas31", "oas3_1", "openapi31"
|
|
104
|
+
:oas31
|
|
105
|
+
when "3", "oas3", "openapi", "oas30", "oas3_0", "openapi30"
|
|
106
|
+
:oas3
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
module_function :parse_schema_type
|
|
110
|
+
|
|
111
|
+
# Resolve option value at request time (like grape-swagger's OptionalObject)
|
|
112
|
+
# Supports: static values, Proc/lambda (with optional request arg), or fallback to request method
|
|
113
|
+
def resolve_option(value, request, default_method)
|
|
114
|
+
if value.is_a?(Proc)
|
|
115
|
+
value.arity.zero? ? value.call : value.call(request)
|
|
116
|
+
elsif value
|
|
117
|
+
value
|
|
118
|
+
elsif request
|
|
119
|
+
request.send(default_method)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
module_function :resolve_option
|
|
123
|
+
end
|
|
124
|
+
end
|