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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module DryIntrospectorSupport
6
+ # Unwraps Dry::Types to extract primitives and member types.
7
+ #
8
+ # Dry::Types can be deeply nested with wrappers (Constrained, Sum, etc.).
9
+ # This module provides utilities to unwrap these types and extract the
10
+ # underlying primitive type and any array member types.
11
+ #
12
+ # @example Unwrapping a constrained type
13
+ # core = TypeUnwrapper.unwrap(Dry::Types["strict.string"].constrained(max_size: 10))
14
+ # # => Returns the base String type
15
+ #
16
+ module TypeUnwrapper
17
+ # Maximum depth for unwrapping nested Dry::Types (prevents infinite loops)
18
+ MAX_DEPTH = 5
19
+
20
+ module_function
21
+
22
+ # Derives the primitive type and member type from a Dry::Types type.
23
+ #
24
+ # @param dry_type [Dry::Types::Type] the type to analyze
25
+ # @return [Array(Class, Object)] tuple of [primitive_class, member_type_or_nil]
26
+ def derive_primitive_and_member(dry_type)
27
+ core = unwrap(dry_type)
28
+
29
+ return [Array, core.type.member] if array_member_type?(core)
30
+ return [Array, core.member] if array_with_member?(core)
31
+
32
+ primitive = core.respond_to?(:primitive) ? core.primitive : nil
33
+ [primitive, nil]
34
+ end
35
+
36
+ # Unwraps a Dry::Types type to get to the core type.
37
+ #
38
+ # @param dry_type [Dry::Types::Type] the type to unwrap
39
+ # @return [Dry::Types::Type] the unwrapped core type
40
+ def unwrap(dry_type)
41
+ current = dry_type
42
+ depth = 0
43
+
44
+ while current.respond_to?(:type) && depth < MAX_DEPTH
45
+ inner = current.type
46
+ break if inner.equal?(current)
47
+
48
+ current = inner
49
+ depth += 1
50
+ end
51
+
52
+ current
53
+ end
54
+
55
+ # Detects if type is a meaningful Dry::Types::Sum (union type like TypeA | TypeB).
56
+ #
57
+ # Returns false for nullable sums (nil | String) which are created by maybe(),
58
+ # as those should be treated as nullable types rather than union types.
59
+ #
60
+ # @param dry_type [Dry::Types::Type] the type to check
61
+ # @return [Boolean] true if it's a meaningful sum type
62
+ def sum_type?(dry_type)
63
+ return false unless defined?(Dry::Types::Sum)
64
+ return false unless dry_type.is_a?(Dry::Types::Sum) || dry_type.class.name&.include?("Sum")
65
+
66
+ # Check if this is a "schema sum" (both sides are Hash schemas)
67
+ # vs a "nullable sum" (one side is NilClass from maybe())
68
+ schema_sum?(dry_type)
69
+ end
70
+
71
+ # Checks if Sum type represents a union of schemas (not just nullable).
72
+ #
73
+ # @param sum_type [Dry::Types::Sum] the sum type to check
74
+ # @return [Boolean] true if it's a union of 2+ non-nil schemas
75
+ def schema_sum?(sum_type)
76
+ return false unless sum_type.respond_to?(:left) && sum_type.respond_to?(:right)
77
+
78
+ types = extract_sum_types(sum_type)
79
+
80
+ # Filter out NilClass types (from maybe())
81
+ non_nil_types = types.reject { |t| nil_type?(t) }
82
+
83
+ # It's a schema sum if we have 2+ non-nil types and at least one is a Hash schema
84
+ non_nil_types.length >= 2 && non_nil_types.any? { |t| hash_schema?(t) }
85
+ end
86
+
87
+ # Checks if type is a nil type (from maybe()).
88
+ #
89
+ # @param dry_type [Dry::Types::Type] the type to check
90
+ # @return [Boolean] true if it's a NilClass type
91
+ def nil_type?(dry_type)
92
+ return true if dry_type.respond_to?(:primitive) && dry_type.primitive == NilClass
93
+
94
+ # Check wrapped type
95
+ unwrapped = unwrap(dry_type)
96
+ unwrapped.respond_to?(:primitive) && unwrapped.primitive == NilClass
97
+ end
98
+
99
+ # Checks if type is a Hash schema (has keys defined).
100
+ #
101
+ # @param dry_type [Dry::Types::Type] the type to check
102
+ # @return [Boolean] true if it's a Hash schema with keys
103
+ def hash_schema?(dry_type)
104
+ return true if dry_type.respond_to?(:keys) && dry_type.keys.any?
105
+
106
+ unwrapped = unwrap(dry_type)
107
+ unwrapped.respond_to?(:keys) && unwrapped.keys.any?
108
+ end
109
+
110
+ # Recursively extracts all types from a Sum type tree.
111
+ #
112
+ # A | B | C becomes Sum(Sum(A, B), C), so we need to traverse the tree.
113
+ #
114
+ # @param dry_type [Dry::Types::Type] the sum type to extract from
115
+ # @param types [Array] accumulator array for types (internal use)
116
+ # @return [Array<Dry::Types::Type>] all leaf types in the sum
117
+ def extract_sum_types(dry_type, types = [])
118
+ if dry_type.respond_to?(:left) && dry_type.respond_to?(:right)
119
+ extract_sum_types(dry_type.left, types)
120
+ extract_sum_types(dry_type.right, types)
121
+ else
122
+ types << dry_type
123
+ end
124
+ types
125
+ end
126
+
127
+ def array_member_type?(core)
128
+ defined?(Dry::Types::Array::Member) &&
129
+ core.respond_to?(:type) &&
130
+ core.type.is_a?(Dry::Types::Array::Member)
131
+ end
132
+ private_class_method :array_member_type?
133
+
134
+ def array_with_member?(core)
135
+ core.respond_to?(:member) &&
136
+ core.respond_to?(:primitive) &&
137
+ core.primitive == Array
138
+ end
139
+ private_class_method :array_with_member?
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../api_model_builders/concerns/type_resolver"
5
+ require_relative "entity_introspector_support/cycle_tracker"
6
+ require_relative "entity_introspector_support/discriminator_handler"
7
+ require_relative "entity_introspector_support/inheritance_builder"
8
+ require_relative "entity_introspector_support/property_extractor"
9
+ require_relative "entity_introspector_support/exposure_processor"
10
+
11
+ module GrapeOAS
12
+ module Introspectors
13
+ # Introspector for Grape::Entity classes.
14
+ # Extracts schema information from entity exposures and documentation.
15
+ class EntityIntrospector
16
+ extend Base
17
+ include GrapeOAS::ApiModelBuilders::Concerns::TypeResolver
18
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
19
+
20
+ # Checks if the subject is a Grape::Entity class.
21
+ #
22
+ # @param subject [Object] The object to check
23
+ # @return [Boolean] true if subject is a Grape::Entity class
24
+ def self.handles?(subject)
25
+ return false unless defined?(Grape::Entity)
26
+ return subject <= Grape::Entity if subject.is_a?(Class)
27
+ return false unless subject.is_a?(String) || subject.is_a?(Symbol)
28
+
29
+ const_name = subject.to_s
30
+ return false unless const_name.match?(/\A[A-Z]/)
31
+ return false unless Object.const_defined?(const_name, false)
32
+
33
+ klass = Object.const_get(const_name, false)
34
+ klass.is_a?(Class) && klass <= Grape::Entity
35
+ rescue NameError
36
+ false
37
+ end
38
+
39
+ # Builds a schema from a Grape::Entity class.
40
+ #
41
+ # @param subject [Class, String, Symbol] Entity class or its name
42
+ # @param stack [Array] Recursion stack for cycle detection
43
+ # @param registry [Hash] Schema registry for caching
44
+ # @return [ApiModel::Schema] The built schema
45
+ def self.build_schema(subject, stack: [], registry: {})
46
+ entity_class = resolve_entity_class(subject)
47
+ return nil unless entity_class
48
+
49
+ new(entity_class, stack: stack, registry: registry).build_schema
50
+ end
51
+
52
+ # Resolves a subject to an entity class.
53
+ #
54
+ # @param subject [Class, String, Symbol] Entity class or its name
55
+ # @return [Class, nil] The resolved entity class
56
+ def self.resolve_entity_class(subject)
57
+ return subject if subject.is_a?(Class) && defined?(Grape::Entity) && subject <= Grape::Entity
58
+ return nil unless subject.is_a?(String) || subject.is_a?(Symbol)
59
+
60
+ const_name = subject.to_s
61
+ return nil unless Object.const_defined?(const_name, false)
62
+
63
+ klass = Object.const_get(const_name, false)
64
+ klass if klass.is_a?(Class) && defined?(Grape::Entity) && klass <= Grape::Entity
65
+ rescue NameError
66
+ nil
67
+ end
68
+
69
+ def initialize(entity_class, stack: [], registry: {})
70
+ @entity_class = entity_class
71
+ @stack = stack
72
+ @registry = registry
73
+ end
74
+
75
+ def build_schema
76
+ return cached_schema if cached_schema_available?
77
+ return build_inherited_schema if inherits_with_discriminator?
78
+
79
+ schema = initialize_or_reuse_schema
80
+ return cycle_tracker.handle_cycle(schema) if cycle_tracker.cyclic_reference?
81
+
82
+ cycle_tracker.with_tracking { populate_schema(schema) }
83
+ end
84
+
85
+ private
86
+
87
+ def cached_schema_available?
88
+ built = @registry[@entity_class]
89
+ built && !built.properties.empty?
90
+ end
91
+
92
+ def cached_schema
93
+ @registry[@entity_class]
94
+ end
95
+
96
+ def initialize_or_reuse_schema
97
+ @registry[@entity_class] ||= ApiModel::Schema.new(
98
+ type: Constants::SchemaTypes::OBJECT,
99
+ canonical_name: @entity_class.name,
100
+ description: nil,
101
+ nullable: nil,
102
+ )
103
+ end
104
+
105
+ def populate_schema(schema)
106
+ doc = entity_doc
107
+ apply_schema_metadata(schema, doc)
108
+ exposure_processor.add_exposures_to_schema(schema)
109
+ schema
110
+ end
111
+
112
+ def apply_schema_metadata(schema, doc)
113
+ schema.description ||= EntityIntrospectorSupport::PropertyExtractor.extract_description(doc)
114
+ schema.nullable = EntityIntrospectorSupport::PropertyExtractor.extract_nullable(doc) if schema.nullable.nil?
115
+ EntityIntrospectorSupport::PropertyExtractor.apply_entity_level_properties(schema, doc)
116
+ apply_extensions(schema, doc)
117
+ discriminator_handler.apply(schema)
118
+ end
119
+
120
+ def apply_extensions(schema, doc)
121
+ root_ext = extract_extensions(doc)
122
+ schema.extensions = root_ext if root_ext
123
+ end
124
+
125
+ def entity_doc
126
+ @entity_class.respond_to?(:documentation) ? (@entity_class.documentation || {}) : {}
127
+ rescue NoMethodError
128
+ {}
129
+ end
130
+
131
+ def inherits_with_discriminator?
132
+ EntityIntrospectorSupport::InheritanceBuilder.inherits_with_discriminator?(@entity_class)
133
+ end
134
+
135
+ def build_inherited_schema
136
+ parent = EntityIntrospectorSupport::InheritanceBuilder.find_parent_entity(@entity_class)
137
+ inheritance_builder.build_inherited_schema(parent)
138
+ end
139
+
140
+ def cycle_tracker
141
+ @cycle_tracker ||= EntityIntrospectorSupport::CycleTracker.new(@entity_class, @stack)
142
+ end
143
+
144
+ def discriminator_handler
145
+ @discriminator_handler ||= EntityIntrospectorSupport::DiscriminatorHandler.new(@entity_class)
146
+ end
147
+
148
+ def exposure_processor
149
+ @exposure_processor ||= EntityIntrospectorSupport::ExposureProcessor.new(
150
+ @entity_class,
151
+ stack: @stack,
152
+ registry: @registry,
153
+ )
154
+ end
155
+
156
+ def inheritance_builder
157
+ @inheritance_builder ||= EntityIntrospectorSupport::InheritanceBuilder.new(
158
+ @entity_class,
159
+ stack: @stack,
160
+ registry: @registry,
161
+ )
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module EntityIntrospectorSupport
6
+ # Tracks and handles cyclic references during entity introspection.
7
+ class CycleTracker
8
+ def initialize(entity_class, stack)
9
+ @entity_class = entity_class
10
+ @stack = stack
11
+ end
12
+
13
+ # Checks if the current entity class is already in the processing stack.
14
+ #
15
+ # @return [Boolean] true if a cycle is detected
16
+ def cyclic_reference?
17
+ @stack.include?(@entity_class)
18
+ end
19
+
20
+ # Handles a detected cycle by marking the schema with a description.
21
+ #
22
+ # @param schema [ApiModel::Schema] the schema to mark
23
+ # @return [ApiModel::Schema] the marked schema
24
+ def handle_cycle(schema)
25
+ schema.description ||= "Cycle detected while introspecting"
26
+ schema
27
+ end
28
+
29
+ # Executes a block while tracking the current entity in the stack.
30
+ #
31
+ # @yield the block to execute while tracking
32
+ # @return the result of the block
33
+ def with_tracking
34
+ @stack << @entity_class
35
+ yield
36
+ ensure
37
+ @stack.pop
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module EntityIntrospectorSupport
6
+ # Handles discriminator fields in entity inheritance for polymorphic schemas.
7
+ class DiscriminatorHandler
8
+ # Checks if an entity inherits from a parent that uses discriminator.
9
+ #
10
+ # @param entity_class [Class] the entity class to check
11
+ # @return [Boolean] true if parent has a discriminator field
12
+ def self.inherits_with_discriminator?(entity_class)
13
+ parent = find_parent_entity(entity_class)
14
+ parent && new(parent).discriminator?
15
+ end
16
+
17
+ # Finds the parent entity class if one exists.
18
+ #
19
+ # @param entity_class [Class] the entity class
20
+ # @return [Class, nil] the parent entity class or nil
21
+ def self.find_parent_entity(entity_class)
22
+ return nil unless defined?(Grape::Entity)
23
+
24
+ parent = entity_class.superclass
25
+ return nil unless parent && parent < Grape::Entity && parent != Grape::Entity
26
+
27
+ parent
28
+ end
29
+
30
+ def initialize(entity_class)
31
+ @entity_class = entity_class
32
+ end
33
+
34
+ # Applies discriminator field to the schema if one is defined.
35
+ #
36
+ # @param schema [ApiModel::Schema] the schema to modify
37
+ def apply(schema)
38
+ discriminator_field = find_discriminator_field
39
+ schema.discriminator = discriminator_field if discriminator_field
40
+ end
41
+
42
+ # Checks if this entity has a discriminator field.
43
+ #
44
+ # @return [Boolean] true if discriminator field exists
45
+ def discriminator?
46
+ exposures.any? do |exposure|
47
+ doc = exposure.documentation || {}
48
+ doc[:is_discriminator] || doc["is_discriminator"]
49
+ end
50
+ rescue NoMethodError
51
+ false
52
+ end
53
+
54
+ # Finds the discriminator field name from entity exposures.
55
+ #
56
+ # @return [String, nil] the discriminator field name or nil
57
+ def find_discriminator_field
58
+ exposures.each do |exposure|
59
+ doc = exposure.documentation || {}
60
+ is_discriminator = doc[:is_discriminator] || doc["is_discriminator"]
61
+ return exposure.key.to_s if is_discriminator
62
+ end
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ # Gets the exposures defined on the entity class.
69
+ #
70
+ # @return [Array] list of entity exposures
71
+ def exposures
72
+ return [] unless @entity_class.respond_to?(:root_exposures)
73
+
74
+ root = @entity_class.root_exposures
75
+ list = root.instance_variable_get(:@exposures) || []
76
+ Array(list)
77
+ rescue NoMethodError
78
+ []
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module EntityIntrospectorSupport
6
+ # Processes entity exposures and builds schemas from them.
7
+ #
8
+ class ExposureProcessor
9
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
10
+
11
+ def initialize(entity_class, stack:, registry:)
12
+ @entity_class = entity_class
13
+ @stack = stack
14
+ @registry = registry
15
+ end
16
+
17
+ # Adds all exposures to a schema.
18
+ #
19
+ # @param schema [ApiModel::Schema] the schema to populate
20
+ def add_exposures_to_schema(schema)
21
+ exposures.each do |exposure|
22
+ next unless exposed?(exposure)
23
+
24
+ add_exposure_to_schema(schema, exposure)
25
+ end
26
+ end
27
+
28
+ # Gets the exposures defined on the entity class.
29
+ #
30
+ # @return [Array] list of entity exposures
31
+ def exposures
32
+ return [] unless @entity_class.respond_to?(:root_exposures)
33
+
34
+ root = @entity_class.root_exposures
35
+ list = root.instance_variable_get(:@exposures) || []
36
+ Array(list)
37
+ rescue NoMethodError
38
+ []
39
+ end
40
+
41
+ # Gets the exposures defined on a parent entity.
42
+ #
43
+ # @param parent_entity [Class] the parent entity class
44
+ # @return [Array] list of parent exposures
45
+ def parent_exposures(parent_entity)
46
+ return [] unless parent_entity.respond_to?(:root_exposures)
47
+
48
+ root = parent_entity.root_exposures
49
+ list = root.instance_variable_get(:@exposures) || []
50
+ Array(list)
51
+ rescue NoMethodError
52
+ []
53
+ end
54
+
55
+ # Builds a schema for an exposure.
56
+ #
57
+ # @param exposure the entity exposure
58
+ # @param doc [Hash] the documentation hash
59
+ # @return [ApiModel::Schema] the built schema
60
+ def schema_for_exposure(exposure, doc)
61
+ opts = exposure.instance_variable_get(:@options) || {}
62
+ type = doc[:type] || doc["type"] || opts[:using]
63
+
64
+ schema = build_exposure_base_schema(type)
65
+ apply_exposure_properties(schema, doc)
66
+ apply_exposure_constraints(schema, doc)
67
+ schema
68
+ end
69
+
70
+ # Checks if an exposure should be included in the schema.
71
+ #
72
+ # @param exposure the entity exposure
73
+ # @return [Boolean] true if exposed
74
+ def exposed?(exposure)
75
+ exposure.instance_variable_get(:@conditions) || []
76
+ true
77
+ rescue NoMethodError
78
+ true
79
+ end
80
+
81
+ # Checks if an exposure is conditional.
82
+ #
83
+ # @param exposure the entity exposure
84
+ # @return [Boolean] true if conditional
85
+ def conditional?(exposure)
86
+ conditions = exposure.instance_variable_get(:@conditions) || []
87
+ !conditions.empty?
88
+ rescue NoMethodError
89
+ false
90
+ end
91
+
92
+ # Checks if an exposure is a merge exposure.
93
+ #
94
+ # @param exposure the entity exposure
95
+ # @param doc [Hash] the documentation hash
96
+ # @param opts [Hash] the options hash
97
+ # @return [Boolean] true if merge exposure
98
+ def merge_exposure?(exposure, doc, opts)
99
+ merge_flag = PropertyExtractor.extract_merge_flag(exposure, doc, opts)
100
+ merge_flag && resolve_entity_from_opts(exposure, doc)
101
+ end
102
+
103
+ private
104
+
105
+ def add_exposure_to_schema(schema, exposure)
106
+ doc = exposure.documentation || {}
107
+ opts = exposure.instance_variable_get(:@options) || {}
108
+
109
+ if merge_exposure?(exposure, doc, opts)
110
+ merge_exposure_into_schema(schema, exposure, doc)
111
+ else
112
+ add_property_from_exposure(schema, exposure, doc)
113
+ end
114
+ end
115
+
116
+ def merge_exposure_into_schema(schema, exposure, doc)
117
+ merged_schema = schema_for_merge(exposure, doc)
118
+ merged_schema.properties.each do |n, ps|
119
+ schema.add_property(n, ps, required: merged_schema.required.include?(n))
120
+ end
121
+ end
122
+
123
+ def add_property_from_exposure(schema, exposure, doc)
124
+ prop_schema = schema_for_exposure(exposure, doc)
125
+ required = determine_required(doc, exposure)
126
+ prop_schema = wrap_in_array_if_needed(prop_schema, doc)
127
+ schema.add_property(exposure.key.to_s, prop_schema, required: required)
128
+ end
129
+
130
+ def determine_required(doc, exposure)
131
+ # If explicitly set in documentation, use that value
132
+ return doc[:required] unless doc[:required].nil?
133
+
134
+ # Conditional exposures are not required (may be absent from output)
135
+ return false if conditional?(exposure)
136
+
137
+ # Unconditional exposures are required by default (always present in output)
138
+ true
139
+ end
140
+
141
+ def wrap_in_array_if_needed(prop_schema, doc)
142
+ is_array = doc[:is_array] || doc["is_array"]
143
+ return prop_schema unless is_array
144
+
145
+ ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
146
+ end
147
+
148
+ def build_exposure_base_schema(type)
149
+ if type.is_a?(Array)
150
+ # Array instance like [String] - extract inner type
151
+ inner = schema_for_type(type.first)
152
+ ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: inner)
153
+ elsif type == Array
154
+ # Array class itself - create array with string items
155
+ ApiModel::Schema.new(
156
+ type: Constants::SchemaTypes::ARRAY,
157
+ items: ApiModel::Schema.new(type: Constants::SchemaTypes::STRING),
158
+ )
159
+ elsif type.is_a?(Hash) || type == Hash
160
+ ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
161
+ else
162
+ schema_for_type(type) || ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
163
+ end
164
+ end
165
+
166
+ def apply_exposure_properties(schema, doc)
167
+ schema.nullable = doc[:nullable] || doc["nullable"] || false
168
+ schema.enum = doc[:values] || doc["values"] if doc[:values] || doc["values"]
169
+ schema.description = doc[:desc] || doc["desc"] if doc[:desc] || doc["desc"]
170
+ schema.format = doc[:format] || doc["format"] if doc[:format] || doc["format"]
171
+ schema.examples = doc[:example] || doc["example"] if schema.respond_to?(:examples=) && (doc[:example] || doc["example"])
172
+ schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
173
+ schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
174
+ defs = doc[:defs] || doc[:$defs]
175
+ schema.defs = defs if defs.is_a?(Hash)
176
+ x_ext = extract_extensions(doc)
177
+ schema.extensions = x_ext if x_ext && schema.respond_to?(:extensions=)
178
+ end
179
+
180
+ def apply_exposure_constraints(schema, doc)
181
+ schema.minimum = doc[:minimum] if doc.key?(:minimum) && schema.respond_to?(:minimum=)
182
+ schema.maximum = doc[:maximum] if doc.key?(:maximum) && schema.respond_to?(:maximum=)
183
+ schema.min_length = doc[:min_length] if doc.key?(:min_length) && schema.respond_to?(:min_length=)
184
+ schema.max_length = doc[:max_length] if doc.key?(:max_length) && schema.respond_to?(:max_length=)
185
+ schema.pattern = doc[:pattern] if doc.key?(:pattern) && schema.respond_to?(:pattern=)
186
+ end
187
+
188
+ def schema_for_type(type)
189
+ case type
190
+ when Class
191
+ schema_for_class_type(type)
192
+ when String, Symbol
193
+ schema_for_string_type(type.to_s)
194
+ else
195
+ default_string_schema
196
+ end
197
+ end
198
+
199
+ def schema_for_class_type(type)
200
+ if defined?(Grape::Entity) && type <= Grape::Entity
201
+ GrapeOAS.introspectors.build_schema(type, stack: @stack, registry: @registry)
202
+ else
203
+ build_schema_for_primitive(type) || default_string_schema
204
+ end
205
+ end
206
+
207
+ def schema_for_string_type(type_name)
208
+ entity_class = resolve_entity_from_string(type_name)
209
+ if entity_class
210
+ GrapeOAS.introspectors.build_schema(entity_class, stack: @stack, registry: @registry)
211
+ else
212
+ schema_type = Constants.primitive_type(type_name) || Constants::SchemaTypes::STRING
213
+ ApiModel::Schema.new(type: schema_type)
214
+ end
215
+ end
216
+
217
+ def default_string_schema
218
+ ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
219
+ end
220
+
221
+ def resolve_entity_from_string(type_name)
222
+ return nil unless defined?(Grape::Entity)
223
+ return nil unless valid_constant_name?(type_name)
224
+ return nil unless Object.const_defined?(type_name, false)
225
+
226
+ klass = Object.const_get(type_name, false)
227
+ klass if klass.is_a?(Class) && klass <= Grape::Entity
228
+ rescue NameError
229
+ nil
230
+ end
231
+
232
+ def schema_for_merge(exposure, doc)
233
+ using_class = resolve_entity_from_opts(exposure, doc)
234
+ return ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT) unless using_class
235
+
236
+ child = GrapeOAS.introspectors.build_schema(using_class, stack: @stack, registry: @registry)
237
+ merged = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
238
+ child.properties.each do |n, ps|
239
+ merged.add_property(n, ps, required: child.required.include?(n))
240
+ end
241
+ merged
242
+ end
243
+
244
+ def resolve_entity_from_opts(exposure, doc)
245
+ opts = exposure.instance_variable_get(:@options) || {}
246
+ type = doc[:type] || doc["type"] || opts[:using]
247
+ return type if defined?(Grape::Entity) && type.is_a?(Class) && type <= Grape::Entity
248
+
249
+ nil
250
+ end
251
+
252
+ def build_schema_for_primitive(type)
253
+ schema_type = Constants.primitive_type(type)
254
+ return nil unless schema_type
255
+
256
+ ApiModel::Schema.new(type: schema_type)
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end