rapitapir 0.1.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/.rspec +3 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +94 -0
- data/CLEANUP_SUMMARY.md +155 -0
- data/CONTRIBUTING.md +280 -0
- data/LICENSE +21 -0
- data/README.md +485 -0
- data/debug_hash.rb +20 -0
- data/docs/EXTENSION_COMPARISON.md +388 -0
- data/docs/SINATRA_EXTENSION.md +467 -0
- data/docs/archive/PHASE_1_2_COMPLETE.md +77 -0
- data/docs/archive/PHASE_1_3_COMPLETE.md +152 -0
- data/docs/archive/PHASE_2_1_OBSERVABILITY_COMPLETED.md +203 -0
- data/docs/archive/PHASE_2_SUMMARY.md +209 -0
- data/docs/archive/REFACTORING_SUMMARY.md +184 -0
- data/docs/archive/phase_1_3_plan.md +136 -0
- data/docs/archive/sinatra_extension_summary.md +188 -0
- data/docs/archive/sinatra_working_solution.md +113 -0
- data/docs/archive/typescript-client-generator-summary.md +259 -0
- data/docs/auto-derivation.md +146 -0
- data/docs/blueprint.md +1091 -0
- data/docs/endpoint-definition.md +211 -0
- data/docs/github_pages_fix.md +52 -0
- data/docs/github_pages_setup.md +49 -0
- data/docs/implementation-status.md +357 -0
- data/docs/observability.md +647 -0
- data/docs/phase3-plan.md +108 -0
- data/docs/sinatra_rapitapir.md +87 -0
- data/docs/type_shortcuts.md +146 -0
- data/examples/README_ENTERPRISE.md +202 -0
- data/examples/authentication_example.rb +192 -0
- data/examples/auto_derivation_ruby_friendly.rb +163 -0
- data/examples/cli/user_api_endpoints.rb +56 -0
- data/examples/client/typescript_client_example.rb +102 -0
- data/examples/client/user-api-client.ts +193 -0
- data/examples/demo_api.rb +41 -0
- data/examples/docs/documentation_example.rb +112 -0
- data/examples/docs/user-api-docs.html +789 -0
- data/examples/docs/user-api-docs.md +403 -0
- data/examples/enhanced_auto_derivation_test.rb +83 -0
- data/examples/enterprise_extension_demo.rb +417 -0
- data/examples/enterprise_rapitapir_api.rb +662 -0
- data/examples/getting_started_extension.rb +218 -0
- data/examples/hello_world.rb +74 -0
- data/examples/oauth2/.env.example +19 -0
- data/examples/oauth2/README.md +205 -0
- data/examples/oauth2/generic_oauth2_api.rb +226 -0
- data/examples/oauth2/get_token.rb +72 -0
- data/examples/oauth2/songs_api_with_auth0.rb +320 -0
- data/examples/oauth2/test_api.sh +16 -0
- data/examples/oauth2/test_songs_api.sh +110 -0
- data/examples/observability/.env.example +35 -0
- data/examples/observability/README.md +230 -0
- data/examples/observability/README_HONEYCOMB.md +332 -0
- data/examples/observability/advanced_setup.rb +384 -0
- data/examples/observability/basic_setup.rb +192 -0
- data/examples/observability/complete_test.rb +121 -0
- data/examples/observability/honeycomb_example.rb +523 -0
- data/examples/observability/honeycomb_rapitapir_clean.rb +488 -0
- data/examples/observability/honeycomb_rapitapir_example.rb +523 -0
- data/examples/observability/honeycomb_working_example.rb +489 -0
- data/examples/observability/quick_test.rb +78 -0
- data/examples/observability/simple_test.rb +14 -0
- data/examples/observability/test_honeycomb_demo.rb +354 -0
- data/examples/observability/test_live_honeycomb.rb +111 -0
- data/examples/observability/test_validation.rb +78 -0
- data/examples/observability/test_working_validation.rb +66 -0
- data/examples/openapi/user_api_schema.rb +132 -0
- data/examples/production_ready_example.rb +105 -0
- data/examples/rails/users_controller.rb +146 -0
- data/examples/readme/basic_sinatra_example.rb +128 -0
- data/examples/server/user_api.rb +179 -0
- data/examples/simple_auto_derivation_demo.rb +44 -0
- data/examples/simple_demo_api.rb +18 -0
- data/examples/sinatra/user_app.rb +127 -0
- data/examples/t_shortcut_demo.rb +59 -0
- data/examples/user_api.rb +190 -0
- data/examples/working_getting_started.rb +184 -0
- data/examples/working_simple_example.rb +195 -0
- data/lib/rapitapir/auth/configuration.rb +129 -0
- data/lib/rapitapir/auth/context.rb +122 -0
- data/lib/rapitapir/auth/errors.rb +104 -0
- data/lib/rapitapir/auth/middleware.rb +324 -0
- data/lib/rapitapir/auth/oauth2.rb +350 -0
- data/lib/rapitapir/auth/schemes.rb +420 -0
- data/lib/rapitapir/auth.rb +113 -0
- data/lib/rapitapir/cli/command.rb +535 -0
- data/lib/rapitapir/cli/server.rb +243 -0
- data/lib/rapitapir/cli/validator.rb +373 -0
- data/lib/rapitapir/client/generator_base.rb +272 -0
- data/lib/rapitapir/client/typescript_generator.rb +350 -0
- data/lib/rapitapir/core/endpoint.rb +158 -0
- data/lib/rapitapir/core/enhanced_endpoint.rb +235 -0
- data/lib/rapitapir/core/input.rb +182 -0
- data/lib/rapitapir/core/output.rb +164 -0
- data/lib/rapitapir/core/request.rb +19 -0
- data/lib/rapitapir/core/response.rb +17 -0
- data/lib/rapitapir/docs/html_generator.rb +780 -0
- data/lib/rapitapir/docs/markdown_generator.rb +464 -0
- data/lib/rapitapir/dsl/endpoint_dsl.rb +116 -0
- data/lib/rapitapir/dsl/enhanced_endpoint_dsl.rb +62 -0
- data/lib/rapitapir/dsl/enhanced_input.rb +73 -0
- data/lib/rapitapir/dsl/enhanced_output.rb +63 -0
- data/lib/rapitapir/dsl/enhanced_structures.rb +393 -0
- data/lib/rapitapir/dsl/fluent_dsl.rb +72 -0
- data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +316 -0
- data/lib/rapitapir/dsl/http_verbs.rb +77 -0
- data/lib/rapitapir/dsl/input_methods.rb +47 -0
- data/lib/rapitapir/dsl/observability_methods.rb +81 -0
- data/lib/rapitapir/dsl/output_methods.rb +43 -0
- data/lib/rapitapir/dsl/type_resolution.rb +43 -0
- data/lib/rapitapir/observability/configuration.rb +108 -0
- data/lib/rapitapir/observability/health_check.rb +236 -0
- data/lib/rapitapir/observability/logging.rb +270 -0
- data/lib/rapitapir/observability/metrics.rb +203 -0
- data/lib/rapitapir/observability/middleware.rb +243 -0
- data/lib/rapitapir/observability/tracing.rb +143 -0
- data/lib/rapitapir/observability.rb +28 -0
- data/lib/rapitapir/openapi/schema_generator.rb +403 -0
- data/lib/rapitapir/schema.rb +136 -0
- data/lib/rapitapir/server/enhanced_rack_adapter.rb +379 -0
- data/lib/rapitapir/server/middleware.rb +120 -0
- data/lib/rapitapir/server/path_matcher.rb +45 -0
- data/lib/rapitapir/server/rack_adapter.rb +215 -0
- data/lib/rapitapir/server/rails_adapter.rb +17 -0
- data/lib/rapitapir/server/rails_adapter_class.rb +53 -0
- data/lib/rapitapir/server/rails_controller.rb +72 -0
- data/lib/rapitapir/server/rails_input_processor.rb +73 -0
- data/lib/rapitapir/server/rails_response_handler.rb +29 -0
- data/lib/rapitapir/server/sinatra_adapter.rb +200 -0
- data/lib/rapitapir/server/sinatra_integration.rb +93 -0
- data/lib/rapitapir/sinatra/configuration.rb +91 -0
- data/lib/rapitapir/sinatra/extension.rb +214 -0
- data/lib/rapitapir/sinatra/oauth2_helpers.rb +236 -0
- data/lib/rapitapir/sinatra/resource_builder.rb +152 -0
- data/lib/rapitapir/sinatra/swagger_ui_generator.rb +166 -0
- data/lib/rapitapir/sinatra_rapitapir.rb +40 -0
- data/lib/rapitapir/types/array.rb +163 -0
- data/lib/rapitapir/types/auto_derivation.rb +265 -0
- data/lib/rapitapir/types/base.rb +146 -0
- data/lib/rapitapir/types/boolean.rb +46 -0
- data/lib/rapitapir/types/date.rb +92 -0
- data/lib/rapitapir/types/datetime.rb +98 -0
- data/lib/rapitapir/types/email.rb +32 -0
- data/lib/rapitapir/types/float.rb +134 -0
- data/lib/rapitapir/types/hash.rb +161 -0
- data/lib/rapitapir/types/integer.rb +143 -0
- data/lib/rapitapir/types/object.rb +156 -0
- data/lib/rapitapir/types/optional.rb +65 -0
- data/lib/rapitapir/types/string.rb +185 -0
- data/lib/rapitapir/types/uuid.rb +32 -0
- data/lib/rapitapir/types.rb +155 -0
- data/lib/rapitapir/version.rb +5 -0
- data/lib/rapitapir.rb +173 -0
- data/rapitapir.gemspec +66 -0
- metadata +387 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module Types
|
7
|
+
# Float type for validating floating-point numbers with range constraints
|
8
|
+
# Supports minimum, maximum, and multiple validation rules
|
9
|
+
class Float < Base
|
10
|
+
protected
|
11
|
+
|
12
|
+
def validate_type(value)
|
13
|
+
return [] if value.is_a?(::Float) || value.is_a?(::Integer)
|
14
|
+
|
15
|
+
["Expected number (float or integer), got #{value.class}"]
|
16
|
+
end
|
17
|
+
|
18
|
+
def validate_constraints(value)
|
19
|
+
errors = []
|
20
|
+
float_value = value.to_f
|
21
|
+
|
22
|
+
errors.concat(validate_range_constraints(float_value))
|
23
|
+
errors.concat(validate_multiple_constraint(float_value))
|
24
|
+
|
25
|
+
errors
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def validate_range_constraints(float_value)
|
31
|
+
errors = []
|
32
|
+
|
33
|
+
errors.concat(validate_minimum_constraints(float_value))
|
34
|
+
errors.concat(validate_maximum_constraints(float_value))
|
35
|
+
|
36
|
+
errors
|
37
|
+
end
|
38
|
+
|
39
|
+
def validate_minimum_constraints(float_value)
|
40
|
+
errors = []
|
41
|
+
|
42
|
+
if constraints[:minimum] && float_value < constraints[:minimum]
|
43
|
+
errors << "Value #{float_value} is below minimum #{constraints[:minimum]}"
|
44
|
+
end
|
45
|
+
|
46
|
+
if constraints[:exclusive_minimum] && float_value <= constraints[:exclusive_minimum]
|
47
|
+
errors << "Value #{float_value} must be greater than #{constraints[:exclusive_minimum]}"
|
48
|
+
end
|
49
|
+
|
50
|
+
errors
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate_maximum_constraints(float_value)
|
54
|
+
errors = []
|
55
|
+
|
56
|
+
if constraints[:maximum] && float_value > constraints[:maximum]
|
57
|
+
errors << "Value #{float_value} exceeds maximum #{constraints[:maximum]}"
|
58
|
+
end
|
59
|
+
|
60
|
+
if constraints[:exclusive_maximum] && float_value >= constraints[:exclusive_maximum]
|
61
|
+
errors << "Value #{float_value} must be less than #{constraints[:exclusive_maximum]}"
|
62
|
+
end
|
63
|
+
|
64
|
+
errors
|
65
|
+
end
|
66
|
+
|
67
|
+
def validate_multiple_constraint(float_value)
|
68
|
+
errors = []
|
69
|
+
|
70
|
+
if constraints[:multiple_of] && (float_value % constraints[:multiple_of]) != 0
|
71
|
+
errors << "Value #{float_value} is not a multiple of #{constraints[:multiple_of]}"
|
72
|
+
end
|
73
|
+
|
74
|
+
errors
|
75
|
+
end
|
76
|
+
|
77
|
+
def coerce_value(value)
|
78
|
+
case value
|
79
|
+
when ::Float then value
|
80
|
+
when ::Integer then value.to_f
|
81
|
+
when ::String then coerce_string_to_float(value)
|
82
|
+
when true, false then coerce_boolean_to_float(value)
|
83
|
+
else
|
84
|
+
coerce_other_to_float(value)
|
85
|
+
end
|
86
|
+
rescue ArgumentError => e
|
87
|
+
raise CoercionError.new(value, 'Float', e.message)
|
88
|
+
end
|
89
|
+
|
90
|
+
def coerce_string_to_float(value)
|
91
|
+
Kernel.Float(value.strip)
|
92
|
+
end
|
93
|
+
|
94
|
+
def coerce_boolean_to_float(value)
|
95
|
+
value ? 1.0 : 0.0
|
96
|
+
end
|
97
|
+
|
98
|
+
def coerce_other_to_float(value)
|
99
|
+
raise CoercionError.new(value, 'Float', 'Value cannot be converted to float') unless value.respond_to?(:to_f)
|
100
|
+
|
101
|
+
value.to_f
|
102
|
+
end
|
103
|
+
|
104
|
+
def json_type
|
105
|
+
'number'
|
106
|
+
end
|
107
|
+
|
108
|
+
def apply_constraints_to_schema(schema)
|
109
|
+
super
|
110
|
+
apply_range_constraints_to_schema(schema)
|
111
|
+
apply_multiple_constraint_to_schema(schema)
|
112
|
+
end
|
113
|
+
|
114
|
+
def apply_range_constraints_to_schema(schema)
|
115
|
+
apply_minimum_constraints_to_schema(schema)
|
116
|
+
apply_maximum_constraints_to_schema(schema)
|
117
|
+
end
|
118
|
+
|
119
|
+
def apply_minimum_constraints_to_schema(schema)
|
120
|
+
schema[:minimum] = constraints[:minimum] if constraints[:minimum]
|
121
|
+
schema[:exclusiveMinimum] = constraints[:exclusive_minimum] if constraints[:exclusive_minimum]
|
122
|
+
end
|
123
|
+
|
124
|
+
def apply_maximum_constraints_to_schema(schema)
|
125
|
+
schema[:maximum] = constraints[:maximum] if constraints[:maximum]
|
126
|
+
schema[:exclusiveMaximum] = constraints[:exclusive_maximum] if constraints[:exclusive_maximum]
|
127
|
+
end
|
128
|
+
|
129
|
+
def apply_multiple_constraint_to_schema(schema)
|
130
|
+
schema[:multipleOf] = constraints[:multiple_of] if constraints[:multiple_of]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module Types
|
7
|
+
# Hash type for validating hash/object structures with schema definitions
|
8
|
+
# Validates hash keys and values according to defined schemas
|
9
|
+
class Hash < Base
|
10
|
+
attr_reader :field_types
|
11
|
+
|
12
|
+
def initialize(field_types = {}, additional_properties: true, **options)
|
13
|
+
@field_types = field_types.freeze
|
14
|
+
super(
|
15
|
+
additional_properties: additional_properties,
|
16
|
+
**options
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
protected
|
21
|
+
|
22
|
+
def validate_type(value)
|
23
|
+
return ["Expected hash/object, got #{value.class}"] unless value.is_a?(::Hash)
|
24
|
+
|
25
|
+
[]
|
26
|
+
end
|
27
|
+
|
28
|
+
def validate_constraints(value)
|
29
|
+
errors = []
|
30
|
+
|
31
|
+
errors.concat(validate_defined_fields(value))
|
32
|
+
errors.concat(validate_additional_properties(value))
|
33
|
+
|
34
|
+
errors
|
35
|
+
end
|
36
|
+
|
37
|
+
def validate_defined_fields(value)
|
38
|
+
errors = []
|
39
|
+
|
40
|
+
field_types.each do |field_name, field_type|
|
41
|
+
field_value = extract_field_value(value, field_name)
|
42
|
+
field_result = field_type.validate(field_value)
|
43
|
+
next if field_result[:valid]
|
44
|
+
|
45
|
+
field_result[:errors].each do |error|
|
46
|
+
errors << "Field '#{field_name}': #{error}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
errors
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate_additional_properties(value)
|
54
|
+
return [] if constraints[:additional_properties]
|
55
|
+
|
56
|
+
expected_keys = field_types.keys.map { |k| [k, k.to_s, k.to_sym] }.flatten.uniq
|
57
|
+
unexpected_keys = value.keys - expected_keys
|
58
|
+
return [] if unexpected_keys.empty?
|
59
|
+
|
60
|
+
["Unexpected fields: #{unexpected_keys.join(', ')}"]
|
61
|
+
end
|
62
|
+
|
63
|
+
def extract_field_value(value, field_name)
|
64
|
+
value[field_name] || value[field_name.to_s] || value[field_name.to_sym]
|
65
|
+
end
|
66
|
+
|
67
|
+
def coerce_value(value)
|
68
|
+
case value
|
69
|
+
when ::Hash
|
70
|
+
coerce_hash_value(value)
|
71
|
+
when ::String
|
72
|
+
coerce_json_string_value(value)
|
73
|
+
else
|
74
|
+
raise CoercionError.new(value, 'Hash', 'Value cannot be converted to hash')
|
75
|
+
end
|
76
|
+
rescue JSON::ParserError => e
|
77
|
+
raise CoercionError.new(value, 'Hash', "Invalid JSON: #{e.message}")
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def coerce_hash_value(value)
|
83
|
+
coerced = {}
|
84
|
+
|
85
|
+
# Coerce defined fields
|
86
|
+
coerce_defined_fields(value, coerced)
|
87
|
+
|
88
|
+
# Include additional properties if allowed
|
89
|
+
coerce_additional_properties(value, coerced) if constraints[:additional_properties]
|
90
|
+
|
91
|
+
coerced
|
92
|
+
end
|
93
|
+
|
94
|
+
def coerce_defined_fields(value, coerced)
|
95
|
+
field_types.each do |field_name, field_type|
|
96
|
+
field_value = find_field_value(value, field_name)
|
97
|
+
coerced[field_name] = field_type.coerce(field_value) if field_value || !field_type.optional?
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def find_field_value(value, field_name)
|
102
|
+
value[field_name] || value[field_name.to_s] || value[field_name.to_sym]
|
103
|
+
end
|
104
|
+
|
105
|
+
def coerce_additional_properties(value, coerced)
|
106
|
+
additional_keys = value.keys - field_types.keys.map { |k| [k, k.to_s, k.to_sym] }.flatten
|
107
|
+
additional_keys.each do |key|
|
108
|
+
coerced[key] = value[key]
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def coerce_json_string_value(value)
|
113
|
+
# Try to parse as JSON
|
114
|
+
require 'json'
|
115
|
+
parsed = JSON.parse(value)
|
116
|
+
raise CoercionError.new(value, 'Hash', 'JSON string did not parse to hash') unless parsed.is_a?(::Hash)
|
117
|
+
|
118
|
+
coerce_value(parsed)
|
119
|
+
end
|
120
|
+
|
121
|
+
def json_type
|
122
|
+
'object'
|
123
|
+
end
|
124
|
+
|
125
|
+
def apply_constraints_to_schema(schema)
|
126
|
+
super
|
127
|
+
apply_field_types_to_schema(schema)
|
128
|
+
apply_additional_properties_constraint(schema)
|
129
|
+
end
|
130
|
+
|
131
|
+
def apply_field_types_to_schema(schema)
|
132
|
+
return unless field_types.any?
|
133
|
+
|
134
|
+
schema[:properties] = build_properties_schema
|
135
|
+
required_fields = collect_required_fields
|
136
|
+
schema[:required] = required_fields unless required_fields.empty?
|
137
|
+
end
|
138
|
+
|
139
|
+
def build_properties_schema
|
140
|
+
field_types.transform_values(&:to_json_schema)
|
141
|
+
end
|
142
|
+
|
143
|
+
def collect_required_fields
|
144
|
+
field_types.reject { |_, field_type| field_type.optional? }.keys
|
145
|
+
end
|
146
|
+
|
147
|
+
def apply_additional_properties_constraint(schema)
|
148
|
+
schema[:additionalProperties] = constraints[:additional_properties]
|
149
|
+
end
|
150
|
+
|
151
|
+
def to_s
|
152
|
+
if field_types.empty?
|
153
|
+
'Hash'
|
154
|
+
else
|
155
|
+
field_strs = field_types.map { |k, v| "#{k}: #{v}" }
|
156
|
+
"Hash{#{field_strs.join(', ')}}"
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module Types
|
7
|
+
# Integer type with numeric constraints
|
8
|
+
#
|
9
|
+
# Validates integer values with optional constraints for minimum/maximum values,
|
10
|
+
# exclusive bounds, and multiple-of validation.
|
11
|
+
#
|
12
|
+
# @example Basic integer
|
13
|
+
# RapiTapir::Types.integer
|
14
|
+
#
|
15
|
+
# @example Integer with constraints
|
16
|
+
# RapiTapir::Types.integer(minimum: 0, maximum: 100, multiple_of: 5)
|
17
|
+
class Integer < Base
|
18
|
+
protected
|
19
|
+
|
20
|
+
def validate_type(value)
|
21
|
+
return ["Expected integer, got #{value.class}"] unless value.is_a?(::Integer)
|
22
|
+
|
23
|
+
[]
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate_constraints(value)
|
27
|
+
errors = []
|
28
|
+
|
29
|
+
errors.concat(validate_range_constraints(value))
|
30
|
+
errors.concat(validate_multiple_constraint(value))
|
31
|
+
|
32
|
+
errors
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def validate_range_constraints(value)
|
38
|
+
errors = []
|
39
|
+
|
40
|
+
errors.concat(validate_minimum_constraints(value))
|
41
|
+
errors.concat(validate_maximum_constraints(value))
|
42
|
+
|
43
|
+
errors
|
44
|
+
end
|
45
|
+
|
46
|
+
def validate_minimum_constraints(value)
|
47
|
+
errors = []
|
48
|
+
|
49
|
+
if constraints[:minimum] && value < constraints[:minimum]
|
50
|
+
errors << "Value #{value} is below minimum #{constraints[:minimum]}"
|
51
|
+
end
|
52
|
+
|
53
|
+
if constraints[:exclusive_minimum] && value <= constraints[:exclusive_minimum]
|
54
|
+
errors << "Value #{value} must be greater than #{constraints[:exclusive_minimum]}"
|
55
|
+
end
|
56
|
+
|
57
|
+
errors
|
58
|
+
end
|
59
|
+
|
60
|
+
def validate_maximum_constraints(value)
|
61
|
+
errors = []
|
62
|
+
|
63
|
+
if constraints[:maximum] && value > constraints[:maximum]
|
64
|
+
errors << "Value #{value} exceeds maximum #{constraints[:maximum]}"
|
65
|
+
end
|
66
|
+
|
67
|
+
if constraints[:exclusive_maximum] && value >= constraints[:exclusive_maximum]
|
68
|
+
errors << "Value #{value} must be less than #{constraints[:exclusive_maximum]}"
|
69
|
+
end
|
70
|
+
|
71
|
+
errors
|
72
|
+
end
|
73
|
+
|
74
|
+
def validate_multiple_constraint(value)
|
75
|
+
errors = []
|
76
|
+
|
77
|
+
if constraints[:multiple_of] && (value % constraints[:multiple_of]) != 0
|
78
|
+
errors << "Value #{value} is not a multiple of #{constraints[:multiple_of]}"
|
79
|
+
end
|
80
|
+
|
81
|
+
errors
|
82
|
+
end
|
83
|
+
|
84
|
+
def coerce_value(value)
|
85
|
+
case value
|
86
|
+
when ::Integer then value
|
87
|
+
when ::Float then value.to_i
|
88
|
+
when ::String then coerce_string_to_integer(value)
|
89
|
+
when true, false then coerce_boolean_to_integer(value)
|
90
|
+
else
|
91
|
+
coerce_other_to_integer(value)
|
92
|
+
end
|
93
|
+
rescue ArgumentError => e
|
94
|
+
raise CoercionError.new(value, 'Integer', e.message)
|
95
|
+
end
|
96
|
+
|
97
|
+
def coerce_string_to_integer(value)
|
98
|
+
Kernel.Integer(value.strip)
|
99
|
+
end
|
100
|
+
|
101
|
+
def coerce_boolean_to_integer(value)
|
102
|
+
value ? 1 : 0
|
103
|
+
end
|
104
|
+
|
105
|
+
def coerce_other_to_integer(value)
|
106
|
+
unless value.respond_to?(:to_i)
|
107
|
+
raise CoercionError.new(value, 'Integer', 'Value cannot be converted to integer')
|
108
|
+
end
|
109
|
+
|
110
|
+
value.to_i
|
111
|
+
end
|
112
|
+
|
113
|
+
def json_type
|
114
|
+
'integer'
|
115
|
+
end
|
116
|
+
|
117
|
+
def apply_constraints_to_schema(schema)
|
118
|
+
super
|
119
|
+
apply_range_constraints_to_schema(schema)
|
120
|
+
apply_multiple_constraint_to_schema(schema)
|
121
|
+
end
|
122
|
+
|
123
|
+
def apply_range_constraints_to_schema(schema)
|
124
|
+
apply_minimum_constraints(schema)
|
125
|
+
apply_maximum_constraints(schema)
|
126
|
+
end
|
127
|
+
|
128
|
+
def apply_minimum_constraints(schema)
|
129
|
+
schema[:minimum] = constraints[:minimum] if constraints[:minimum]
|
130
|
+
schema[:exclusiveMinimum] = constraints[:exclusive_minimum] if constraints[:exclusive_minimum]
|
131
|
+
end
|
132
|
+
|
133
|
+
def apply_maximum_constraints(schema)
|
134
|
+
schema[:maximum] = constraints[:maximum] if constraints[:maximum]
|
135
|
+
schema[:exclusiveMaximum] = constraints[:exclusive_maximum] if constraints[:exclusive_maximum]
|
136
|
+
end
|
137
|
+
|
138
|
+
def apply_multiple_constraint_to_schema(schema)
|
139
|
+
schema[:multipleOf] = constraints[:multiple_of] if constraints[:multiple_of]
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module Types
|
7
|
+
# Object type for validating complex object structures with typed properties
|
8
|
+
# Defines objects with named properties and their respective types
|
9
|
+
class Object < Base
|
10
|
+
attr_reader :fields
|
11
|
+
|
12
|
+
def initialize(**options, &block)
|
13
|
+
@fields = {}
|
14
|
+
super(**options)
|
15
|
+
|
16
|
+
return unless block_given?
|
17
|
+
|
18
|
+
builder = ObjectBuilder.new(self)
|
19
|
+
builder.instance_eval(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def field(name, type, required: true, **options)
|
23
|
+
field_type = required ? type : ::RapiTapir::Types::Optional.new(type)
|
24
|
+
field_type = field_type.with_metadata(**options) if options.any?
|
25
|
+
@fields[name.to_sym] = field_type
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def required_field(name, type, **options)
|
30
|
+
field(name, type, required: true, **options)
|
31
|
+
end
|
32
|
+
|
33
|
+
def optional_field(name, type, **options)
|
34
|
+
field(name, type, required: false, **options)
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
def validate_type(value)
|
40
|
+
return ["Expected hash/object, got #{value.class}"] unless value.is_a?(::Hash)
|
41
|
+
|
42
|
+
[]
|
43
|
+
end
|
44
|
+
|
45
|
+
def validate_constraints(value)
|
46
|
+
errors = []
|
47
|
+
|
48
|
+
# Validate each defined field
|
49
|
+
fields.each do |field_name, field_type|
|
50
|
+
field_value = extract_field_value(value, field_name)
|
51
|
+
|
52
|
+
field_result = field_type.validate(field_value)
|
53
|
+
next if field_result[:valid]
|
54
|
+
|
55
|
+
field_result[:errors].each do |error|
|
56
|
+
errors << "Field '#{field_name}': #{error}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
errors
|
61
|
+
end
|
62
|
+
|
63
|
+
def coerce_value(value)
|
64
|
+
case value
|
65
|
+
when ::Hash
|
66
|
+
coerce_hash_to_object(value)
|
67
|
+
when ::String
|
68
|
+
coerce_json_string_to_object(value)
|
69
|
+
else
|
70
|
+
raise CoercionError.new(value, 'Object', 'Value cannot be converted to object')
|
71
|
+
end
|
72
|
+
rescue JSON::ParserError => e
|
73
|
+
raise CoercionError.new(value, 'Object', "Invalid JSON: #{e.message}")
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def coerce_hash_to_object(value)
|
79
|
+
coerced = {}
|
80
|
+
|
81
|
+
# Coerce each defined field
|
82
|
+
fields.each do |field_name, field_type|
|
83
|
+
field_value = extract_field_value(value, field_name)
|
84
|
+
coerced[field_name] = field_type.coerce(field_value) if field_value || !field_type.optional?
|
85
|
+
end
|
86
|
+
|
87
|
+
coerced
|
88
|
+
end
|
89
|
+
|
90
|
+
def coerce_json_string_to_object(value)
|
91
|
+
# Try to parse as JSON
|
92
|
+
require 'json'
|
93
|
+
parsed = JSON.parse(value)
|
94
|
+
raise CoercionError.new(value, 'Object', 'JSON string did not parse to object') unless parsed.is_a?(::Hash)
|
95
|
+
|
96
|
+
coerce_value(parsed)
|
97
|
+
end
|
98
|
+
|
99
|
+
def json_type
|
100
|
+
'object'
|
101
|
+
end
|
102
|
+
|
103
|
+
def apply_constraints_to_schema(schema)
|
104
|
+
super
|
105
|
+
|
106
|
+
if fields.any?
|
107
|
+
schema[:properties] = {}
|
108
|
+
required_fields = []
|
109
|
+
|
110
|
+
fields.each do |field_name, field_type|
|
111
|
+
schema[:properties][field_name] = field_type.to_json_schema
|
112
|
+
required_fields << field_name unless field_type.optional?
|
113
|
+
end
|
114
|
+
|
115
|
+
schema[:required] = required_fields unless required_fields.empty?
|
116
|
+
end
|
117
|
+
|
118
|
+
schema[:additionalProperties] = false # Objects are strict by default
|
119
|
+
end
|
120
|
+
|
121
|
+
def to_s
|
122
|
+
if fields.empty?
|
123
|
+
'Object'
|
124
|
+
else
|
125
|
+
field_strs = fields.map { |name, type| "#{name}: #{type}" }
|
126
|
+
"Object{#{field_strs.join(', ')}}"
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def extract_field_value(hash, field_name)
|
131
|
+
# Try different key formats: symbol, string, and string version of symbol
|
132
|
+
hash[field_name] || hash[field_name.to_s] || hash[field_name.to_sym]
|
133
|
+
end
|
134
|
+
|
135
|
+
# Builder for constructing Object type definitions
|
136
|
+
# Provides a fluent interface for defining object properties
|
137
|
+
class ObjectBuilder
|
138
|
+
def initialize(object_type)
|
139
|
+
@object_type = object_type
|
140
|
+
end
|
141
|
+
|
142
|
+
def field(name, type, required: true, **options)
|
143
|
+
@object_type.field(name, type, required: required, **options)
|
144
|
+
end
|
145
|
+
|
146
|
+
def required_field(name, type, **options)
|
147
|
+
@object_type.required_field(name, type, **options)
|
148
|
+
end
|
149
|
+
|
150
|
+
def optional_field(name, type, **options)
|
151
|
+
@object_type.optional_field(name, type, **options)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module RapiTapir
|
6
|
+
module Types
|
7
|
+
# Optional type wrapper for nullable values
|
8
|
+
# Wraps other types to allow nil values in addition to the wrapped type
|
9
|
+
class Optional < Base
|
10
|
+
attr_reader :wrapped_type
|
11
|
+
|
12
|
+
def initialize(wrapped_type)
|
13
|
+
@wrapped_type = wrapped_type
|
14
|
+
super(optional: true)
|
15
|
+
end
|
16
|
+
|
17
|
+
def validate(value)
|
18
|
+
# Optional types allow nil
|
19
|
+
return validation_result(true, []) if value.nil?
|
20
|
+
|
21
|
+
# Delegate to wrapped type
|
22
|
+
wrapped_type.validate(value)
|
23
|
+
end
|
24
|
+
|
25
|
+
def coerce(value)
|
26
|
+
return nil if value.nil?
|
27
|
+
|
28
|
+
wrapped_type.coerce(value)
|
29
|
+
end
|
30
|
+
|
31
|
+
def required?
|
32
|
+
false
|
33
|
+
end
|
34
|
+
|
35
|
+
def optional?
|
36
|
+
true
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_json_schema
|
40
|
+
wrapped_type.to_json_schema
|
41
|
+
# Optional types are handled by not including them in the required array
|
42
|
+
# at the parent level, so we don't need to modify the schema here
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_s
|
46
|
+
"Optional[#{wrapped_type}]"
|
47
|
+
end
|
48
|
+
|
49
|
+
def with_metadata(**meta)
|
50
|
+
# Delegate metadata to wrapped type but maintain Optional wrapper
|
51
|
+
Optional.new(wrapped_type.with_metadata(**meta))
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def validation_result(valid, errors)
|
57
|
+
{
|
58
|
+
valid: valid,
|
59
|
+
errors: errors,
|
60
|
+
value_errors: errors.empty? ? [] : [ValidationError.new(nil, self, errors)]
|
61
|
+
}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|