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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ class Operation
6
+ include Concerns::ContentTypeResolver
7
+ include Concerns::OasUtilities
8
+
9
+ attr_reader :api, :route, :app, :path_param_name_map, :template_override
10
+
11
+ def initialize(api:, route:, app: nil, path_param_name_map: nil, template_override: nil)
12
+ @api = api
13
+ @route = route
14
+ @app = app
15
+ @path_param_name_map = path_param_name_map || {}
16
+ @template_override = template_override
17
+ end
18
+
19
+ def build
20
+ operation = GrapeOAS::ApiModel::Operation.new(
21
+ http_method: http_method,
22
+ operation_id: operation_id,
23
+ summary: route.options[:description],
24
+ description: build_description,
25
+ tag_names: tag_names,
26
+ extensions: operation_extensions,
27
+ consumes: consumes,
28
+ produces: produces,
29
+ deprecated: build_deprecated,
30
+ )
31
+ operation.suppress_default_error_response = build_suppress_default_error_response
32
+
33
+ api.add_tags(*tag_names) if tag_names.any?
34
+
35
+ build_request(operation)
36
+
37
+ build_responses.each { |resp| operation.add_response(resp) }
38
+ ensure_path_parameters(operation)
39
+
40
+ operation.security = build_security if build_security
41
+
42
+ operation
43
+ end
44
+
45
+ private
46
+
47
+ def operation_id
48
+ @operation_id ||= route.options.fetch(:nickname) do
49
+ slug = route
50
+ .pattern
51
+ .origin
52
+ .gsub(/[^a-z0-9]+/i, "_")
53
+ .gsub(/_+/, "_")
54
+ .sub(/^_|_$/, "")
55
+
56
+ "#{http_method}_#{slug}"
57
+ end
58
+ end
59
+
60
+ def http_method
61
+ @http_method ||= route.request_method.downcase.to_sym
62
+ end
63
+
64
+ def tag_names
65
+ @tag_names ||= Array(route.options[:tags]).presence || derive_tag_from_path
66
+ end
67
+
68
+ # Derive tag from path when no explicit tags are defined (like grape-swagger's tag_object)
69
+ def derive_tag_from_path
70
+ path = template_override || sanitize_route_path(route.path)
71
+ # Remove path parameters like {id} and {version}
72
+ path_without_params = path.gsub(/\{[^}]+\}/, "")
73
+ segments = path_without_params.split("/").reject(&:empty?)
74
+
75
+ # Remove prefix and version from segments
76
+ prefix_segments = route_prefix_segments
77
+ version_segments = route_version_segments
78
+
79
+ filtered = segments.reject { |s| prefix_segments.include?(s) || version_segments.include?(s) }
80
+
81
+ Array(filtered.first).presence || []
82
+ end
83
+
84
+ def route_prefix_segments
85
+ prefix = route.prefix.to_s
86
+ prefix.split("/").reject(&:empty?)
87
+ end
88
+
89
+ def route_version_segments
90
+ version = route.version
91
+ Array(version).map(&:to_s)
92
+ end
93
+
94
+ def build_response
95
+ GrapeOAS::ApiModelBuilders::Response
96
+ .new(api: api, route: route, app: app)
97
+ .build
98
+ end
99
+
100
+ def build_responses
101
+ Array(build_response)
102
+ end
103
+
104
+ def build_security
105
+ route.options.dig(:documentation, :security) ||
106
+ route.options[:security] ||
107
+ route.options[:auth]
108
+ end
109
+
110
+ def build_deprecated
111
+ route.options[:deprecated] ||
112
+ route.options.dig(:documentation, :deprecated) ||
113
+ false
114
+ end
115
+
116
+ def build_suppress_default_error_response
117
+ route.options[:suppress_default_error_response] ||
118
+ route.options.dig(:documentation, :suppress_default_error_response) ||
119
+ false
120
+ end
121
+
122
+ def build_description
123
+ route.options[:detail] ||
124
+ route.options.dig(:documentation, :desc)
125
+ end
126
+
127
+ def consumes
128
+ resolve_content_types
129
+ end
130
+
131
+ def produces
132
+ resolve_content_types
133
+ end
134
+
135
+ def operation_extensions
136
+ extract_extensions(route.options[:documentation])
137
+ end
138
+
139
+ def build_request(operation)
140
+ GrapeOAS::ApiModelBuilders::Request
141
+ .new(api: api, route: route, operation: operation, path_param_name_map: path_param_name_map)
142
+ .build
143
+ end
144
+
145
+ # Ensure every {param} in the path template has a corresponding path parameter.
146
+ def ensure_path_parameters(operation)
147
+ template = template_override || sanitize_route_path(route.path)
148
+ placeholders = template.scan(/\{([^}]+)\}/).flatten
149
+ existing = Array(operation.parameters).select { |p| p.location == "path" }.map(&:name)
150
+ missing = placeholders - existing
151
+ missing.each do |name|
152
+ operation.add_parameter(
153
+ GrapeOAS::ApiModel::Parameter.new(
154
+ location: "path",
155
+ name: name,
156
+ required: true,
157
+ schema: GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::STRING),
158
+ ),
159
+ )
160
+ end
161
+ end
162
+
163
+ def sanitize_route_path(path)
164
+ path.gsub(Path::EXTENSION_PATTERN, "").gsub(Path::PATH_PARAMETER_PATTERN, "{\\k<param>}")
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module ApiModelBuilders
5
+ class Path
6
+ # Matches format extensions like (.json), (.:format), (.json)(.:format)
7
+ EXTENSION_PATTERN = /(\(\.[^)]+\))+$/
8
+ private_constant :EXTENSION_PATTERN
9
+
10
+ PATH_PARAMETER_PATTERN = %r{(?<=/):(?<param>[^/]+)}
11
+ private_constant :PATH_PARAMETER_PATTERN
12
+
13
+ NORMALIZED_PLACEHOLDER = /\{[^}]+\}/
14
+ private_constant :NORMALIZED_PLACEHOLDER
15
+
16
+ attr_reader :api, :routes, :app, :namespace_filter
17
+
18
+ def initialize(api:, routes:, app: nil, namespace_filter: nil)
19
+ @api = api
20
+ @routes = routes
21
+ @app = app
22
+ @namespace_filter = namespace_filter
23
+ end
24
+
25
+ def build
26
+ canonical_paths = {}
27
+
28
+ @routes.each_with_object({}) do |route, api_routes|
29
+ next if skip_route?(route)
30
+
31
+ route_path = sanitize_path(route.path)
32
+ normalized = normalize_template(route_path)
33
+
34
+ canonical_info = canonical_paths[normalized]
35
+ path_param_name_map = nil
36
+
37
+ if canonical_info
38
+ path_param_name_map = map_param_names(canonical_info[:template], route_path)
39
+ route_path = canonical_info[:template]
40
+ else
41
+ canonical_paths[normalized] = { template: route_path }
42
+ end
43
+
44
+ api_path = api_routes.fetch(route_path) do
45
+ path = GrapeOAS::ApiModel::Path.new(template: route_path)
46
+ api_routes[route_path] = path
47
+
48
+ api.add_path(path)
49
+
50
+ path
51
+ end
52
+
53
+ operation = build_operation(route, path_param_name_map: path_param_name_map, template_override: route_path)
54
+
55
+ api_path.add_operation(operation)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def skip_route?(route)
62
+ return true if filtered_by_namespace?(route)
63
+
64
+ # Check route_setting :swagger, hidden: true
65
+ route_hidden = route.settings.dig(:swagger, :hidden)
66
+ # Check desc "...", swagger: { hidden: true }
67
+ route_hidden = route.options.dig(:swagger, :hidden) if route.options.dig(:swagger, :hidden)
68
+ # Direct hidden option takes precedence (from desc hidden: or verb method options)
69
+ route_hidden = route.options[:hidden] if route.options.key?(:hidden)
70
+
71
+ # Support callable objects (Proc/lambda) for conditional hiding
72
+ route_hidden = route_hidden.call if route_hidden.respond_to?(:call)
73
+
74
+ route_hidden
75
+ end
76
+
77
+ # Returns true if route should be filtered out due to namespace filter.
78
+ # Routes are included if their path matches the namespace exactly or starts with namespace followed by "/".
79
+ def filtered_by_namespace?(route)
80
+ return false unless namespace_filter
81
+
82
+ route_path = sanitize_path(route.path)
83
+ namespace_prefix = namespace_filter.start_with?("/") ? namespace_filter : "/#{namespace_filter}"
84
+
85
+ # Match exact namespace or namespace followed by / or {
86
+ return false if route_path == namespace_prefix
87
+ return false if route_path.start_with?("#{namespace_prefix}/")
88
+
89
+ true
90
+ end
91
+
92
+ def build_operation(route, path_param_name_map: nil, template_override: nil)
93
+ GrapeOAS::ApiModelBuilders::Operation
94
+ .new(api: api, route: route, app: app, path_param_name_map: path_param_name_map, template_override: template_override)
95
+ .build
96
+ end
97
+
98
+ def sanitize_path(path)
99
+ path
100
+ .gsub(EXTENSION_PATTERN, "") # Remove format extensions like (.json)
101
+ .gsub(PATH_PARAMETER_PATTERN, "{\\k<param>}") # Replace named parameters with curly braces
102
+ end
103
+
104
+ def normalize_template(path)
105
+ sanitize_path(path).gsub(NORMALIZED_PLACEHOLDER, "{}")
106
+ end
107
+
108
+ def map_param_names(canonical_template, incoming_template)
109
+ canonical_params = canonical_template.scan(/\{([^}]+)\}/).flatten
110
+ incoming_params = incoming_template.scan(/\{([^}]+)\}/).flatten
111
+ return nil unless canonical_params.length == incoming_params.length
112
+
113
+ mapping = incoming_params.zip(canonical_params).to_h
114
+ # Only return mapping when names differ to avoid needless work
115
+ mapping if mapping.any? && mapping.keys != mapping.values
116
+ end
117
+
118
+ public_constant :EXTENSION_PATTERN
119
+ public_constant :PATH_PARAMETER_PATTERN
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require_relative "concerns/type_resolver"
5
+
6
+ module GrapeOAS
7
+ module ApiModelBuilders
8
+ class Request
9
+ include Concerns::TypeResolver
10
+ include Concerns::OasUtilities
11
+
12
+ attr_reader :api, :route, :operation, :path_param_name_map
13
+
14
+ def initialize(api:, route:, operation:, path_param_name_map: nil)
15
+ @api = api
16
+ @route = route
17
+ @operation = operation
18
+ @path_param_name_map = path_param_name_map || {}
19
+ end
20
+
21
+ def build
22
+ body_schema, route_params = GrapeOAS::ApiModelBuilders::RequestParams
23
+ .new(api: api, route: route, path_param_name_map: path_param_name_map)
24
+ .build
25
+
26
+ contract_schema = build_contract_schema
27
+ body_schema = contract_schema if contract_schema
28
+
29
+ operation.add_parameters(*route_params)
30
+ append_request_body(body_schema) unless body_schema.empty?
31
+ end
32
+
33
+ private
34
+
35
+ def append_request_body(body_schema)
36
+ # OAS spec says GET/HEAD/DELETE "MAY ignore" request bodies
37
+ # Skip by default unless explicitly allowed via documentation option
38
+ http_method = operation.http_method.to_s.downcase
39
+ if %w[get head delete].include?(http_method)
40
+ allow_body = route.options.dig(:documentation, :request_body) ||
41
+ route.options[:request_body]
42
+ return unless allow_body
43
+ end
44
+
45
+ media_ext = media_type_extensions(Constants::MimeTypes::JSON)
46
+
47
+ # Set canonical_name if not already set (e.g., DryIntrospector may have set it for polymorphism)
48
+ if body_schema.respond_to?(:canonical_name) && body_schema.canonical_name.nil?
49
+ settings = route.respond_to?(:settings) ? route.settings : {}
50
+ contract_class = route.options[:contract] || route.options[:schema] || settings[:contract]
51
+
52
+ if contract_class.is_a?(Class) && defined?(Menti::Endpoint::Schema) && contract_class < Menti::Endpoint::Schema
53
+ body_schema.canonical_name = contract_class.name
54
+ elsif contract_class # some other contract (e.g., Dry); keep inline
55
+ # no-op
56
+ elsif body_schema.properties.values.any? { |prop| prop.respond_to?(:canonical_name) && prop.canonical_name }
57
+ # keep entity/property refs intact; don't override
58
+ elsif operation.respond_to?(:operation_id) && operation.operation_id
59
+ body_schema.canonical_name = "#{operation.operation_id}_Request"
60
+ end
61
+ end
62
+
63
+ media_types = Array(operation.consumes.presence || [Constants::MimeTypes::JSON]).map do |mime|
64
+ GrapeOAS::ApiModel::MediaType.new(
65
+ mime_type: mime,
66
+ schema: body_schema,
67
+ extensions: media_ext,
68
+ )
69
+ end
70
+ operation.request_body = GrapeOAS::ApiModel::RequestBody.new(
71
+ required: body_schema.required && !body_schema.required.empty?,
72
+ media_types: media_types,
73
+ extensions: request_body_extensions,
74
+ body_name: route.options[:body_name],
75
+ )
76
+ end
77
+
78
+ def documentation_options
79
+ route.options[:documentation] || {}
80
+ end
81
+
82
+ def request_body_extensions
83
+ extract_extensions(documentation_options)
84
+ end
85
+
86
+ def media_type_extensions(mime)
87
+ content = documentation_options[:content]
88
+ return nil unless content.is_a?(Hash)
89
+
90
+ mt = content[mime] || content[mime.to_sym]
91
+ extract_extensions(mt)
92
+ end
93
+
94
+ def build_contract_schema
95
+ settings = route.respond_to?(:settings) ? route.settings : {}
96
+ contract = route.options[:contract] || settings[:contract]
97
+ return unless contract
98
+
99
+ schema_obj = if contract.respond_to?(:schema)
100
+ contract.schema
101
+ elsif contract.respond_to?(:call)
102
+ contract
103
+ end
104
+
105
+ # Pass the contract class (not schema_obj) so DryIntrospector can detect inheritance
106
+ return GrapeOAS::Introspectors::DryIntrospector.build(contract) if schema_obj.respond_to?(:types)
107
+
108
+ contract_hash = if contract.respond_to?(:to_h)
109
+ contract.to_h
110
+ elsif contract.respond_to?(:schema) && contract.schema.respond_to?(:to_h)
111
+ contract.schema.to_h
112
+ end
113
+ return unless contract_hash.is_a?(Hash)
114
+
115
+ hash_to_schema(contract_hash)
116
+ end
117
+
118
+ def hash_to_schema(obj)
119
+ schema = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
120
+ obj.each do |key, value|
121
+ case value
122
+ when Hash
123
+ schema.add_property(key, hash_to_schema(value))
124
+ when Array
125
+ item_schema = if value.first.is_a?(Hash)
126
+ hash_to_schema(value.first)
127
+ else
128
+ GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
129
+ end
130
+ schema.add_property(key, GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: item_schema))
131
+ else
132
+ prop_schema = GrapeOAS::ApiModel::Schema.new(type: map_type(value))
133
+ prop_schema.nullable = true if value.nil?
134
+ schema.add_property(key, prop_schema)
135
+ end
136
+ end
137
+ schema
138
+ end
139
+
140
+ def map_type(value)
141
+ return value.primitive.to_s.downcase if value.respond_to?(:primitive)
142
+
143
+ # First try direct lookup (for Ruby class values like String, Integer)
144
+ # Then try class-based lookup (for actual runtime values like "hello", 123)
145
+ Constants::RUBY_TYPE_MAPPING.fetch(value) do
146
+ Constants::RUBY_TYPE_MAPPING.fetch(value.class, Constants::SchemaTypes::STRING)
147
+ end
148
+ end
149
+
150
+ def schema_from_types(types_hash, rule_constraints)
151
+ schema = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
152
+ types_hash.each do |name, dry_type|
153
+ prop_schema = schema_for_type(dry_type)
154
+ merge_rule_constraints(prop_schema, rule_constraints[name]) if rule_constraints[name]
155
+ required = true
156
+ required = false if dry_type.respond_to?(:optional?) && dry_type.optional?
157
+ required = false if dry_type.respond_to?(:meta) && dry_type.meta[:omittable]
158
+ schema.add_property(name, prop_schema, required: required)
159
+ end
160
+ schema
161
+ end
162
+
163
+ def schema_for_type(dry_type)
164
+ if dry_type.respond_to?(:primitive) && dry_type.primitive == Array && dry_type.respond_to?(:member)
165
+ items_schema = schema_for_type(dry_type.member)
166
+ schema = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
167
+ apply_array_meta_constraints(schema, dry_type.respond_to?(:meta) ? dry_type.meta : {})
168
+ return schema
169
+ end
170
+
171
+ primitive, member = derive_primitive_and_member(dry_type)
172
+ if dry_type.respond_to?(:primitive) && dry_type.primitive == Array
173
+ member ||= dry_type.respond_to?(:member) ? dry_type.member : nil
174
+ primitive = Array
175
+ end
176
+ meta = dry_type.respond_to?(:meta) ? dry_type.meta : {}
177
+ nullable = dry_type.respond_to?(:optional?) && dry_type.optional?
178
+ enum_vals = dry_type.respond_to?(:values) ? dry_type.values : nil
179
+
180
+ schema = if primitive == Array
181
+ items_schema = member ? schema_for_type(member) : default_string_schema
182
+ s = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
183
+ apply_array_meta_constraints(s, meta)
184
+ s
185
+ else
186
+ build_schema_for_primitive(primitive)
187
+ end
188
+
189
+ schema.nullable = nullable
190
+ schema.enum = enum_vals if enum_vals
191
+ apply_string_meta_constraints(schema, meta) if primitive == String
192
+ apply_numeric_meta_constraints(schema, meta) if [Integer, Float, BigDecimal].include?(primitive)
193
+ schema
194
+ end
195
+
196
+ def derive_primitive_and_member(dry_type)
197
+ if defined?(Dry::Types::Array::Member) && dry_type.respond_to?(:type) && dry_type.type.is_a?(Dry::Types::Array::Member)
198
+ return [Array, dry_type.type.member]
199
+ end
200
+
201
+ return [Array, dry_type.member] if dry_type.respond_to?(:member)
202
+
203
+ primitive = dry_type.respond_to?(:primitive) ? dry_type.primitive : nil
204
+ [primitive, nil]
205
+ end
206
+
207
+ def apply_string_meta_constraints(schema, meta)
208
+ min_length = extract_min_constraint(meta)
209
+ max_length = extract_max_constraint(meta)
210
+ schema.min_length = min_length if min_length
211
+ schema.max_length = max_length if max_length
212
+ schema.pattern = meta[:pattern] if meta[:pattern]
213
+ end
214
+
215
+ def apply_array_meta_constraints(schema, meta)
216
+ min_items = extract_min_constraint(meta, :min_items)
217
+ max_items = extract_max_constraint(meta, :max_items)
218
+ schema.min_items = min_items if min_items
219
+ schema.max_items = max_items if max_items
220
+ end
221
+
222
+ # Extract minimum constraint, supporting multiple key names
223
+ def extract_min_constraint(meta, specific_key = :min_length)
224
+ meta[:min_size] || meta[specific_key]
225
+ end
226
+
227
+ # Extract maximum constraint, supporting multiple key names
228
+ def extract_max_constraint(meta, specific_key = :max_length)
229
+ meta[:max_size] || meta[specific_key]
230
+ end
231
+
232
+ def apply_numeric_meta_constraints(schema, meta)
233
+ if meta[:gt]
234
+ schema.minimum = meta[:gt]
235
+ schema.exclusive_minimum = true
236
+ elsif meta[:gteq]
237
+ schema.minimum = meta[:gteq]
238
+ end
239
+ if meta[:lt]
240
+ schema.maximum = meta[:lt]
241
+ schema.exclusive_maximum = true
242
+ elsif meta[:lteq]
243
+ schema.maximum = meta[:lteq]
244
+ end
245
+ end
246
+
247
+ def merge_rule_constraints(schema, rule_constraints)
248
+ return unless rule_constraints
249
+
250
+ schema.enum ||= rule_constraints[:enum]
251
+ schema.nullable ||= rule_constraints[:nullable]
252
+ schema.min_length ||= rule_constraints[:min] if rule_constraints[:min]
253
+ schema.max_length ||= rule_constraints[:max] if rule_constraints[:max]
254
+ schema.minimum ||= rule_constraints[:minimum] if rule_constraints[:minimum]
255
+ schema.maximum ||= rule_constraints[:maximum] if rule_constraints[:maximum]
256
+ schema.exclusive_minimum ||= rule_constraints[:exclusive_minimum]
257
+ schema.exclusive_maximum ||= rule_constraints[:exclusive_maximum]
258
+ end
259
+
260
+ # Very small parser for FakeType rule_ast used in tests
261
+ def extract_rule_constraints(schema_obj)
262
+ return {} unless schema_obj.respond_to?(:rules)
263
+
264
+ # Only supports FakeSchema/FakeType used in tests
265
+ constraints = Hash.new { |h, k| h[k] = {} }
266
+ if schema_obj.respond_to?(:types)
267
+ schema_obj.types.each do |name, dry_type|
268
+ next unless dry_type.respond_to?(:rule_ast)
269
+
270
+ rules = dry_type.rule_ast
271
+ Array(rules).each do |rule|
272
+ next unless rule.is_a?(Array)
273
+
274
+ _, pred = rule
275
+ next unless pred.is_a?(Array)
276
+
277
+ pname, pargs = pred
278
+ case pname
279
+ when :size?
280
+ min, max = Array(pargs).first
281
+ constraints[name][:min] = min
282
+ constraints[name][:max] = max
283
+ when :maybe
284
+ constraints[name][:nullable] = true
285
+ end
286
+ end
287
+ end
288
+ end
289
+ constraints
290
+ rescue NoMethodError, TypeError
291
+ {}
292
+ end
293
+
294
+ def extract_enum_from_core_values(core)
295
+ return unless core.respond_to?(:values)
296
+
297
+ vals = core.values
298
+ vals if vals.is_a?(Array)
299
+ rescue NoMethodError
300
+ nil
301
+ end
302
+ end
303
+ end
304
+ end