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,130 @@
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 OAS3Schema
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
+ "openapi" => openapi_version,
21
+ "info" => build_info,
22
+ "servers" => build_servers,
23
+ "tags" => build_tags,
24
+ "paths" => OAS3::Paths.new(@api, @ref_tracker,
25
+ nullable_keyword: nullable_keyword?,
26
+ suppress_default_error_response: @api.suppress_default_error_response,).build,
27
+ "components" => build_components,
28
+ "security" => build_security
29
+ }.compact
30
+ end
31
+
32
+ private
33
+
34
+ def openapi_version
35
+ "3.0.0"
36
+ end
37
+
38
+ # Allow subclasses (e.g., OAS31Schema) to override
39
+ def schema_builder
40
+ OAS3::Schema
41
+ end
42
+
43
+ def build_info
44
+ info = {
45
+ "title" => @api.title,
46
+ "version" => @api.version
47
+ }
48
+ license = if @api.respond_to?(:license) && @api.license
49
+ @api.license.dup
50
+ else
51
+ { "name" => Constants::Defaults::LICENSE_NAME, "url" => Constants::Defaults::LICENSE_URL }
52
+ end
53
+ license.delete("identifier")
54
+ license["url"] ||= Constants::Defaults::LICENSE_URL
55
+ info["license"] = license
56
+ info
57
+ end
58
+
59
+ def build_servers
60
+ servers = Array(@api.servers).map do |srv|
61
+ srv.is_a?(Hash) ? srv : { "url" => srv.to_s }
62
+ end
63
+
64
+ servers = [{ "url" => Constants::Defaults::SERVER_URL }] if servers.empty?
65
+
66
+ servers
67
+ end
68
+
69
+ def build_components
70
+ schemas = build_schemas
71
+ security_schemes = build_security_schemes
72
+ components = {}
73
+ components["schemas"] = schemas if schemas.any?
74
+ components["securitySchemes"] = security_schemes if security_schemes&.any?
75
+ components
76
+ end
77
+
78
+ def build_schemas
79
+ schemas = {}
80
+ pending = @ref_tracker ? @ref_tracker.to_a : []
81
+ processed = Set.new
82
+
83
+ # Add pre-registered models to pending
84
+ Array(@api.registered_schemas).each do |schema|
85
+ pending << schema.canonical_name if schema.respond_to?(:canonical_name) && schema.canonical_name
86
+ end
87
+
88
+ until pending.empty?
89
+ canonical_name = pending.shift
90
+ next if processed.include?(canonical_name)
91
+
92
+ processed << canonical_name
93
+
94
+ ref_name = canonical_name.gsub("::", "_")
95
+ schema = find_schema_by_canonical_name(canonical_name)
96
+ if schema
97
+ schemas[ref_name] =
98
+ schema_builder.new(schema, @ref_tracker, nullable_keyword: nullable_keyword?).build
99
+ end
100
+ collect_refs(schema, pending) if schema
101
+
102
+ # any new refs added while building
103
+ next unless @ref_tracker
104
+
105
+ @ref_tracker.to_a.each do |cn|
106
+ pending << cn unless processed.include?(cn) || pending.include?(cn)
107
+ end
108
+ end
109
+
110
+ schemas
111
+ end
112
+
113
+ def build_security_schemes
114
+ return nil if @api.security_definitions.nil? || @api.security_definitions.empty?
115
+
116
+ @api.security_definitions
117
+ end
118
+
119
+ def build_security
120
+ return @api.security unless @api.security.nil? || @api.security.empty?
121
+
122
+ []
123
+ end
124
+
125
+ def nullable_keyword?
126
+ true
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ # Registry for managing schema exporters for different OpenAPI versions.
6
+ # Allows third-party gems to register custom exporters for new formats.
7
+ #
8
+ # @example Registering a custom exporter with single alias
9
+ # GrapeOAS.exporters.register(MyCustomExporter, as: :custom)
10
+ #
11
+ # @example Registering with multiple aliases
12
+ # GrapeOAS.exporters.register(OAS30Schema, as: [:oas3, :oas30])
13
+ #
14
+ class Registry
15
+ def initialize
16
+ @exporters = {}
17
+ end
18
+
19
+ # Registers an exporter class for one or more schema types.
20
+ #
21
+ # @param exporter_class [Class] The exporter class to register
22
+ # @param as [Symbol, Array<Symbol>] The schema type identifier(s)
23
+ # @return [self]
24
+ def register(exporter_class, as:)
25
+ schema_types = Array(as)
26
+ schema_types.each { |type| @exporters[type] = exporter_class }
27
+ self
28
+ end
29
+
30
+ # Unregisters an exporter for one or more schema types.
31
+ #
32
+ # @param schema_types [Symbol, Array<Symbol>] The schema type(s) to remove
33
+ # @return [self]
34
+ def unregister(*schema_types)
35
+ schema_types.flatten.each { |type| @exporters.delete(type) }
36
+ self
37
+ end
38
+
39
+ # Finds the exporter class for a given schema type.
40
+ #
41
+ # @param schema_type [Symbol] The schema type
42
+ # @return [Class] The exporter class
43
+ # @raise [ArgumentError] if no exporter is registered for the type
44
+ def for(schema_type)
45
+ exporter = @exporters[schema_type]
46
+ raise ArgumentError, "Unsupported schema type: #{schema_type}" unless exporter
47
+
48
+ exporter
49
+ end
50
+
51
+ # Checks if an exporter is registered for the given schema type.
52
+ #
53
+ # @param schema_type [Symbol] The schema type to check
54
+ # @return [Boolean]
55
+ def registered?(schema_type)
56
+ @exporters.key?(schema_type)
57
+ end
58
+
59
+ # Returns all registered schema types.
60
+ #
61
+ # @return [Array<Symbol>]
62
+ def schema_types
63
+ @exporters.keys
64
+ end
65
+
66
+ # Returns the number of registered exporters.
67
+ #
68
+ # @return [Integer]
69
+ def size
70
+ @exporters.size
71
+ end
72
+
73
+ # Clears all registered exporters.
74
+ #
75
+ # @return [self]
76
+ def clear
77
+ @exporters.clear
78
+ self
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ # Returns the exporter class for the given schema type.
6
+ # Delegates to the global exporter registry.
7
+ #
8
+ # @param schema_type [Symbol] The type of schema (:oas2, :oas3, :oas30, :oas31)
9
+ # @return [Class] The exporter class for the specified schema type
10
+ # @raise [ArgumentError] if no exporter is registered for the type
11
+ def for(schema_type)
12
+ GrapeOAS.exporters.for(schema_type)
13
+ end
14
+ module_function :for
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ # Base module that defines the interface for all introspectors.
6
+ # Any introspector (built-in or third-party) must implement these class methods.
7
+ #
8
+ # @example Implementing a custom introspector
9
+ # class MyIntrospector
10
+ # extend GrapeOAS::Introspectors::Base
11
+ #
12
+ # def self.handles?(subject)
13
+ # subject.is_a?(Class) && subject < MyResponseModel
14
+ # end
15
+ #
16
+ # def self.build_schema(subject, stack: [], registry: {})
17
+ # # Build and return an ApiModel::Schema
18
+ # end
19
+ # end
20
+ #
21
+ # # Register the introspector
22
+ # GrapeOAS.introspectors.register(MyIntrospector)
23
+ #
24
+ module Base
25
+ # Checks if this introspector can handle the given subject.
26
+ #
27
+ # @param subject [Object] The object to introspect (e.g., entity class, contract)
28
+ # @return [Boolean] true if this introspector can handle the subject
29
+ def handles?(subject)
30
+ raise NotImplementedError, "#{self} must implement .handles?(subject)"
31
+ end
32
+
33
+ # Builds a schema from the given subject.
34
+ #
35
+ # @param subject [Object] The object to introspect
36
+ # @param stack [Array] Recursion stack for cycle detection
37
+ # @param registry [Hash] Schema registry for caching built schemas
38
+ # @return [ApiModel::Schema, nil] The built schema, or nil if not applicable
39
+ def build_schema(subject, stack: [], registry: {})
40
+ raise NotImplementedError, "#{self} must implement .build_schema(subject, stack:, registry:)"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "dry_introspector_support/contract_resolver"
5
+ require_relative "dry_introspector_support/inheritance_handler"
6
+ require_relative "dry_introspector_support/type_schema_builder"
7
+
8
+ module GrapeOAS
9
+ module Introspectors
10
+ # Introspector for Dry::Validation contracts and Dry::Schema.
11
+ # Extracts an ApiModel schema from contract definitions.
12
+ class DryIntrospector
13
+ extend Base
14
+
15
+ # Re-export ConstraintSet for external use
16
+ ConstraintSet = DryIntrospectorSupport::ConstraintExtractor::ConstraintSet
17
+
18
+ # Checks if the subject is a Dry contract or schema.
19
+ #
20
+ # @param subject [Object] The object to check
21
+ # @return [Boolean] true if subject is a Dry contract/schema
22
+ def self.handles?(subject)
23
+ # Check for Dry::Validation::Contract class
24
+ return true if dry_contract_class?(subject)
25
+
26
+ # Check for schema with types (instantiated contract or schema result)
27
+ return true if subject.respond_to?(:schema) && subject.schema.respond_to?(:types)
28
+
29
+ # Check for direct schema object
30
+ subject.respond_to?(:types)
31
+ end
32
+
33
+ # Builds a schema from a Dry contract or schema.
34
+ #
35
+ # @param subject [Object] Contract class, instance, or schema
36
+ # @param stack [Array] Recursion stack for cycle detection
37
+ # @param registry [Hash] Schema registry for caching
38
+ # @return [ApiModel::Schema, nil] The built schema
39
+ def self.build_schema(subject, stack: [], registry: {})
40
+ new(subject, stack: stack, registry: registry).build
41
+ end
42
+
43
+ # Legacy class method for backward compatibility.
44
+ # @deprecated Use build_schema instead
45
+ def self.build(contract, stack: [], registry: {})
46
+ build_schema(contract, stack: stack, registry: registry)
47
+ end
48
+
49
+ def self.dry_contract_class?(subject)
50
+ defined?(Dry::Validation::Contract) && subject.is_a?(Class) && subject < Dry::Validation::Contract
51
+ end
52
+ private_class_method :dry_contract_class?
53
+
54
+ def initialize(contract, stack: [], registry: {})
55
+ @contract = contract
56
+ @stack = stack
57
+ @registry = registry
58
+ end
59
+
60
+ def build
61
+ return unless contract_resolver.contract_schema.respond_to?(:types)
62
+
63
+ # Check registry cache first (like EntityIntrospector does)
64
+ cached = cached_schema
65
+ return cached if cached
66
+
67
+ parent_contract = inheritance_handler.find_parent_contract
68
+ return inheritance_handler.build_inherited_schema(parent_contract, type_schema_builder) if parent_contract
69
+
70
+ build_flat_schema
71
+ end
72
+
73
+ private
74
+
75
+ # Returns cached schema if it exists and has properties.
76
+ # Checks by both canonical_name (for Dry::Schema with schema_name)
77
+ # and contract_class (for Dry::Validation::Contract).
78
+ #
79
+ # @return [ApiModel::Schema, nil]
80
+ def cached_schema
81
+ # Try canonical_name first (for Dry::Schema objects with schema_name)
82
+ if contract_resolver.canonical_name
83
+ cached = @registry[contract_resolver.canonical_name]
84
+ return cached if cached && !cached.properties.empty?
85
+ end
86
+
87
+ # Fall back to contract_class (for Dry::Validation::Contract)
88
+ cached = @registry[contract_resolver.contract_class]
89
+ return cached if cached && !cached.properties.empty?
90
+
91
+ nil
92
+ end
93
+
94
+ def build_flat_schema
95
+ rule_constraints = DryIntrospectorSupport::ConstraintExtractor.extract(contract_resolver.contract_schema)
96
+ schema = ApiModel::Schema.new(
97
+ type: Constants::SchemaTypes::OBJECT,
98
+ canonical_name: contract_resolver.canonical_name,
99
+ )
100
+
101
+ contract_resolver.contract_schema.types.each do |name, dry_type|
102
+ constraints = rule_constraints[name]
103
+ prop_schema = type_schema_builder.build_schema_for_type(dry_type, constraints)
104
+ schema.add_property(name, prop_schema, required: type_schema_builder.required?(dry_type, constraints))
105
+ end
106
+
107
+ # Use canonical_name as registry key for schema objects (they don't have unique classes),
108
+ # fall back to contract_class for Contract classes
109
+ registry_key = contract_resolver.canonical_name || contract_resolver.contract_class
110
+ @registry[registry_key] = schema
111
+ schema
112
+ end
113
+
114
+ def contract_resolver
115
+ @contract_resolver ||= DryIntrospectorSupport::ContractResolver.new(@contract)
116
+ end
117
+
118
+ def inheritance_handler
119
+ @inheritance_handler ||= DryIntrospectorSupport::InheritanceHandler.new(
120
+ contract_resolver,
121
+ stack: @stack,
122
+ registry: @registry,
123
+ )
124
+ end
125
+
126
+ def type_schema_builder
127
+ @type_schema_builder ||= DryIntrospectorSupport::TypeSchemaBuilder.new
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module DryIntrospectorSupport
6
+ # Extracts typed values from Dry::Schema AST argument nodes.
7
+ module ArgumentExtractor
8
+ module_function
9
+
10
+ def extract_numeric(arg)
11
+ return arg if arg.is_a?(Numeric)
12
+ return arg[1] if arg.is_a?(Array) && arg.size == 2 && arg.first == :num
13
+
14
+ nil
15
+ end
16
+
17
+ def extract_range(arg)
18
+ return arg if arg.is_a?(Range)
19
+ return arg[1] if arg.is_a?(Array) && arg.first == :range
20
+
21
+ nil
22
+ end
23
+
24
+ def extract_list(arg)
25
+ return arg[1] if arg.is_a?(Array) && %i[list set].include?(arg.first)
26
+ return arg if arg.is_a?(Array)
27
+
28
+ nil
29
+ end
30
+
31
+ def extract_literal(arg)
32
+ return arg unless arg.is_a?(Array)
33
+ return arg[1] if arg.length == 2 && %i[value val literal class left right].include?(arg.first)
34
+ return extract_literal(arg.first) if arg.first.is_a?(Array)
35
+
36
+ arg
37
+ end
38
+
39
+ def extract_pattern(arg)
40
+ return arg.source if arg.is_a?(Regexp)
41
+ return arg[1].source if arg.is_a?(Array) && arg.first == :regexp && arg[1].is_a?(Regexp)
42
+ return arg[1] if arg.is_a?(Array) && arg.first == :regexp && arg[1].is_a?(String)
43
+ return arg[1].source if arg.is_a?(Array) && arg.first == :regex && arg[1].is_a?(Regexp)
44
+ return arg[1] if arg.is_a?(Array) && arg.first == :regex && arg[1].is_a?(String)
45
+
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module DryIntrospectorSupport
6
+ # Walks Dry::Schema AST nodes and extracts validation constraints.
7
+ #
8
+ # Dry::Schema and Dry::Validation use an AST representation for their rules.
9
+ # This walker traverses the AST and extracts constraints (enum values, min/max,
10
+ # nullable, etc.) into a ConstraintSet that can be applied to OpenAPI schemas.
11
+ #
12
+ # @example Walking a simple AST
13
+ # walker = AstWalker.new(ConstraintSet)
14
+ # constraints = walker.walk([:predicate, [:type?, [String]]])
15
+ #
16
+ class AstWalker
17
+ # AST tags that represent logical/structural nodes vs predicates
18
+ LOGIC_TAGS = %i[predicate rule and or implication not key each].freeze
19
+
20
+ # Creates a new AST walker.
21
+ #
22
+ # @param constraint_set_class [Class] the class to use for constraint aggregation
23
+ def initialize(constraint_set_class)
24
+ @constraint_set_class = constraint_set_class
25
+ end
26
+
27
+ # Walks an AST and extracts all constraints.
28
+ #
29
+ # @param ast [Array] the Dry::Schema AST node
30
+ # @return [ConstraintSet] the extracted constraints
31
+ def walk(ast)
32
+ constraints = constraint_set_class.new(unhandled_predicates: [])
33
+ visit(ast, constraints)
34
+ constraints
35
+ end
36
+
37
+ # Intersects multiple constraint branches (for OR logic).
38
+ #
39
+ # When handling OR branches, only constraints that apply to ALL branches
40
+ # should be included in the output. This method computes the intersection.
41
+ #
42
+ # @param branches [Array<ConstraintSet>] the branches to intersect
43
+ # @return [ConstraintSet] the intersected constraints
44
+ def intersect_branches(branches)
45
+ return branches.first if branches.size <= 1
46
+
47
+ base = branches.first
48
+ branches[1..].each do |b|
49
+ intersect_branch(base, b)
50
+ end
51
+ base
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :constraint_set_class
57
+
58
+ def visit(node, constraints)
59
+ return unless node.is_a?(Array)
60
+
61
+ tag = node[0]
62
+ if shorthand_predicate?(tag, node)
63
+ PredicateHandler.new(constraints).handle([tag, node[1]])
64
+ return
65
+ end
66
+ visit_tag(tag, node, constraints)
67
+ end
68
+
69
+ def shorthand_predicate?(tag, node)
70
+ tag.is_a?(Symbol) && !LOGIC_TAGS.include?(tag) && node.length == 2
71
+ end
72
+
73
+ def visit_tag(tag, node, constraints)
74
+ case tag
75
+ when :predicate
76
+ PredicateHandler.new(constraints).handle(node[1])
77
+ when :rule, :not, :each
78
+ visit(node[1], constraints)
79
+ when :or
80
+ visit_or_branch(node, constraints)
81
+ when :key
82
+ visit(node[1][1], constraints) if node[1].is_a?(Array)
83
+ else # :and, :implication, and any other tags
84
+ visit_children(node, constraints)
85
+ end
86
+ end
87
+
88
+ def visit_children(node, constraints)
89
+ Array(node[1]).each { |child| visit(child, constraints) }
90
+ end
91
+
92
+ def visit_or_branch(node, constraints)
93
+ branches = Array(node[1]).map { |child| branch_constraints(child) }
94
+ common = intersect_branches(branches)
95
+ ConstraintMerger.merge(constraints, common)
96
+ end
97
+
98
+ def branch_constraints(child)
99
+ c = constraint_set_class.new(unhandled_predicates: [])
100
+ visit(child, c)
101
+ c
102
+ end
103
+
104
+ def intersect_branch(base, other)
105
+ base.enum = (base.enum & other.enum) if base.enum && other.enum
106
+ base.min_size = intersect_min(base.min_size, other.min_size)
107
+ base.max_size = intersect_max(base.max_size, other.max_size)
108
+ base.minimum = intersect_min(base.minimum, other.minimum)
109
+ base.maximum = intersect_max(base.maximum, other.maximum)
110
+ base.exclusive_minimum &&= other.exclusive_minimum if other.exclusive_minimum == false
111
+ base.exclusive_maximum &&= other.exclusive_maximum if other.exclusive_maximum == false
112
+ base.nullable &&= other.nullable if other.nullable == false
113
+ end
114
+
115
+ def intersect_min(val1, val2)
116
+ val1 && val2 ? [val1, val2].max : nil
117
+ end
118
+
119
+ def intersect_max(val1, val2)
120
+ val1 && val2 ? [val1, val2].min : nil
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end