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,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Applies extracted constraints to ApiModel::Schema objects.
|
|
7
|
+
class ConstraintApplier
|
|
8
|
+
def initialize(schema, constraints, meta = {})
|
|
9
|
+
@schema = schema
|
|
10
|
+
@constraints = constraints
|
|
11
|
+
@meta = meta
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply_meta
|
|
15
|
+
case schema.type
|
|
16
|
+
when Constants::SchemaTypes::STRING
|
|
17
|
+
apply_string_meta
|
|
18
|
+
when Constants::SchemaTypes::INTEGER, Constants::SchemaTypes::NUMBER
|
|
19
|
+
apply_numeric_meta
|
|
20
|
+
when Constants::SchemaTypes::ARRAY
|
|
21
|
+
apply_array_meta
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply_rule_constraints
|
|
26
|
+
return unless constraints
|
|
27
|
+
|
|
28
|
+
apply_type_specific_constraints
|
|
29
|
+
apply_common_constraints
|
|
30
|
+
apply_extension_constraints
|
|
31
|
+
attach_unhandled
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :schema, :constraints, :meta
|
|
37
|
+
|
|
38
|
+
def apply_string_meta
|
|
39
|
+
min_length = meta[:min_size] || meta[:min_length]
|
|
40
|
+
max_length = meta[:max_size] || meta[:max_length]
|
|
41
|
+
schema.min_length = min_length if min_length
|
|
42
|
+
schema.max_length = max_length if max_length
|
|
43
|
+
schema.pattern = meta[:pattern] if meta[:pattern]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_array_meta
|
|
47
|
+
min_items = meta[:min_size] || meta[:min_items]
|
|
48
|
+
max_items = meta[:max_size] || meta[:max_items]
|
|
49
|
+
schema.min_items = min_items if min_items
|
|
50
|
+
schema.max_items = max_items if max_items
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_numeric_meta
|
|
54
|
+
if meta[:gt]
|
|
55
|
+
schema.minimum = meta[:gt]
|
|
56
|
+
schema.exclusive_minimum = true
|
|
57
|
+
elsif meta[:gteq]
|
|
58
|
+
schema.minimum = meta[:gteq]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if meta[:lt]
|
|
62
|
+
schema.maximum = meta[:lt]
|
|
63
|
+
schema.exclusive_maximum = true
|
|
64
|
+
elsif meta[:lteq]
|
|
65
|
+
schema.maximum = meta[:lteq]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply_type_specific_constraints
|
|
70
|
+
case schema.type
|
|
71
|
+
when Constants::SchemaTypes::STRING
|
|
72
|
+
apply_string_constraints
|
|
73
|
+
when Constants::SchemaTypes::ARRAY
|
|
74
|
+
apply_array_constraints
|
|
75
|
+
when Constants::SchemaTypes::INTEGER, Constants::SchemaTypes::NUMBER
|
|
76
|
+
apply_numeric_constraints
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def apply_string_constraints
|
|
81
|
+
schema.min_length ||= constraints.min_size if constraints.min_size
|
|
82
|
+
schema.max_length ||= constraints.max_size if constraints.max_size
|
|
83
|
+
schema.pattern ||= constraints.pattern if constraints.pattern
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def apply_array_constraints
|
|
87
|
+
schema.min_items ||= constraints.min_size if constraints.min_size
|
|
88
|
+
schema.max_items ||= constraints.max_size if constraints.max_size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apply_numeric_constraints
|
|
92
|
+
numeric_min = constraints.minimum || constraints.min_size
|
|
93
|
+
numeric_max = constraints.maximum || constraints.max_size
|
|
94
|
+
schema.minimum ||= numeric_min if numeric_min
|
|
95
|
+
schema.maximum ||= numeric_max if numeric_max
|
|
96
|
+
schema.exclusive_minimum ||= constraints.exclusive_minimum
|
|
97
|
+
schema.exclusive_maximum ||= constraints.exclusive_maximum
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_common_constraints
|
|
101
|
+
schema.enum ||= constraints.enum if constraints.enum
|
|
102
|
+
schema.nullable = true if constraints.nullable
|
|
103
|
+
schema.format ||= constraints.format if constraints.format
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def apply_extension_constraints
|
|
107
|
+
apply_extension("multipleOf", constraints.extensions&.dig("multipleOf"))
|
|
108
|
+
apply_extension("x-excludedValues", constraints.excluded_values)
|
|
109
|
+
apply_extension("x-typePredicate", constraints.type_predicate)
|
|
110
|
+
apply_extension("x-numberParity", constraints.parity&.to_s)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def apply_extension(key, value)
|
|
114
|
+
return unless value
|
|
115
|
+
|
|
116
|
+
schema.extensions ||= {}
|
|
117
|
+
schema.extensions[key] ||= value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def attach_unhandled
|
|
121
|
+
return unless constraints&.unhandled_predicates
|
|
122
|
+
|
|
123
|
+
filtered = Array(constraints.unhandled_predicates) - ignored_predicates
|
|
124
|
+
return if filtered.empty?
|
|
125
|
+
|
|
126
|
+
schema.extensions ||= {}
|
|
127
|
+
schema.extensions["x-unhandledPredicates"] = filtered
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ignored_predicates
|
|
131
|
+
%i[key? key str? int? bool? boolean? array? hash? number? float?]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Extracts constraint information from Dry::Schema AST nodes.
|
|
7
|
+
# Delegates AST walking to AstWalker and merging to ConstraintMerger.
|
|
8
|
+
class ConstraintExtractor
|
|
9
|
+
# Value object holding all possible constraints extracted from a Dry contract.
|
|
10
|
+
ConstraintSet = Struct.new(
|
|
11
|
+
:enum,
|
|
12
|
+
:nullable,
|
|
13
|
+
:min_size,
|
|
14
|
+
:max_size,
|
|
15
|
+
:minimum,
|
|
16
|
+
:maximum,
|
|
17
|
+
:exclusive_minimum,
|
|
18
|
+
:exclusive_maximum,
|
|
19
|
+
:pattern,
|
|
20
|
+
:excluded_values,
|
|
21
|
+
:unhandled_predicates,
|
|
22
|
+
:required,
|
|
23
|
+
:type_predicate,
|
|
24
|
+
:parity,
|
|
25
|
+
:format,
|
|
26
|
+
:extensions,
|
|
27
|
+
keyword_init: true,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def self.extract(contract)
|
|
31
|
+
new(contract).extract
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(contract)
|
|
35
|
+
@contract = contract
|
|
36
|
+
@ast_walker = AstWalker.new(ConstraintSet)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract
|
|
40
|
+
constraints = Hash.new { |h, k| h[k] = ConstraintSet.new(unhandled_predicates: []) }
|
|
41
|
+
|
|
42
|
+
extract_from_rules(constraints)
|
|
43
|
+
extract_from_types(constraints)
|
|
44
|
+
|
|
45
|
+
constraints
|
|
46
|
+
rescue NoMethodError, TypeError
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :contract, :ast_walker
|
|
53
|
+
|
|
54
|
+
def extract_from_rules(constraints)
|
|
55
|
+
return unless contract.respond_to?(:rules)
|
|
56
|
+
|
|
57
|
+
contract.rules.each do |name, rule|
|
|
58
|
+
ast = rule.respond_to?(:to_ast) ? rule.to_ast : rule
|
|
59
|
+
c = walk_ast(ast)
|
|
60
|
+
# infer requiredness: required rules are usually :and, optional via :implication
|
|
61
|
+
c.required = ast[0] != :implication if ast.is_a?(Array)
|
|
62
|
+
ConstraintMerger.merge(constraints[name], c)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_from_types(constraints)
|
|
67
|
+
return unless contract.respond_to?(:types)
|
|
68
|
+
|
|
69
|
+
contract.types.each do |name, dry_type|
|
|
70
|
+
next unless dry_type.respond_to?(:rule_ast) || (dry_type.respond_to?(:meta) && dry_type.meta[:rules])
|
|
71
|
+
|
|
72
|
+
asts = dry_type.respond_to?(:rule_ast) ? dry_type.rule_ast : dry_type.meta[:rules]
|
|
73
|
+
Array(asts).each do |ast|
|
|
74
|
+
ConstraintMerger.merge(constraints[name], walk_ast(ast))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def walk_ast(ast)
|
|
80
|
+
ast_walker.walk(ast)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Merges constraint sets, combining values from source into target.
|
|
7
|
+
module ConstraintMerger
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def merge(target, source)
|
|
11
|
+
return unless source
|
|
12
|
+
|
|
13
|
+
merge_basic_constraints(target, source)
|
|
14
|
+
merge_bound_constraints(target, source)
|
|
15
|
+
merge_extension_constraints(target, source)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def merge_basic_constraints(target, source)
|
|
19
|
+
target.enum ||= source.enum
|
|
20
|
+
target.nullable ||= source.nullable
|
|
21
|
+
target.pattern ||= source.pattern if source.pattern
|
|
22
|
+
target.format ||= source.format if source.format
|
|
23
|
+
target.required = source.required unless source.required.nil?
|
|
24
|
+
target.type_predicate ||= source.type_predicate if source.type_predicate
|
|
25
|
+
target.parity ||= source.parity if source.parity
|
|
26
|
+
end
|
|
27
|
+
private_class_method :merge_basic_constraints
|
|
28
|
+
|
|
29
|
+
def merge_bound_constraints(target, source)
|
|
30
|
+
target.min_size ||= source.min_size if source.min_size
|
|
31
|
+
target.max_size ||= source.max_size if source.max_size
|
|
32
|
+
target.minimum ||= source.minimum if source.minimum
|
|
33
|
+
target.maximum ||= source.maximum if source.maximum
|
|
34
|
+
target.exclusive_minimum ||= source.exclusive_minimum
|
|
35
|
+
target.exclusive_maximum ||= source.exclusive_maximum
|
|
36
|
+
end
|
|
37
|
+
private_class_method :merge_bound_constraints
|
|
38
|
+
|
|
39
|
+
def merge_extension_constraints(target, source)
|
|
40
|
+
target.excluded_values ||= source.excluded_values if source.excluded_values
|
|
41
|
+
target.unhandled_predicates |= Array(source.unhandled_predicates) if source.unhandled_predicates
|
|
42
|
+
end
|
|
43
|
+
private_class_method :merge_extension_constraints
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Resolves contract class, schema, and metadata from a Dry contract.
|
|
7
|
+
# Handles both class and instance contracts.
|
|
8
|
+
class ContractResolver
|
|
9
|
+
def initialize(contract)
|
|
10
|
+
@contract = contract
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Gets the contract class (handles both class and instance).
|
|
14
|
+
#
|
|
15
|
+
# @return [Class] the contract class
|
|
16
|
+
def contract_class
|
|
17
|
+
@contract.is_a?(Class) ? @contract : @contract.class
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Gets the schema from contract (handles both class and instance).
|
|
21
|
+
#
|
|
22
|
+
# @return [Object] the contract schema
|
|
23
|
+
def contract_schema
|
|
24
|
+
if @contract.is_a?(Class)
|
|
25
|
+
@contract.respond_to?(:schema) ? @contract.schema : @contract
|
|
26
|
+
else
|
|
27
|
+
@contract.respond_to?(:schema) ? @contract.class.schema : @contract
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Gets canonical name for contracts and schemas.
|
|
32
|
+
# - For Dry::Validation::Contract: uses class name
|
|
33
|
+
# - For Dry::Schema with schema_name: uses the defined schema_name
|
|
34
|
+
#
|
|
35
|
+
# @return [String, nil] the canonical name or nil
|
|
36
|
+
def canonical_name
|
|
37
|
+
return contract_class.name if validation_contract?
|
|
38
|
+
|
|
39
|
+
# Check for schema_name on Dry::Schema objects
|
|
40
|
+
return @contract.schema_name if @contract.respond_to?(:schema_name) && @contract.schema_name
|
|
41
|
+
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Checks if this is a Dry::Validation::Contract (class or instance).
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] true if validation contract
|
|
48
|
+
def validation_contract?
|
|
49
|
+
return false unless defined?(Dry::Validation::Contract)
|
|
50
|
+
|
|
51
|
+
if @contract.is_a?(Class)
|
|
52
|
+
@contract < Dry::Validation::Contract
|
|
53
|
+
else
|
|
54
|
+
@contract.is_a?(Dry::Validation::Contract)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Handles contract inheritance detection and allOf schema building.
|
|
7
|
+
class InheritanceHandler
|
|
8
|
+
def initialize(contract_resolver, stack:, registry:)
|
|
9
|
+
@contract_resolver = contract_resolver
|
|
10
|
+
@stack = stack
|
|
11
|
+
@registry = registry
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Finds parent contract class if this contract inherits from another.
|
|
15
|
+
#
|
|
16
|
+
# @return [Class, nil] the parent contract class or nil
|
|
17
|
+
def find_parent_contract
|
|
18
|
+
return nil unless defined?(Dry::Validation::Contract)
|
|
19
|
+
|
|
20
|
+
parent = @contract_resolver.contract_class.superclass
|
|
21
|
+
return nil unless parent && parent < Dry::Validation::Contract && parent != Dry::Validation::Contract
|
|
22
|
+
return nil unless parent.respond_to?(:schema)
|
|
23
|
+
|
|
24
|
+
parent
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Checks if the contract has a parent contract.
|
|
28
|
+
#
|
|
29
|
+
# @return [Boolean] true if inherited
|
|
30
|
+
def inherited?
|
|
31
|
+
!find_parent_contract.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds an inherited schema using allOf composition.
|
|
35
|
+
#
|
|
36
|
+
# @param parent_contract [Class] the parent contract class
|
|
37
|
+
# @param type_schema_builder [TypeSchemaBuilder] builder for type schemas
|
|
38
|
+
# @return [ApiModel::Schema] the composed schema
|
|
39
|
+
def build_inherited_schema(parent_contract, type_schema_builder)
|
|
40
|
+
# Build parent schema first
|
|
41
|
+
parent_schema = DryIntrospector.new(parent_contract, stack: @stack, registry: @registry).build
|
|
42
|
+
|
|
43
|
+
# Build child-only properties
|
|
44
|
+
child_schema = build_child_only_schema(parent_contract, type_schema_builder)
|
|
45
|
+
|
|
46
|
+
# Create allOf schema
|
|
47
|
+
schema = ApiModel::Schema.new(
|
|
48
|
+
canonical_name: @contract_resolver.contract_class.name,
|
|
49
|
+
all_of: [parent_schema, child_schema],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@registry[@contract_resolver.contract_class] = schema
|
|
53
|
+
schema
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Gets type keys from parent contract.
|
|
57
|
+
#
|
|
58
|
+
# @param parent_contract [Class] the parent contract class
|
|
59
|
+
# @return [Array<String>] list of parent type keys
|
|
60
|
+
def parent_contract_types(parent_contract)
|
|
61
|
+
return [] unless parent_contract.respond_to?(:schema)
|
|
62
|
+
|
|
63
|
+
parent_contract.schema.types.keys.map(&:to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_child_only_schema(parent_contract, type_schema_builder)
|
|
69
|
+
child_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
|
|
70
|
+
parent_keys = parent_contract_types(parent_contract)
|
|
71
|
+
rule_constraints = ConstraintExtractor.extract(@contract_resolver.contract_schema)
|
|
72
|
+
|
|
73
|
+
@contract_resolver.contract_schema.types.each do |name, dry_type|
|
|
74
|
+
# Skip inherited properties
|
|
75
|
+
next if parent_keys.include?(name.to_s)
|
|
76
|
+
|
|
77
|
+
constraints = rule_constraints[name]
|
|
78
|
+
prop_schema = type_schema_builder.build_schema_for_type(dry_type, constraints)
|
|
79
|
+
child_schema.add_property(name, prop_schema, required: type_schema_builder.required?(dry_type, constraints))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
child_schema
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Handles Dry::Schema predicate nodes and updates constraints accordingly.
|
|
7
|
+
class PredicateHandler
|
|
8
|
+
def initialize(constraints)
|
|
9
|
+
@constraints = constraints
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
HANDLED_PREDICATES = %i[
|
|
13
|
+
key? size? min_size? max_size? range? empty? bytesize? max_bytesize? min_bytesize?
|
|
14
|
+
maybe nil? filled?
|
|
15
|
+
included_in? excluded_from? eql? true? false?
|
|
16
|
+
gt? gteq? min? lt? lteq? max? multiple_of? divisible_by?
|
|
17
|
+
format? uuid? uri? url? email? date? time? date_time?
|
|
18
|
+
str? int? array? hash? number? float? bool? boolean? type?
|
|
19
|
+
odd? even?
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def handle(pred_node)
|
|
23
|
+
return unless pred_node.is_a?(Array)
|
|
24
|
+
|
|
25
|
+
name = pred_node[0]
|
|
26
|
+
args = Array(pred_node[1])
|
|
27
|
+
|
|
28
|
+
dispatch_predicate(name, args)
|
|
29
|
+
constraints.unhandled_predicates << name unless HANDLED_PREDICATES.include?(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :constraints
|
|
35
|
+
|
|
36
|
+
def dispatch_predicate(name, args)
|
|
37
|
+
case name
|
|
38
|
+
when :key? then constraints.required = true if constraints.required.nil?
|
|
39
|
+
when :size?, :min_size? then handle_size(name, args)
|
|
40
|
+
when :max_size? then constraints.max_size = ArgumentExtractor.extract_numeric(args.first)
|
|
41
|
+
when :range? then handle_range(args)
|
|
42
|
+
when :empty? then constraints.min_size = constraints.max_size = 0
|
|
43
|
+
when :bytesize?, :max_bytesize?, :min_bytesize? then handle_bytesize(name, args)
|
|
44
|
+
when :maybe, :nil? then constraints.nullable = true
|
|
45
|
+
when :filled? then constraints.nullable = false
|
|
46
|
+
when :included_in? then apply_enum_from_list(args)
|
|
47
|
+
when :excluded_from? then apply_excluded_from_list(args)
|
|
48
|
+
when :eql? then apply_enum_from_literal(args)
|
|
49
|
+
when :true? then constraints.enum = [true]
|
|
50
|
+
when :false? then constraints.enum = [false]
|
|
51
|
+
when :gt? then apply_exclusive_minimum(args)
|
|
52
|
+
when :gteq?, :min? then constraints.minimum = ArgumentExtractor.extract_numeric(args.first)
|
|
53
|
+
when :lt? then apply_exclusive_maximum(args)
|
|
54
|
+
when :lteq?, :max? then constraints.maximum = ArgumentExtractor.extract_numeric(args.first)
|
|
55
|
+
when :multiple_of?, :divisible_by? then handle_multiple_of(args)
|
|
56
|
+
when :format? then apply_pattern(args)
|
|
57
|
+
when :uuid? then constraints.format = "uuid"
|
|
58
|
+
when :uri?, :url? then constraints.format = "uri"
|
|
59
|
+
when :email? then constraints.format = "email"
|
|
60
|
+
when :date? then constraints.format = "date"
|
|
61
|
+
when :time?, :date_time? then constraints.format = "date-time"
|
|
62
|
+
when :bool?, :boolean? then constraints.type_predicate ||= :boolean
|
|
63
|
+
when :type? then constraints.type_predicate = ArgumentExtractor.extract_literal(args.first)
|
|
64
|
+
when :odd? then constraints.parity = :odd
|
|
65
|
+
when :even? then constraints.parity = :even
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply_enum_from_list(args)
|
|
70
|
+
vals = ArgumentExtractor.extract_list(args.first)
|
|
71
|
+
constraints.enum = vals if vals
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_excluded_from_list(args)
|
|
75
|
+
vals = ArgumentExtractor.extract_list(args.first)
|
|
76
|
+
constraints.excluded_values = vals if vals
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def apply_enum_from_literal(args)
|
|
80
|
+
val = ArgumentExtractor.extract_literal(args.first)
|
|
81
|
+
constraints.enum = [val] unless val.nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def apply_exclusive_minimum(args)
|
|
85
|
+
constraints.minimum = ArgumentExtractor.extract_numeric(args.first)
|
|
86
|
+
constraints.exclusive_minimum = true if constraints.minimum
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def apply_exclusive_maximum(args)
|
|
90
|
+
constraints.maximum = ArgumentExtractor.extract_numeric(args.first)
|
|
91
|
+
constraints.exclusive_maximum = true if constraints.maximum
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply_pattern(args)
|
|
95
|
+
pat = ArgumentExtractor.extract_pattern(args.first)
|
|
96
|
+
constraints.pattern = pat if pat
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_size(name, args)
|
|
100
|
+
min_val = ArgumentExtractor.extract_numeric(args[0])
|
|
101
|
+
max_val = ArgumentExtractor.extract_numeric(args[1]) if name == :size?
|
|
102
|
+
constraints.min_size = min_val if min_val
|
|
103
|
+
constraints.max_size = max_val if max_val
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_range(args)
|
|
107
|
+
rng = args.first.is_a?(Range) ? args.first : ArgumentExtractor.extract_range(args.first)
|
|
108
|
+
return unless rng
|
|
109
|
+
|
|
110
|
+
constraints.minimum = rng.begin if rng.begin
|
|
111
|
+
constraints.maximum = rng.end if rng.end
|
|
112
|
+
constraints.exclusive_maximum = rng.exclude_end?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def handle_multiple_of(args)
|
|
116
|
+
val = ArgumentExtractor.extract_numeric(args.first)
|
|
117
|
+
constraints.extensions ||= {}
|
|
118
|
+
constraints.extensions["multipleOf"] ||= val if val
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def handle_bytesize(name, args)
|
|
122
|
+
min_val = ArgumentExtractor.extract_numeric(args[0]) if %i[bytesize? min_bytesize?].include?(name)
|
|
123
|
+
max_source = name == :bytesize? ? args[1] : args[0]
|
|
124
|
+
max_val = ArgumentExtractor.extract_numeric(max_source) if %i[bytesize? max_bytesize?].include?(name)
|
|
125
|
+
constraints.min_size = min_val if min_val
|
|
126
|
+
constraints.max_size = max_val if max_val
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrapeOAS
|
|
4
|
+
module Introspectors
|
|
5
|
+
module DryIntrospectorSupport
|
|
6
|
+
# Builds OpenAPI schemas from Dry types.
|
|
7
|
+
class TypeSchemaBuilder
|
|
8
|
+
include ApiModelBuilders::Concerns::TypeResolver
|
|
9
|
+
|
|
10
|
+
# Re-export ConstraintSet for external use
|
|
11
|
+
ConstraintSet = ConstraintExtractor::ConstraintSet
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
# Stateless builder - no initialization needed
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Builds a schema for a Dry type.
|
|
18
|
+
#
|
|
19
|
+
# @param dry_type [Object] the Dry type
|
|
20
|
+
# @param constraints [ConstraintSet, nil] extracted constraints
|
|
21
|
+
# @return [ApiModel::Schema] the built schema
|
|
22
|
+
def build_schema_for_type(dry_type, constraints = nil)
|
|
23
|
+
constraints ||= ConstraintSet.new(unhandled_predicates: [])
|
|
24
|
+
meta = dry_type.respond_to?(:meta) ? dry_type.meta : {}
|
|
25
|
+
|
|
26
|
+
# Check for Sum type first (TypeA | TypeB) -> anyOf
|
|
27
|
+
return build_any_of_schema(dry_type) if TypeUnwrapper.sum_type?(dry_type)
|
|
28
|
+
|
|
29
|
+
# Check for Hash schema type (nested schemas like .hash(SomeSchema))
|
|
30
|
+
return build_hash_schema(dry_type) if hash_schema_type?(dry_type)
|
|
31
|
+
|
|
32
|
+
primitive, member = TypeUnwrapper.derive_primitive_and_member(dry_type)
|
|
33
|
+
enum_vals = extract_enum_from_type(dry_type)
|
|
34
|
+
|
|
35
|
+
schema = build_base_schema(primitive, member)
|
|
36
|
+
schema.nullable = true if nullable?(dry_type, constraints)
|
|
37
|
+
schema.enum = enum_vals if enum_vals
|
|
38
|
+
schema.enum = constraints.enum if constraints.enum && schema.enum.nil?
|
|
39
|
+
|
|
40
|
+
apply_constraints(schema, constraints, meta)
|
|
41
|
+
schema
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Checks if a type is required.
|
|
45
|
+
#
|
|
46
|
+
# @param dry_type [Object] the Dry type
|
|
47
|
+
# @param constraints [ConstraintSet, nil] extracted constraints
|
|
48
|
+
# @return [Boolean] true if required
|
|
49
|
+
def required?(dry_type, constraints = nil)
|
|
50
|
+
# prefer rule-derived info if present
|
|
51
|
+
return constraints.required if constraints && !constraints.required.nil?
|
|
52
|
+
|
|
53
|
+
meta = dry_type.respond_to?(:meta) ? dry_type.meta : {}
|
|
54
|
+
return false if dry_type.respond_to?(:optional?) && dry_type.optional?
|
|
55
|
+
return false if meta[:omittable]
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def build_any_of_schema(sum_type)
|
|
63
|
+
types = TypeUnwrapper.extract_sum_types(sum_type)
|
|
64
|
+
|
|
65
|
+
any_of_schemas = types.map do |t|
|
|
66
|
+
build_schema_for_sum_member(t)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
ApiModel::Schema.new(any_of: any_of_schemas)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_schema_for_sum_member(dry_type)
|
|
73
|
+
# Handle Hash schemas (Types::Hash.schema(...))
|
|
74
|
+
return build_hash_schema(dry_type) if hash_schema_type?(dry_type)
|
|
75
|
+
|
|
76
|
+
# Fall back to regular type handling
|
|
77
|
+
build_schema_for_type(dry_type)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hash_schema_type?(dry_type)
|
|
81
|
+
return true if dry_type.respond_to?(:keys) && dry_type.keys.any?
|
|
82
|
+
|
|
83
|
+
# Check for wrapped types
|
|
84
|
+
unwrapped = TypeUnwrapper.unwrap(dry_type)
|
|
85
|
+
unwrapped.respond_to?(:keys) && unwrapped.keys.any?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_hash_schema(dry_type)
|
|
89
|
+
schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
|
|
90
|
+
unwrapped = TypeUnwrapper.unwrap(dry_type)
|
|
91
|
+
|
|
92
|
+
return schema unless unwrapped.respond_to?(:keys)
|
|
93
|
+
|
|
94
|
+
# Dry::Schema keys method returns an array of Key objects, not a Hash
|
|
95
|
+
schema_keys = unwrapped.keys
|
|
96
|
+
schema_keys.each do |key|
|
|
97
|
+
key_name = key.respond_to?(:name) ? key.name.to_s : key.to_s
|
|
98
|
+
key_type = key.respond_to?(:type) ? key.type : nil
|
|
99
|
+
|
|
100
|
+
prop_schema = key_type ? build_schema_for_type(key_type) : default_string_schema
|
|
101
|
+
req = key.respond_to?(:required?) ? key.required? : true
|
|
102
|
+
schema.add_property(key_name, prop_schema, required: req)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
schema
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_base_schema(primitive, member)
|
|
109
|
+
if primitive == Array
|
|
110
|
+
items_schema = member ? build_schema_for_type(member) : default_string_schema
|
|
111
|
+
ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
|
|
112
|
+
else
|
|
113
|
+
build_schema_for_primitive(primitive)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_constraints(schema, constraints, meta)
|
|
118
|
+
applier = ConstraintApplier.new(schema, constraints, meta)
|
|
119
|
+
applier.apply_meta
|
|
120
|
+
applier.apply_rule_constraints
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def nullable?(dry_type, constraints)
|
|
124
|
+
meta = dry_type.respond_to?(:meta) ? dry_type.meta : {}
|
|
125
|
+
return true if dry_type.respond_to?(:optional?) && dry_type.optional?
|
|
126
|
+
return true if meta[:maybe]
|
|
127
|
+
return true if constraints&.nullable
|
|
128
|
+
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def extract_enum_from_type(dry_type)
|
|
133
|
+
return unless dry_type.respond_to?(:values)
|
|
134
|
+
|
|
135
|
+
vals = dry_type.values
|
|
136
|
+
vals if vals.is_a?(Array)
|
|
137
|
+
rescue NoMethodError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|