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,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