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