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