grape-oas 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/CONTRIBUTING.md +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/RELEASING.md +109 -0
- data/grape-oas.gemspec +27 -0
- data/lib/grape-oas.rb +3 -0
- data/lib/grape_oas/api_model/api.rb +42 -0
- data/lib/grape_oas/api_model/media_type.rb +22 -0
- data/lib/grape_oas/api_model/node.rb +57 -0
- data/lib/grape_oas/api_model/operation.rb +55 -0
- data/lib/grape_oas/api_model/parameter.rb +24 -0
- data/lib/grape_oas/api_model/path.rb +29 -0
- data/lib/grape_oas/api_model/request_body.rb +27 -0
- data/lib/grape_oas/api_model/response.rb +28 -0
- data/lib/grape_oas/api_model/schema.rb +60 -0
- data/lib/grape_oas/api_model_builder.rb +63 -0
- data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +93 -0
- data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +75 -0
- data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +142 -0
- data/lib/grape_oas/api_model_builders/operation.rb +168 -0
- data/lib/grape_oas/api_model_builders/path.rb +122 -0
- data/lib/grape_oas/api_model_builders/request.rb +304 -0
- data/lib/grape_oas/api_model_builders/request_params.rb +128 -0
- data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +155 -0
- data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +64 -0
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +163 -0
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +111 -0
- data/lib/grape_oas/api_model_builders/response.rb +241 -0
- data/lib/grape_oas/api_model_builders/response_parsers/base.rb +56 -0
- data/lib/grape_oas/api_model_builders/response_parsers/default_response_parser.rb +31 -0
- data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +35 -0
- data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +85 -0
- data/lib/grape_oas/constants.rb +81 -0
- data/lib/grape_oas/documentation_extension.rb +124 -0
- data/lib/grape_oas/exporter/base/operation.rb +88 -0
- data/lib/grape_oas/exporter/base/paths.rb +53 -0
- data/lib/grape_oas/exporter/concerns/schema_indexer.rb +93 -0
- data/lib/grape_oas/exporter/concerns/tag_builder.rb +55 -0
- data/lib/grape_oas/exporter/oas2/operation.rb +31 -0
- data/lib/grape_oas/exporter/oas2/parameter.rb +116 -0
- data/lib/grape_oas/exporter/oas2/paths.rb +19 -0
- data/lib/grape_oas/exporter/oas2/response.rb +74 -0
- data/lib/grape_oas/exporter/oas2/schema.rb +125 -0
- data/lib/grape_oas/exporter/oas2_schema.rb +133 -0
- data/lib/grape_oas/exporter/oas3/operation.rb +24 -0
- data/lib/grape_oas/exporter/oas3/parameter.rb +27 -0
- data/lib/grape_oas/exporter/oas3/paths.rb +21 -0
- data/lib/grape_oas/exporter/oas3/request_body.rb +54 -0
- data/lib/grape_oas/exporter/oas3/response.rb +85 -0
- data/lib/grape_oas/exporter/oas3/schema.rb +249 -0
- data/lib/grape_oas/exporter/oas30_schema.rb +13 -0
- data/lib/grape_oas/exporter/oas31/schema.rb +42 -0
- data/lib/grape_oas/exporter/oas31_schema.rb +34 -0
- data/lib/grape_oas/exporter/oas3_schema.rb +130 -0
- data/lib/grape_oas/exporter/registry.rb +82 -0
- data/lib/grape_oas/exporter.rb +16 -0
- data/lib/grape_oas/introspectors/base.rb +44 -0
- data/lib/grape_oas/introspectors/dry_introspector.rb +131 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +51 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/ast_walker.rb +125 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_applier.rb +136 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +85 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_merger.rb +47 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/contract_resolver.rb +60 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/inheritance_handler.rb +87 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +131 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb +143 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +143 -0
- data/lib/grape_oas/introspectors/entity_introspector.rb +165 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/cycle_tracker.rb +42 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +83 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +261 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +112 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +53 -0
- data/lib/grape_oas/introspectors/registry.rb +136 -0
- data/lib/grape_oas/rake/oas_tasks.rb +127 -0
- data/lib/grape_oas/version.rb +5 -0
- data/lib/grape_oas.rb +145 -0
- metadata +152 -0
|
@@ -0,0 +1,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
|