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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module Base
6
+ # Base class for Operation exporters
7
+ # Contains common logic shared between OAS2 and OAS3
8
+ class Operation
9
+ def initialize(operation, ref_tracker = nil, **options)
10
+ @op = operation
11
+ @ref_tracker = ref_tracker
12
+ @options = options
13
+ end
14
+
15
+ def build
16
+ data = build_common_fields
17
+
18
+ # Add version-specific fields
19
+ data.merge!(build_version_specific_fields)
20
+
21
+ # Add security if present
22
+ data["security"] = @op.security unless @op.security.nil? || @op.security.empty?
23
+
24
+ # Guarantee a 4xx response for lint rules
25
+ ensure_default_error_response(data)
26
+
27
+ # Merge extensions
28
+ data.merge!(@op.extensions) if @op.extensions&.any?
29
+
30
+ data.compact
31
+ end
32
+
33
+ private
34
+
35
+ # Common fields present in both OAS2 and OAS3
36
+ def build_common_fields
37
+ summary = @op.summary
38
+ summary ||= @op.description&.split(/\.\s/)&.first&.strip
39
+ # Fallback: generate a readable summary from the operationId to satisfy lint rules
40
+ if summary.nil? && @op.operation_id
41
+ summary = @op.operation_id
42
+ .to_s
43
+ .tr("_", " ")
44
+ .split
45
+ .map(&:capitalize)
46
+ .join(" ")
47
+ end
48
+
49
+ {
50
+ "operationId" => @op.operation_id,
51
+ "summary" => summary,
52
+ "description" => @op.description,
53
+ "deprecated" => @op.deprecated,
54
+ "tags" => @op.tag_names
55
+ }
56
+ end
57
+
58
+ # Template method - subclasses must implement version-specific fields
59
+ # @return [Hash] Version-specific fields (e.g., consumes/produces for OAS2, requestBody for OAS3)
60
+ def build_version_specific_fields
61
+ raise NotImplementedError, "#{self.class} must implement #build_version_specific_fields"
62
+ end
63
+
64
+ # Ensure there is at least one 4xx response when any responses exist
65
+ # Can be suppressed per-operation via suppress_default_error_response flag
66
+ def ensure_default_error_response(data)
67
+ # Check operation-level suppression
68
+ return data if @op.respond_to?(:suppress_default_error_response) && @op.suppress_default_error_response
69
+
70
+ # Check global suppression
71
+ return data if @options[:suppress_default_error_response]
72
+
73
+ responses = data["responses"]
74
+ return data unless responses && !responses.empty?
75
+
76
+ has_4xx = responses.keys.any? { |code| code.to_s.start_with?("4") }
77
+ return data if has_4xx
78
+
79
+ responses["400"] = {
80
+ "description" => "Bad Request"
81
+ }
82
+
83
+ data
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module Base
6
+ # Base class for Paths exporters
7
+ # Contains common logic shared between OAS2 and OAS3
8
+ class Paths
9
+ def initialize(source, ref_tracker = nil, **options)
10
+ @source = source
11
+ @ref_tracker = ref_tracker
12
+ @options = options
13
+ end
14
+
15
+ def build
16
+ if api?(@source)
17
+ build_paths(@source)
18
+ else
19
+ build_path_item(@source)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def api?(obj)
26
+ obj.respond_to?(:paths)
27
+ end
28
+
29
+ def build_paths(api)
30
+ paths = {}
31
+ api.paths.each do |path|
32
+ paths[path.template] = build_path_item(path)
33
+ end
34
+ paths
35
+ end
36
+
37
+ def build_path_item(path)
38
+ item = {}
39
+ path.operations.each do |operation|
40
+ item[operation.http_method] = build_operation(operation)
41
+ end
42
+ item
43
+ end
44
+
45
+ # Template method - subclasses must implement
46
+ # Returns the version-specific Operation instance
47
+ def build_operation(operation)
48
+ raise NotImplementedError, "#{self.class} must implement #build_operation"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module Concerns
6
+ # Shared schema indexing and reference collection logic for OAS2 and OAS3 schema exporters.
7
+ # Handles building schema indexes from operations and collecting nested schema references.
8
+ module SchemaIndexer
9
+ def find_schema_by_canonical_name(canonical_name)
10
+ @ref_schemas[canonical_name] || schema_index[canonical_name]
11
+ end
12
+
13
+ def schema_index
14
+ @schema_index ||= build_schema_index
15
+ end
16
+
17
+ def build_schema_index
18
+ index = {}
19
+ # Index schemas from operations
20
+ @api.paths.each do |path|
21
+ path.operations.each do |op|
22
+ collect_schemas_from_operation(op, index)
23
+ end
24
+ end
25
+ # Index pre-registered models
26
+ Array(@api.registered_schemas).each do |schema|
27
+ index_schema(schema, index)
28
+ end
29
+ index
30
+ end
31
+
32
+ def collect_schemas_from_operation(operation, index)
33
+ Array(operation.parameters).each do |param|
34
+ index_schema(param.schema, index)
35
+ end
36
+
37
+ if operation.request_body
38
+ Array(operation.request_body.media_types).each do |media_type|
39
+ index_schema(media_type.schema, index)
40
+ end
41
+ end
42
+
43
+ Array(operation.responses).each do |resp|
44
+ Array(resp.media_types).each do |media_type|
45
+ index_schema(media_type.schema, index)
46
+ end
47
+ end
48
+ end
49
+
50
+ def index_schema(schema, index)
51
+ return unless schema.respond_to?(:canonical_name) && schema.canonical_name
52
+
53
+ index[schema.canonical_name] ||= schema
54
+ end
55
+
56
+ def collect_refs(schema, pending, seen = Set.new)
57
+ return unless schema
58
+
59
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
60
+ return if seen.include?(schema.canonical_name)
61
+
62
+ seen << schema.canonical_name
63
+ @ref_schemas[schema.canonical_name] ||= schema
64
+ end
65
+
66
+ if schema.respond_to?(:properties) && schema.properties
67
+ schema.properties.each_value do |prop|
68
+ if prop.respond_to?(:canonical_name) && prop.canonical_name
69
+ pending << prop.canonical_name
70
+ @ref_schemas[prop.canonical_name] ||= prop
71
+ end
72
+ collect_refs(prop, pending, seen)
73
+ end
74
+ end
75
+ collect_refs(schema.items, pending, seen) if schema.respond_to?(:items) && schema.items
76
+
77
+ # Handle allOf/oneOf/anyOf composition (for inheritance/polymorphism)
78
+ %i[all_of one_of any_of].each do |composition_type|
79
+ next unless schema.respond_to?(composition_type) && schema.send(composition_type)
80
+
81
+ schema.send(composition_type).each do |sub_schema|
82
+ if sub_schema.respond_to?(:canonical_name) && sub_schema.canonical_name
83
+ pending << sub_schema.canonical_name
84
+ @ref_schemas[sub_schema.canonical_name] ||= sub_schema
85
+ end
86
+ collect_refs(sub_schema, pending, seen)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module Concerns
6
+ # Shared tag-building logic for OAS2 and OAS3 schema exporters.
7
+ # Extracts tag definitions from operations and normalizes tag formats.
8
+ module TagBuilder
9
+ def build_tags
10
+ used_tag_names = collect_used_tag_names
11
+ seen_names = Set.new
12
+ tags = Array(@api.tag_defs).filter_map do |tag|
13
+ normalized = normalize_tag(tag)
14
+ tag_name = normalized["name"]
15
+ # Only include tags that are actually used by operations and not already seen
16
+ next if seen_names.include?(tag_name) || !used_tag_names.include?(tag_name)
17
+
18
+ seen_names << tag_name
19
+ normalized
20
+ end
21
+ tags.empty? ? nil : tags
22
+ end
23
+
24
+ def normalize_tag(tag)
25
+ if tag.is_a?(Hash)
26
+ # Convert symbol keys to string keys
27
+ tag.transform_keys(&:to_s)
28
+ elsif tag.respond_to?(:name)
29
+ h = { "name" => tag.name.to_s }
30
+ h["description"] = tag.description if tag.respond_to?(:description)
31
+ h
32
+ else
33
+ name = tag.to_s
34
+ desc = if defined?(ActiveSupport::Inflector)
35
+ "Operations about #{ActiveSupport::Inflector.pluralize(name)}"
36
+ else
37
+ "Operations about #{name}s"
38
+ end
39
+ { "name" => name, "description" => desc }
40
+ end
41
+ end
42
+
43
+ def collect_used_tag_names
44
+ used_tags = Set.new
45
+ @api.paths.each do |path|
46
+ path.operations.each do |op|
47
+ Array(op.tag_names).each { |t| used_tags << t }
48
+ end
49
+ end
50
+ used_tags
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS2
6
+ # OAS2-specific Operation exporter
7
+ # Inherits common operation logic from Base::Operation
8
+ class Operation < Base::Operation
9
+ private
10
+
11
+ # OAS2-specific fields: consumes, produces, parameters (including body)
12
+ def build_version_specific_fields
13
+ {
14
+ "consumes" => consumes,
15
+ "produces" => produces,
16
+ "parameters" => Parameter.new(@op, @ref_tracker).build,
17
+ "responses" => Response.new(@op.responses, @ref_tracker).build
18
+ }
19
+ end
20
+
21
+ def consumes
22
+ Array(@op.consumes.presence || [Constants::MimeTypes::JSON])
23
+ end
24
+
25
+ def produces
26
+ Array(@op.produces.presence || [Constants::MimeTypes::JSON])
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS2
6
+ class Parameter
7
+ PRIMITIVE_MAPPINGS = {
8
+ Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER, format: "int32" },
9
+ "long" => { type: Constants::SchemaTypes::INTEGER, format: "int64" },
10
+ "float" => { type: Constants::SchemaTypes::NUMBER, format: "float" },
11
+ "double" => { type: Constants::SchemaTypes::NUMBER, format: "double" },
12
+ "byte" => { type: Constants::SchemaTypes::STRING, format: "byte" },
13
+ "date" => { type: Constants::SchemaTypes::STRING, format: "date" },
14
+ "dateTime" => { type: Constants::SchemaTypes::STRING, format: "date-time" },
15
+ "binary" => { type: Constants::SchemaTypes::STRING, format: "binary" },
16
+ "password" => { type: Constants::SchemaTypes::STRING, format: "password" },
17
+ "email" => { type: Constants::SchemaTypes::STRING, format: "email" },
18
+ "uuid" => { type: Constants::SchemaTypes::STRING, format: "uuid" }
19
+ }.freeze
20
+
21
+ def initialize(operation, ref_tracker = nil)
22
+ @op = operation
23
+ @ref_tracker = ref_tracker
24
+ end
25
+
26
+ def build
27
+ params = Array(@op.parameters).map { |param| build_parameter(param) }
28
+ params << build_body_parameter(@op.request_body) if @op.request_body
29
+ params
30
+ end
31
+
32
+ private
33
+
34
+ def build_parameter(param)
35
+ type = param.schema&.type
36
+ format = param.schema&.format
37
+ primitive_types = PRIMITIVE_MAPPINGS.keys + %w[object string boolean file json array]
38
+ is_primitive = type && primitive_types.include?(type)
39
+
40
+ if is_primitive && param.location != "body"
41
+ mapping = PRIMITIVE_MAPPINGS[type]
42
+ result = {
43
+ "name" => param.name,
44
+ "in" => param.location,
45
+ "required" => param.required,
46
+ "description" => param.description,
47
+ "type" => mapping ? mapping[:type] : type,
48
+ "format" => format || (mapping ? mapping[:format] : nil)
49
+ }
50
+ apply_collection_format(result, param, type)
51
+ result.compact
52
+ else
53
+ {
54
+ "name" => param.name,
55
+ "in" => param.location,
56
+ "required" => param.required,
57
+ "description" => param.description,
58
+ "schema" => build_schema_or_ref(param.schema)
59
+ }.tap do |h|
60
+ h["type"] = type if type
61
+ h["format"] = format if format
62
+ end.compact
63
+ end
64
+ end
65
+
66
+ def apply_collection_format(result, param, type)
67
+ return unless type == Constants::SchemaTypes::ARRAY
68
+ return unless param.collection_format
69
+
70
+ valid_formats = %w[csv ssv tsv pipes multi brackets]
71
+ result["collectionFormat"] = param.collection_format if valid_formats.include?(param.collection_format)
72
+ end
73
+
74
+ def build_body_parameter(request_body)
75
+ schema = build_body_schema(request_body)
76
+ name = derive_body_name(request_body)
77
+ {
78
+ "name" => name,
79
+ "in" => "body",
80
+ "required" => request_body.required,
81
+ "description" => request_body.description,
82
+ "schema" => schema
83
+ }.compact
84
+ end
85
+
86
+ def derive_body_name(request_body)
87
+ # Use explicit body_name if provided
88
+ return request_body.body_name if request_body.respond_to?(:body_name) && request_body.body_name
89
+
90
+ # Fall back to canonical name from schema
91
+ canonical = begin
92
+ request_body&.media_types&.first&.schema&.canonical_name
93
+ rescue NoMethodError
94
+ nil
95
+ end
96
+ canonical ? canonical.gsub("::", "_") : "body"
97
+ end
98
+
99
+ def build_body_schema(request_body)
100
+ mt = Array(request_body.media_types).first
101
+ mt ? build_schema_or_ref(mt.schema) : nil
102
+ end
103
+
104
+ def build_schema_or_ref(schema)
105
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
106
+ @ref_tracker << schema.canonical_name if @ref_tracker
107
+ ref_name = schema.canonical_name.gsub("::", "_")
108
+ { "$ref" => "#/definitions/#{ref_name}" }
109
+ else
110
+ Schema.new(schema, @ref_tracker).build
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS2
6
+ # OAS2-specific Paths exporter
7
+ # Inherits common path building logic from Base::Paths
8
+ class Paths < Base::Paths
9
+ private
10
+
11
+ # Build OAS2-specific operation
12
+ def build_operation(operation)
13
+ Operation.new(operation, @ref_tracker,
14
+ suppress_default_error_response: @options[:suppress_default_error_response],).build
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS2
6
+ class Response
7
+ def initialize(responses, ref_tracker = nil)
8
+ @responses = responses
9
+ @ref_tracker = ref_tracker
10
+ end
11
+
12
+ def build
13
+ res = {}
14
+ Array(@responses).each do |resp|
15
+ res[resp.http_status] = {
16
+ "description" => resp.description,
17
+ "schema" => build_response_schema(resp),
18
+ "headers" => build_headers(resp.headers),
19
+ "examples" => build_examples(resp.media_types, resp.examples)
20
+ }.compact
21
+ res[resp.http_status].merge!(resp.extensions) if resp.extensions
22
+ end
23
+ res
24
+ end
25
+
26
+ private
27
+
28
+ def build_response_schema(resp)
29
+ mt = Array(resp.media_types).first
30
+ mt ? build_schema_or_ref(mt.schema) : nil
31
+ end
32
+
33
+ def build_schema_or_ref(schema)
34
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
35
+ @ref_tracker << schema.canonical_name if @ref_tracker
36
+ ref_name = schema.canonical_name.gsub("::", "_")
37
+ { "$ref" => "#/definitions/#{ref_name}" }
38
+ else
39
+ Schema.new(schema, @ref_tracker).build
40
+ end
41
+ end
42
+
43
+ def build_headers(headers)
44
+ return nil unless headers && !headers.empty?
45
+
46
+ headers.each_with_object({}) do |hdr, h|
47
+ name = hdr[:name] || hdr["name"] || hdr[:key] || hdr["key"]
48
+ next unless name
49
+
50
+ # OAS2 headers have type at top level (not wrapped in schema)
51
+ schema_value = hdr[:schema] || hdr["schema"] || {}
52
+ schema_type = schema_value["type"] || schema_value[:type] || Constants::SchemaTypes::STRING
53
+ description = hdr[:description] || hdr["description"] || schema_value["description"]
54
+
55
+ header_obj = { "type" => schema_type }
56
+ header_obj["description"] = description if description
57
+ h[name] = header_obj
58
+ end
59
+ end
60
+
61
+ def build_examples(media_types, response_examples = nil)
62
+ return nil unless media_types
63
+
64
+ mt = Array(media_types).first
65
+ # Media type examples take precedence over response-level examples
66
+ examples = mt&.examples || response_examples
67
+ return nil unless examples
68
+
69
+ examples.is_a?(Hash) ? examples : { mt&.mime_type || "application/json" => examples }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module OAS2
6
+ class Schema
7
+ def initialize(schema, ref_tracker = nil)
8
+ @schema = schema
9
+ @ref_tracker = ref_tracker
10
+ end
11
+
12
+ def build
13
+ return {} unless @schema
14
+
15
+ # Handle allOf composition (for inheritance)
16
+ return build_all_of_schema if @schema.all_of && !@schema.all_of.empty?
17
+
18
+ # Handle oneOf/anyOf by using first type (OAS2 doesn't support oneOf/anyOf)
19
+ # Skip if schema has explicit type - use type with extensions instead
20
+ return build_first_of_schema(:one_of) if @schema.one_of && !@schema.one_of.empty? && !@schema.type
21
+ return build_first_of_schema(:any_of) if @schema.any_of && !@schema.any_of.empty? && !@schema.type
22
+
23
+ schema_hash = build_base_hash
24
+ apply_constraints(schema_hash)
25
+ apply_extensions(schema_hash)
26
+ schema_hash.compact
27
+ end
28
+
29
+ def build_base_hash
30
+ schema_hash = {
31
+ "type" => @schema.type,
32
+ "format" => @schema.format,
33
+ "description" => @schema.description&.to_s,
34
+ "properties" => build_properties(@schema.properties),
35
+ "items" => (@schema.items ? build_schema_or_ref(@schema.items) : nil),
36
+ "enum" => normalize_enum(@schema.enum, @schema.type)
37
+ }
38
+ if schema_hash["properties"].nil? || schema_hash["properties"].empty? || @schema.type != Constants::SchemaTypes::OBJECT
39
+ schema_hash.delete("properties")
40
+ end
41
+ schema_hash["example"] = @schema.examples if @schema.examples
42
+ schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
43
+ schema_hash["discriminator"] = @schema.discriminator if @schema.discriminator
44
+ schema_hash
45
+ end
46
+
47
+ def apply_constraints(schema_hash)
48
+ schema_hash["minLength"] = @schema.min_length if @schema.min_length
49
+ schema_hash["maxLength"] = @schema.max_length if @schema.max_length
50
+ schema_hash["pattern"] = @schema.pattern if @schema.pattern
51
+ schema_hash["minimum"] = @schema.minimum if @schema.minimum
52
+ schema_hash["maximum"] = @schema.maximum if @schema.maximum
53
+ schema_hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
54
+ schema_hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
55
+ schema_hash["minItems"] = @schema.min_items if @schema.min_items
56
+ schema_hash["maxItems"] = @schema.max_items if @schema.max_items
57
+ end
58
+
59
+ def apply_extensions(schema_hash)
60
+ schema_hash.merge!(@schema.extensions) if @schema.extensions
61
+ end
62
+
63
+ private
64
+
65
+ # Build schema from oneOf/anyOf by using first type (OAS2 doesn't support these)
66
+ # Extensions are merged to allow x-anyOf/x-oneOf for consumers that support them
67
+ def build_first_of_schema(composition_type)
68
+ schemas = @schema.send(composition_type)
69
+ first_schema = schemas.first
70
+ return {} unless first_schema
71
+
72
+ # Build the first schema as the fallback
73
+ result = build_schema_or_ref(first_schema)
74
+ result["description"] = @schema.description.to_s if @schema.description
75
+ apply_extensions(result)
76
+ result
77
+ end
78
+
79
+ # Build allOf schema for inheritance
80
+ def build_all_of_schema
81
+ all_of_items = @schema.all_of.map do |item|
82
+ build_schema_or_ref(item)
83
+ end
84
+
85
+ result = { "allOf" => all_of_items }
86
+ result["description"] = @schema.description.to_s if @schema.description
87
+ result
88
+ end
89
+
90
+ def build_properties(properties)
91
+ return nil unless properties
92
+ return nil if properties.empty?
93
+
94
+ properties.transform_values do |prop_schema|
95
+ build_schema_or_ref(prop_schema)
96
+ end
97
+ end
98
+
99
+ def build_schema_or_ref(schema)
100
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
101
+ @ref_tracker << schema.canonical_name if @ref_tracker
102
+ ref_name = schema.canonical_name.gsub("::", "_")
103
+ { "$ref" => "#/definitions/#{ref_name}" }
104
+ else
105
+ Schema.new(schema, @ref_tracker).build
106
+ end
107
+ end
108
+
109
+ def normalize_enum(enum_vals, type)
110
+ return nil unless enum_vals.is_a?(Array)
111
+
112
+ coerced = enum_vals.map do |v|
113
+ case type
114
+ when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
115
+ when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
116
+ else v
117
+ end
118
+ end.compact
119
+
120
+ coerced.uniq
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end