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