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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "concerns/tag_builder"
4
+ require_relative "concerns/schema_indexer"
5
+
6
+ module GrapeOAS
7
+ module Exporter
8
+ class OAS2Schema
9
+ include Concerns::TagBuilder
10
+ include Concerns::SchemaIndexer
11
+
12
+ def initialize(api_model:)
13
+ @api = api_model
14
+ @ref_tracker = Set.new
15
+ @ref_schemas = {}
16
+ end
17
+
18
+ def generate
19
+ {
20
+ "swagger" => "2.0",
21
+ "info" => build_info,
22
+ "host" => build_host,
23
+ "basePath" => build_base_path,
24
+ "schemes" => build_schemes,
25
+ "consumes" => build_consumes,
26
+ "produces" => build_produces,
27
+ "tags" => build_tags,
28
+ "paths" => build_paths,
29
+ "definitions" => build_definitions,
30
+ "securityDefinitions" => build_security_definitions,
31
+ "security" => build_security
32
+ }.compact
33
+ end
34
+
35
+ private
36
+
37
+ def build_info
38
+ {
39
+ "title" => @api.title,
40
+ "version" => @api.version
41
+ }
42
+ end
43
+
44
+ def build_host
45
+ @api.host
46
+ end
47
+
48
+ def build_base_path
49
+ @api.base_path
50
+ end
51
+
52
+ def build_schemes
53
+ Array(@api.schemes).presence
54
+ end
55
+
56
+ def build_consumes
57
+ media_types = @api.paths.flat_map do |path|
58
+ path.operations.flat_map { |op| op.consumes || [] }
59
+ end.uniq
60
+
61
+ media_types.empty? ? [Constants::MimeTypes::JSON] : media_types
62
+ end
63
+
64
+ def build_produces
65
+ media_types = @api.paths.flat_map do |path|
66
+ path.operations.flat_map { |op| op.produces || [] }
67
+ end.uniq
68
+
69
+ media_types.empty? ? [Constants::MimeTypes::JSON] : media_types
70
+ end
71
+
72
+ def build_paths
73
+ OAS2::Paths.new(@api, @ref_tracker,
74
+ suppress_default_error_response: @api.suppress_default_error_response,).build
75
+ end
76
+
77
+ def build_schema_or_ref(schema)
78
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
79
+ ref_name = schema.canonical_name.gsub("::", "_")
80
+ @ref_tracker << schema.canonical_name
81
+ { "$ref" => "#/definitions/#{ref_name}" }
82
+ else
83
+ build_schema(schema)
84
+ end
85
+ end
86
+
87
+ def build_schema(schema)
88
+ OAS2::Schema.new(schema, @ref_tracker).build
89
+ end
90
+
91
+ def build_definitions
92
+ definitions = {}
93
+ pending = @ref_tracker.to_a
94
+ processed = Set.new
95
+
96
+ # Add pre-registered models to pending
97
+ Array(@api.registered_schemas).each do |schema|
98
+ pending << schema.canonical_name if schema.respond_to?(:canonical_name) && schema.canonical_name
99
+ end
100
+
101
+ until pending.empty?
102
+ canonical_name = pending.shift
103
+ next if processed.include?(canonical_name)
104
+
105
+ processed << canonical_name
106
+
107
+ ref_name = canonical_name.gsub("::", "_")
108
+ schema = find_schema_by_canonical_name(canonical_name)
109
+ definitions[ref_name] = OAS2::Schema.new(schema, @ref_tracker).build if schema
110
+ collect_refs(schema, pending) if schema
111
+
112
+ @ref_tracker.to_a.each do |cn|
113
+ pending << cn unless processed.include?(cn) || pending.include?(cn)
114
+ end
115
+ end
116
+
117
+ definitions
118
+ end
119
+
120
+ def build_security_definitions
121
+ return nil if @api.security_definitions.nil? || @api.security_definitions.empty?
122
+
123
+ @api.security_definitions
124
+ end
125
+
126
+ def build_security
127
+ return nil if @api.security.nil? || @api.security.empty?
128
+
129
+ @api.security
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS3
6
+ # OAS3-specific Operation exporter
7
+ # Inherits common operation logic from Base::Operation
8
+ class Operation < Base::Operation
9
+ private
10
+
11
+ # OAS3-specific fields: parameters (no body), requestBody, responses
12
+ def build_version_specific_fields
13
+ nullable_keyword = @options.key?(:nullable_keyword) ? @options[:nullable_keyword] : true
14
+
15
+ {
16
+ "parameters" => Parameter.new(@op, @ref_tracker, nullable_keyword: nullable_keyword).build,
17
+ "requestBody" => RequestBody.new(@op.request_body, @ref_tracker, nullable_keyword: nullable_keyword).build,
18
+ "responses" => Response.new(@op.responses, @ref_tracker, nullable_keyword: nullable_keyword).build
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS3
6
+ class Parameter
7
+ def initialize(operation, ref_tracker = nil, nullable_keyword: true)
8
+ @op = operation
9
+ @ref_tracker = ref_tracker
10
+ @nullable_keyword = nullable_keyword
11
+ end
12
+
13
+ def build
14
+ Array(@op.parameters).map do |param|
15
+ {
16
+ "name" => param.name,
17
+ "in" => param.location,
18
+ "required" => param.required,
19
+ "description" => param.description,
20
+ "schema" => Schema.new(param.schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
21
+ }.compact
22
+ end.presence
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS3
6
+ # OAS3-specific Paths exporter
7
+ # Inherits common path building logic from Base::Paths
8
+ class Paths < Base::Paths
9
+ private
10
+
11
+ # Build OAS3-specific operation with nullable_keyword option
12
+ def build_operation(operation)
13
+ nullable_keyword = @options.key?(:nullable_keyword) ? @options[:nullable_keyword] : true
14
+ Operation.new(operation, @ref_tracker,
15
+ nullable_keyword: nullable_keyword,
16
+ suppress_default_error_response: @options[:suppress_default_error_response],).build
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS3
6
+ class RequestBody
7
+ def initialize(request_body, ref_tracker = nil, nullable_keyword: true)
8
+ @request_body = request_body
9
+ @ref_tracker = ref_tracker
10
+ @nullable_keyword = nullable_keyword
11
+ end
12
+
13
+ def build
14
+ return nil unless @request_body
15
+
16
+ data = {
17
+ "description" => @request_body.description,
18
+ "required" => @request_body.required,
19
+ "content" => build_content(@request_body.media_types)
20
+ }.compact
21
+
22
+ data.merge!(@request_body.extensions) if @request_body.extensions&.any?
23
+ data
24
+ end
25
+
26
+ private
27
+
28
+ def build_content(media_types)
29
+ return nil unless media_types
30
+
31
+ media_types.each_with_object({}) do |mt, h|
32
+ schema_entry = build_schema_or_ref(mt.schema)
33
+ entry = {
34
+ "schema" => schema_entry,
35
+ "examples" => mt.examples
36
+ }.compact
37
+ entry.merge!(mt.extensions) if mt.extensions&.any?
38
+ h[mt.mime_type] = entry
39
+ end
40
+ end
41
+
42
+ def build_schema_or_ref(schema)
43
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
44
+ @ref_tracker << schema.canonical_name if @ref_tracker
45
+ ref_name = schema.canonical_name.gsub("::", "_")
46
+ { "$ref" => "#/components/schemas/#{ref_name}" }
47
+ else
48
+ Schema.new(schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS3
6
+ class Response
7
+ def initialize(responses, ref_tracker = nil, nullable_keyword: true)
8
+ @responses = responses
9
+ @ref_tracker = ref_tracker
10
+ @nullable_keyword = nullable_keyword
11
+ end
12
+
13
+ def build
14
+ @responses.each_with_object({}) do |resp, h|
15
+ h[resp.http_status] = {
16
+ "description" => resp.description || "Response",
17
+ "headers" => build_headers(resp.headers),
18
+ "content" => build_content(resp.media_types, resp.examples)
19
+ }.compact
20
+ h[resp.http_status].merge!(resp.extensions) if resp.extensions
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_headers(headers)
27
+ return nil unless headers && !headers.empty?
28
+
29
+ headers.each_with_object({}) do |hdr, h|
30
+ name = hdr[:name] || hdr["name"] || hdr[:key] || hdr["key"]
31
+ next unless name
32
+
33
+ # OAS3 requires headers to have schema wrapper and optional description
34
+ schema_value = hdr[:schema] || hdr["schema"] || {}
35
+ schema_type = schema_value["type"] || schema_value[:type] || Constants::SchemaTypes::STRING
36
+ description = hdr[:description] || hdr["description"] || schema_value["description"]
37
+
38
+ header_obj = { "schema" => { "type" => schema_type } }
39
+ header_obj["description"] = description if description
40
+ h[name] = header_obj
41
+ end
42
+ end
43
+
44
+ def build_content(media_types, response_examples = nil)
45
+ return nil unless media_types
46
+
47
+ media_types.each_with_object({}) do |mt, h|
48
+ entry = { "schema" => build_schema_or_ref(mt.schema) }
49
+ # OAS3: use "example" for single value, "examples" for named examples with value wrapper
50
+ # Media type examples take precedence over response-level examples
51
+ examples = mt.examples || response_examples
52
+ add_examples_to_entry(entry, examples)
53
+ h[mt.mime_type] = entry.compact
54
+ end
55
+ end
56
+
57
+ # OAS3 examples must be wrapped as { name => { "value" => ... } }
58
+ # Use "example" (singular) for simple cases, "examples" for multiple/named
59
+ def add_examples_to_entry(entry, examples)
60
+ return unless examples
61
+
62
+ if examples.is_a?(Hash) && examples.keys.all? { |k| k.is_a?(String) || k.is_a?(Symbol) }
63
+ # Named examples - wrap each value if not already wrapped
64
+ entry["examples"] = examples.transform_values do |v|
65
+ v.is_a?(Hash) && v.key?("value") ? v : { "value" => v }
66
+ end
67
+ else
68
+ # Single example value
69
+ entry["example"] = examples
70
+ end
71
+ end
72
+
73
+ def build_schema_or_ref(schema)
74
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
75
+ @ref_tracker << schema.canonical_name if @ref_tracker
76
+ ref_name = schema.canonical_name.gsub("::", "_")
77
+ { "$ref" => "#/components/schemas/#{ref_name}" }
78
+ else
79
+ Schema.new(schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS3
6
+ class Schema
7
+ def initialize(schema, ref_tracker = nil, nullable_keyword: true)
8
+ @schema = schema
9
+ @ref_tracker = ref_tracker
10
+ @nullable_keyword = nullable_keyword
11
+ end
12
+
13
+ def build
14
+ return {} unless @schema
15
+ return build_all_of_schema if @schema.all_of && !@schema.all_of.empty?
16
+ return build_one_of_schema if @schema.one_of && !@schema.one_of.empty?
17
+ return build_any_of_schema if @schema.any_of && !@schema.any_of.empty?
18
+
19
+ schema_hash = build_base_hash
20
+ apply_examples(schema_hash)
21
+ sanitize_enum_against_type(schema_hash)
22
+ apply_extensions_and_extra_properties(schema_hash)
23
+ apply_all_constraints(schema_hash)
24
+ schema_hash.compact
25
+ end
26
+
27
+ def build_base_hash
28
+ schema_hash = {}
29
+ schema_hash["type"] = nullable_type
30
+ schema_hash["format"] = @schema.format
31
+ schema_hash["description"] = @schema.description.to_s if @schema.description
32
+ props = build_properties(@schema.properties)
33
+ schema_hash["properties"] = props if props
34
+ schema_hash["items"] = @schema.items ? build_schema_or_ref(@schema.items) : nil
35
+ schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
36
+ schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"]) if @schema.enum
37
+ schema_hash
38
+ end
39
+
40
+ def apply_examples(schema_hash)
41
+ return unless @schema.examples
42
+
43
+ examples = Array(@schema.examples).map { |ex| coerce_example(ex, schema_hash["type"]) }
44
+ schema_hash["example"] = examples.first
45
+ end
46
+
47
+ def apply_extensions_and_extra_properties(schema_hash)
48
+ schema_hash.merge!(@schema.extensions) if @schema.extensions
49
+ schema_hash.delete("properties") if schema_hash["properties"]&.empty? || @schema.type != Constants::SchemaTypes::OBJECT
50
+ schema_hash["additionalProperties"] = @schema.additional_properties unless @schema.additional_properties.nil?
51
+ if !@nullable_keyword && !@schema.unevaluated_properties.nil?
52
+ schema_hash["unevaluatedProperties"] = @schema.unevaluated_properties
53
+ end
54
+ schema_hash["$defs"] = @schema.defs if !@nullable_keyword && @schema.defs && !@schema.defs.empty?
55
+ schema_hash["discriminator"] = build_discriminator if @schema.discriminator
56
+ end
57
+
58
+ def apply_all_constraints(schema_hash)
59
+ apply_numeric_constraints(schema_hash)
60
+ apply_string_constraints(schema_hash)
61
+ apply_array_constraints(schema_hash)
62
+ end
63
+
64
+ private
65
+
66
+ # Build allOf schema for inheritance
67
+ def build_all_of_schema
68
+ all_of_items = @schema.all_of.map do |item|
69
+ build_schema_or_ref(item)
70
+ end
71
+
72
+ result = { "allOf" => all_of_items }
73
+ result["description"] = @schema.description.to_s if @schema.description
74
+ result
75
+ end
76
+
77
+ # Build oneOf schema for polymorphism
78
+ def build_one_of_schema
79
+ one_of_items = @schema.one_of.map do |item|
80
+ build_schema_or_ref(item)
81
+ end
82
+
83
+ result = { "oneOf" => one_of_items }
84
+ result["description"] = @schema.description.to_s if @schema.description
85
+ result["discriminator"] = build_discriminator if @schema.discriminator
86
+ result
87
+ end
88
+
89
+ # Build anyOf schema for polymorphism
90
+ def build_any_of_schema
91
+ any_of_items = @schema.any_of.map do |item|
92
+ build_schema_or_ref(item)
93
+ end
94
+
95
+ result = { "anyOf" => any_of_items }
96
+ result["description"] = @schema.description.to_s if @schema.description
97
+ result["discriminator"] = build_discriminator if @schema.discriminator
98
+ result
99
+ end
100
+
101
+ # Build OAS3 discriminator object
102
+ def build_discriminator
103
+ return nil unless @schema.discriminator
104
+
105
+ if @schema.discriminator.is_a?(Hash)
106
+ # Already in object format with propertyName and optional mapping
107
+ disc = { "propertyName" => @schema.discriminator[:property_name] || @schema.discriminator["propertyName"] }
108
+ mapping = @schema.discriminator[:mapping] || @schema.discriminator["mapping"]
109
+ disc["mapping"] = mapping if mapping && !mapping.empty?
110
+ disc
111
+ else
112
+ # Simple string - convert to object format
113
+ { "propertyName" => @schema.discriminator.to_s }
114
+ end
115
+ end
116
+
117
+ def nullable_type
118
+ return @schema.type unless @schema.respond_to?(:nullable) && @schema.nullable
119
+
120
+ if @nullable_keyword
121
+ # OAS3.0 style
122
+ type_hash = { "type" => @schema.type, "nullable" => true }
123
+ return type_hash["type"] if @schema.type.nil?
124
+ return type_hash["type"] if @schema.type.is_a?(Array)
125
+
126
+ type_hash["type"]
127
+ else
128
+ base = Array(@schema.type || Constants::SchemaTypes::STRING)
129
+ (base | ["null"])
130
+ end
131
+ end
132
+
133
+ def build_properties(properties)
134
+ return nil unless properties
135
+ return nil if properties.empty?
136
+
137
+ properties.transform_values do |prop_schema|
138
+ build_schema_or_ref(prop_schema)
139
+ end
140
+ end
141
+
142
+ def build_schema_or_ref(schema)
143
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
144
+ @ref_tracker << schema.canonical_name if @ref_tracker
145
+ ref_name = schema.canonical_name.gsub("::", "_")
146
+ { "$ref" => "#/components/schemas/#{ref_name}" }
147
+ else
148
+ Schema.new(schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
149
+ end
150
+ end
151
+
152
+ def normalize_enum(enum_vals, type)
153
+ return nil unless enum_vals.is_a?(Array)
154
+
155
+ coerced = enum_vals.map do |v|
156
+ case type
157
+ when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
158
+ when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
159
+ else v
160
+ end
161
+ end.compact
162
+
163
+ result = coerced.uniq
164
+ return nil if result.empty?
165
+
166
+ result
167
+ end
168
+
169
+ def apply_numeric_constraints(hash)
170
+ hash["minimum"] = @schema.minimum if @schema.minimum
171
+ hash["maximum"] = @schema.maximum if @schema.maximum
172
+
173
+ if @nullable_keyword
174
+ hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
175
+ hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
176
+ else
177
+ if @schema.exclusive_minimum && @schema.minimum
178
+ hash["exclusiveMinimum"] = @schema.minimum
179
+ hash.delete("minimum")
180
+ end
181
+ if @schema.exclusive_maximum && @schema.maximum
182
+ hash["exclusiveMaximum"] = @schema.maximum
183
+ hash.delete("maximum")
184
+ end
185
+ end
186
+ end
187
+
188
+ def apply_string_constraints(hash)
189
+ hash["minLength"] = @schema.min_length if @schema.min_length
190
+ hash["maxLength"] = @schema.max_length if @schema.max_length
191
+ hash["pattern"] = @schema.pattern if @schema.pattern
192
+ end
193
+
194
+ def apply_array_constraints(hash)
195
+ hash["minItems"] = @schema.min_items if @schema.min_items
196
+ hash["maxItems"] = @schema.max_items if @schema.max_items
197
+ end
198
+
199
+ # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs
200
+ def sanitize_enum_against_type(hash)
201
+ enum_vals = hash["enum"]
202
+ type_val = hash["type"]
203
+ return unless enum_vals && type_val
204
+
205
+ base_type = if type_val.is_a?(Array)
206
+ (type_val - ["null"]).first
207
+ else
208
+ type_val
209
+ end
210
+
211
+ # Remove enum for unsupported base types or mismatches
212
+ case base_type
213
+ when Constants::SchemaTypes::ARRAY, Constants::SchemaTypes::OBJECT, nil
214
+ hash.delete("enum")
215
+ when Constants::SchemaTypes::INTEGER
216
+ hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(Integer) }
217
+ when Constants::SchemaTypes::NUMBER
218
+ hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(Numeric) }
219
+ when Constants::SchemaTypes::BOOLEAN
220
+ hash.delete("enum") unless enum_vals.all? { |v| [true, false].include?(v) }
221
+ else # string and fallback
222
+ hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(String) }
223
+ end
224
+ end
225
+
226
+ def coerce_example(example, type_val)
227
+ base_type = if type_val.is_a?(Array)
228
+ (type_val - ["null"]).first
229
+ else
230
+ type_val
231
+ end
232
+
233
+ case base_type
234
+ when Constants::SchemaTypes::INTEGER
235
+ example.to_i
236
+ when Constants::SchemaTypes::NUMBER
237
+ example.to_f
238
+ when Constants::SchemaTypes::BOOLEAN
239
+ example == true || example.to_s == "true"
240
+ when Constants::SchemaTypes::STRING, nil
241
+ example.to_s
242
+ else
243
+ example
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ class OAS30Schema < OAS3Schema
6
+ private
7
+
8
+ def openapi_version
9
+ "3.0.0"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS31
6
+ # OAS3.1-specific Schema exporter
7
+ # Differs from OAS3 by preferring `examples` over deprecated `example`.
8
+ class Schema < OAS3::Schema
9
+ def openapi_version
10
+ "3.1.0"
11
+ end
12
+
13
+ def build
14
+ hash = super
15
+
16
+ # swap example -> examples if present
17
+ if hash.key?("example")
18
+ ex = hash.delete("example")
19
+ hash["examples"] ||= ex
20
+ end
21
+ normalize_examples!(hash)
22
+ hash
23
+ end
24
+
25
+ private
26
+
27
+ # Ensure examples is always an array and recurse into nested schemas
28
+ def normalize_examples!(hash)
29
+ hash["examples"] = [hash["examples"]].compact if hash.key?("examples") && !hash["examples"].is_a?(Array)
30
+
31
+ if hash.key?("properties") && hash["properties"].is_a?(Hash)
32
+ hash["properties"].each_value { |v| normalize_examples!(v) if v.is_a?(Hash) }
33
+ end
34
+
35
+ return unless hash.key?("items") && hash["items"].is_a?(Hash)
36
+
37
+ normalize_examples!(hash["items"])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ class OAS31Schema < OAS3Schema
6
+ private
7
+
8
+ def openapi_version
9
+ "3.1.0"
10
+ end
11
+
12
+ def build_info
13
+ info = super
14
+ license = if @api.respond_to?(:license) && @api.license
15
+ @api.license
16
+ else
17
+ # OAS 3.1 requires exactly one of 'identifier' OR 'url' (not both)
18
+ { "name" => Constants::Defaults::LICENSE_NAME,
19
+ "identifier" => Constants::Defaults::LICENSE_IDENTIFIER }
20
+ end
21
+ info["license"] = license
22
+ info
23
+ end
24
+
25
+ def schema_builder
26
+ OAS31::Schema
27
+ end
28
+
29
+ def nullable_keyword?
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end