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