rapitapir 0.1.1 → 2.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 +4 -4
- data/.rubocop.yml +7 -7
- data/.rubocop_todo.yml +83 -0
- data/README.md +1319 -235
- data/RUBY_WEEKLY_LAUNCH_POST.md +219 -0
- data/docs/RAILS_INTEGRATION_IMPLEMENTATION.md +209 -0
- data/docs/SINATRA_EXTENSION.md +399 -348
- data/docs/STRICT_VALIDATION.md +229 -0
- data/docs/VALIDATION_IMPROVEMENTS.md +218 -0
- data/docs/ai-integration-plan.md +112 -0
- data/docs/auto-derivation.md +505 -92
- data/docs/endpoint-definition.md +536 -129
- data/docs/n8n-integration.md +212 -0
- data/docs/observability.md +810 -500
- data/docs/using-mcp.md +93 -0
- data/examples/ai/knowledge_base_rag.rb +83 -0
- data/examples/ai/user_management_mcp.rb +92 -0
- data/examples/ai/user_validation_llm.rb +187 -0
- data/examples/rails/RAILS_8_GUIDE.md +165 -0
- data/examples/rails/RAILS_LOADING_FIX.rb +35 -0
- data/examples/rails/README.md +497 -0
- data/examples/rails/comprehensive_test.rb +91 -0
- data/examples/rails/config/routes.rb +48 -0
- data/examples/rails/debug_controller.rb +63 -0
- data/examples/rails/detailed_test.rb +46 -0
- data/examples/rails/enhanced_users_controller.rb +278 -0
- data/examples/rails/final_server_test.rb +50 -0
- data/examples/rails/hello_world_app.rb +116 -0
- data/examples/rails/hello_world_controller.rb +186 -0
- data/examples/rails/hello_world_routes.rb +28 -0
- data/examples/rails/rails8_minimal_demo.rb +132 -0
- data/examples/rails/rails8_simple_demo.rb +140 -0
- data/examples/rails/rails8_working_demo.rb +255 -0
- data/examples/rails/real_world_blog_api.rb +510 -0
- data/examples/rails/server_test.rb +46 -0
- data/examples/rails/test_direct_processing.rb +41 -0
- data/examples/rails/test_hello_world.rb +80 -0
- data/examples/rails/test_rails_integration.rb +54 -0
- data/examples/rails/traditional_app/Gemfile +37 -0
- data/examples/rails/traditional_app/README.md +265 -0
- data/examples/rails/traditional_app/app/controllers/api/v1/posts_controller.rb +254 -0
- data/examples/rails/traditional_app/app/controllers/api/v1/users_controller.rb +220 -0
- data/examples/rails/traditional_app/app/controllers/application_controller.rb +86 -0
- data/examples/rails/traditional_app/app/controllers/application_controller_simplified.rb +87 -0
- data/examples/rails/traditional_app/app/controllers/documentation_controller.rb +149 -0
- data/examples/rails/traditional_app/app/controllers/health_controller.rb +42 -0
- data/examples/rails/traditional_app/config/routes.rb +25 -0
- data/examples/rails/traditional_app/config/routes_best_practice.rb +25 -0
- data/examples/rails/traditional_app/config/routes_simplified.rb +36 -0
- data/examples/rails/traditional_app_runnable.rb +406 -0
- data/examples/rails/users_controller.rb +4 -1
- data/examples/serverless/Gemfile +43 -0
- data/examples/serverless/QUICKSTART.md +331 -0
- data/examples/serverless/README.md +520 -0
- data/examples/serverless/aws_lambda_example.rb +307 -0
- data/examples/serverless/aws_sam_template.yaml +215 -0
- data/examples/serverless/azure_functions_example.rb +407 -0
- data/examples/serverless/deploy.rb +204 -0
- data/examples/serverless/gcp_cloud_functions_example.rb +367 -0
- data/examples/serverless/gcp_function.yaml +23 -0
- data/examples/serverless/host.json +24 -0
- data/examples/serverless/package.json +32 -0
- data/examples/serverless/spec/aws_lambda_spec.rb +196 -0
- data/examples/serverless/spec/spec_helper.rb +89 -0
- data/examples/serverless/vercel.json +31 -0
- data/examples/serverless/vercel_example.rb +404 -0
- data/examples/strict_validation_examples.rb +104 -0
- data/examples/validation_error_examples.rb +173 -0
- data/lib/rapitapir/ai/llm_instruction.rb +456 -0
- data/lib/rapitapir/ai/mcp.rb +134 -0
- data/lib/rapitapir/ai/rag.rb +287 -0
- data/lib/rapitapir/ai/rag_middleware.rb +147 -0
- data/lib/rapitapir/auth/oauth2.rb +43 -57
- data/lib/rapitapir/cli/command.rb +362 -2
- data/lib/rapitapir/cli/mcp_export.rb +18 -0
- data/lib/rapitapir/cli/validator.rb +2 -6
- data/lib/rapitapir/core/endpoint.rb +59 -6
- data/lib/rapitapir/core/enhanced_endpoint.rb +2 -6
- data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +53 -0
- data/lib/rapitapir/endpoint_registry.rb +47 -0
- data/lib/rapitapir/observability/health_check.rb +4 -4
- data/lib/rapitapir/observability/logging.rb +10 -10
- data/lib/rapitapir/schema.rb +2 -2
- data/lib/rapitapir/server/rack_adapter.rb +1 -3
- data/lib/rapitapir/server/rails/configuration.rb +77 -0
- data/lib/rapitapir/server/rails/controller_base.rb +185 -0
- data/lib/rapitapir/server/rails/documentation_helpers.rb +76 -0
- data/lib/rapitapir/server/rails/resource_builder.rb +181 -0
- data/lib/rapitapir/server/rails/routes.rb +114 -0
- data/lib/rapitapir/server/rails_adapter.rb +10 -3
- data/lib/rapitapir/server/rails_adapter_class.rb +1 -3
- data/lib/rapitapir/server/rails_controller.rb +1 -3
- data/lib/rapitapir/server/rails_integration.rb +67 -0
- data/lib/rapitapir/server/rails_response_handler.rb +16 -3
- data/lib/rapitapir/server/sinatra_adapter.rb +29 -5
- data/lib/rapitapir/server/sinatra_integration.rb +4 -4
- data/lib/rapitapir/sinatra/extension.rb +2 -2
- data/lib/rapitapir/sinatra/oauth2_helpers.rb +34 -40
- data/lib/rapitapir/types/array.rb +4 -0
- data/lib/rapitapir/types/auto_derivation.rb +4 -18
- data/lib/rapitapir/types/datetime.rb +1 -3
- data/lib/rapitapir/types/float.rb +2 -6
- data/lib/rapitapir/types/hash.rb +40 -2
- data/lib/rapitapir/types/integer.rb +4 -12
- data/lib/rapitapir/types/object.rb +6 -2
- data/lib/rapitapir/types.rb +6 -2
- data/lib/rapitapir/version.rb +1 -1
- data/lib/rapitapir.rb +5 -3
- data/rapitapir.gemspec +7 -5
- metadata +116 -16
@@ -10,37 +10,37 @@ module RapiTapir
|
|
10
10
|
# Configure Auth0 OAuth2 authentication
|
11
11
|
def auth0_oauth2(scheme_name = :oauth2_auth0, domain:, audience:, **options)
|
12
12
|
auth_scheme = RapiTapir::Auth::OAuth2::Auth0Scheme.new(scheme_name, {
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
13
|
+
domain: domain,
|
14
|
+
audience: audience,
|
15
|
+
**options
|
16
|
+
})
|
17
|
+
|
18
18
|
# Store the auth scheme for use in endpoint protection
|
19
19
|
settings.rapitapir_config.add_auth_scheme(scheme_name, auth_scheme)
|
20
|
-
|
20
|
+
|
21
21
|
# Add authentication helper methods
|
22
22
|
helpers do
|
23
23
|
include OAuth2HelperMethods
|
24
24
|
end
|
25
|
-
|
25
|
+
|
26
26
|
auth_scheme
|
27
27
|
end
|
28
28
|
|
29
29
|
# Configure generic OAuth2 authentication with token introspection
|
30
30
|
def oauth2_introspection(scheme_name = :oauth2, introspection_endpoint:, client_id:, client_secret:, **options)
|
31
31
|
auth_scheme = RapiTapir::Auth::OAuth2::GenericScheme.new(scheme_name, {
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
32
|
+
introspection_endpoint: introspection_endpoint,
|
33
|
+
client_id: client_id,
|
34
|
+
client_secret: client_secret,
|
35
|
+
**options
|
36
|
+
})
|
37
|
+
|
38
38
|
settings.rapitapir_config.add_auth_scheme(scheme_name, auth_scheme)
|
39
|
-
|
39
|
+
|
40
40
|
helpers do
|
41
41
|
include OAuth2HelperMethods
|
42
42
|
end
|
43
|
-
|
43
|
+
|
44
44
|
auth_scheme
|
45
45
|
end
|
46
46
|
|
@@ -58,7 +58,7 @@ module RapiTapir
|
|
58
58
|
before do
|
59
59
|
# Skip protection for excluded paths
|
60
60
|
next if except.any? { |pattern| request.path_info.match?(pattern) }
|
61
|
-
|
61
|
+
|
62
62
|
authorize_oauth2!(required_scopes: scopes, scheme: scheme)
|
63
63
|
end
|
64
64
|
end
|
@@ -69,20 +69,18 @@ module RapiTapir
|
|
69
69
|
# Authenticate and authorize with OAuth2
|
70
70
|
def authorize_oauth2!(required_scopes: [], scheme: :oauth2_auth0)
|
71
71
|
auth_scheme = settings.rapitapir_config.auth_schemes[scheme]
|
72
|
-
|
73
|
-
unless auth_scheme
|
74
|
-
halt 500, { error: 'OAuth2 authentication not configured' }.to_json
|
75
|
-
end
|
72
|
+
|
73
|
+
halt 500, { error: 'OAuth2 authentication not configured' }.to_json unless auth_scheme
|
76
74
|
|
77
75
|
begin
|
78
76
|
context = auth_scheme.authenticate(request)
|
79
|
-
|
77
|
+
|
80
78
|
unless context
|
81
79
|
challenge = auth_scheme.challenge
|
82
80
|
headers 'WWW-Authenticate' => challenge
|
83
|
-
halt 401, {
|
81
|
+
halt 401, {
|
84
82
|
error: 'unauthorized',
|
85
|
-
error_description: 'Access token required'
|
83
|
+
error_description: 'Access token required'
|
86
84
|
}.to_json
|
87
85
|
end
|
88
86
|
|
@@ -92,7 +90,7 @@ module RapiTapir
|
|
92
90
|
# Check required scopes if specified
|
93
91
|
if required_scopes.any?
|
94
92
|
missing_scopes = required_scopes - context.scopes
|
95
|
-
|
93
|
+
|
96
94
|
if missing_scopes.any?
|
97
95
|
halt 403, {
|
98
96
|
error: 'insufficient_scope',
|
@@ -135,34 +133,34 @@ module RapiTapir
|
|
135
133
|
# Check if user has specific scope
|
136
134
|
def has_scope?(scope)
|
137
135
|
return false unless current_auth_context
|
138
|
-
|
136
|
+
|
139
137
|
current_auth_context.scopes.include?(scope.to_s)
|
140
138
|
end
|
141
139
|
|
142
140
|
# Check if user has all required scopes
|
143
141
|
def has_scopes?(*scopes)
|
144
142
|
return false unless current_auth_context
|
145
|
-
|
143
|
+
|
146
144
|
scopes.all? { |scope| has_scope?(scope) }
|
147
145
|
end
|
148
146
|
|
149
147
|
# Check if user has any of the specified scopes
|
150
148
|
def has_any_scope?(*scopes)
|
151
149
|
return false unless current_auth_context
|
152
|
-
|
150
|
+
|
153
151
|
scopes.any? { |scope| has_scope?(scope) }
|
154
152
|
end
|
155
153
|
|
156
154
|
# Require specific scopes for the current request
|
157
155
|
def require_scopes!(*scopes)
|
158
156
|
missing_scopes = scopes.reject { |scope| has_scope?(scope) }
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
}
|
165
|
-
|
157
|
+
|
158
|
+
return unless missing_scopes.any?
|
159
|
+
|
160
|
+
halt 403, {
|
161
|
+
error: 'insufficient_scope',
|
162
|
+
error_description: "Missing required scopes: #{missing_scopes.join(', ')}"
|
163
|
+
}.to_json
|
166
164
|
end
|
167
165
|
|
168
166
|
# Extract token from Authorization header
|
@@ -226,11 +224,7 @@ module RapiTapir
|
|
226
224
|
end
|
227
225
|
|
228
226
|
# Extend the Sinatra extension with OAuth2 helpers if it exists
|
229
|
-
if defined?(RapiTapir::Sinatra::Extension)
|
230
|
-
RapiTapir::Sinatra::Extension::ClassMethods.include(RapiTapir::Sinatra::OAuth2Helpers)
|
231
|
-
end
|
227
|
+
RapiTapir::Sinatra::Extension::ClassMethods.include(RapiTapir::Sinatra::OAuth2Helpers) if defined?(RapiTapir::Sinatra::Extension)
|
232
228
|
|
233
229
|
# Extend endpoint building with OAuth2 methods
|
234
|
-
if defined?(RapiTapir::DSL::FluentEndpointBuilder)
|
235
|
-
RapiTapir::DSL::FluentEndpointBuilder.include(RapiTapir::Sinatra::OAuth2EndpointExtensions)
|
236
|
-
end
|
230
|
+
RapiTapir::DSL::FluentEndpointBuilder.include(RapiTapir::Sinatra::OAuth2EndpointExtensions) if defined?(RapiTapir::DSL::FluentEndpointBuilder)
|
@@ -136,12 +136,16 @@ module RapiTapir
|
|
136
136
|
schema[:uniqueItems] = constraints[:unique_items] if constraints[:unique_items]
|
137
137
|
end
|
138
138
|
|
139
|
+
public
|
140
|
+
|
139
141
|
def to_s
|
140
142
|
item_type_str = format_item_type_string
|
141
143
|
constraint_part = format_constraints_string
|
142
144
|
"Array[#{item_type_str}]#{constraint_part}"
|
143
145
|
end
|
144
146
|
|
147
|
+
private
|
148
|
+
|
145
149
|
def format_item_type_string
|
146
150
|
item_type.respond_to?(:to_s) ? item_type.to_s : item_type.class.name
|
147
151
|
end
|
@@ -22,7 +22,6 @@ module RapiTapir
|
|
22
22
|
# @param only [Array<Symbol>] Only include these fields
|
23
23
|
# @param except [Array<Symbol>] Exclude these fields
|
24
24
|
# @return [RapiTapir::Types::Hash]
|
25
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
26
25
|
def from_hash(hash, only: nil, except: nil)
|
27
26
|
raise ArgumentError, "Expected Hash, got #{hash.class}" unless hash.is_a?(::Hash)
|
28
27
|
|
@@ -39,7 +38,6 @@ module RapiTapir
|
|
39
38
|
|
40
39
|
create_hash_type(schema_hash)
|
41
40
|
end
|
42
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
43
41
|
|
44
42
|
# Derive schema from JSON Schema object
|
45
43
|
#
|
@@ -47,11 +45,8 @@ module RapiTapir
|
|
47
45
|
# @param only [Array<Symbol>] Only include these fields
|
48
46
|
# @param except [Array<Symbol>] Exclude these fields
|
49
47
|
# @return [RapiTapir::Types::Hash]
|
50
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
51
48
|
def from_json_schema(json_schema, only: nil, except: nil)
|
52
|
-
unless json_schema.is_a?(::Hash) && json_schema['type'] == 'object'
|
53
|
-
raise ArgumentError, 'JSON Schema must be an object type'
|
54
|
-
end
|
49
|
+
raise ArgumentError, 'JSON Schema must be an object type' unless json_schema.is_a?(::Hash) && json_schema['type'] == 'object'
|
55
50
|
|
56
51
|
properties = json_schema['properties'] || {}
|
57
52
|
required_fields = Array(json_schema['required'])
|
@@ -69,7 +64,6 @@ module RapiTapir
|
|
69
64
|
|
70
65
|
RapiTapir::Types.hash(schema_hash)
|
71
66
|
end
|
72
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
73
67
|
|
74
68
|
# Derive schema from OpenStruct instance
|
75
69
|
#
|
@@ -77,7 +71,7 @@ module RapiTapir
|
|
77
71
|
# @param only [Array<Symbol>] Only include these fields
|
78
72
|
# @param except [Array<Symbol>] Exclude these fields
|
79
73
|
# @return [RapiTapir::Types::Hash]
|
80
|
-
# rubocop:disable
|
74
|
+
# rubocop:disable Style/OpenStructUse
|
81
75
|
def from_open_struct(open_struct, only: nil, except: nil)
|
82
76
|
raise ArgumentError, "Expected OpenStruct, got #{open_struct.class}" unless open_struct.is_a?(OpenStruct)
|
83
77
|
|
@@ -93,7 +87,7 @@ module RapiTapir
|
|
93
87
|
|
94
88
|
create_hash_type(schema_hash)
|
95
89
|
end
|
96
|
-
# rubocop:enable
|
90
|
+
# rubocop:enable Style/OpenStructUse
|
97
91
|
|
98
92
|
# Derive schema from Protobuf message class
|
99
93
|
#
|
@@ -101,11 +95,8 @@ module RapiTapir
|
|
101
95
|
# @param only [Array<Symbol>] Only include these fields
|
102
96
|
# @param except [Array<Symbol>] Exclude these fields
|
103
97
|
# @return [RapiTapir::Types::Hash]
|
104
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
105
98
|
def from_protobuf(proto_class, only: nil, except: nil)
|
106
|
-
unless defined?(Google::Protobuf) && proto_class.respond_to?(:descriptor)
|
107
|
-
raise ArgumentError, 'Protobuf not available or invalid protobuf class'
|
108
|
-
end
|
99
|
+
raise ArgumentError, 'Protobuf not available or invalid protobuf class' unless defined?(Google::Protobuf) && proto_class.respond_to?(:descriptor)
|
109
100
|
|
110
101
|
schema_hash = {}
|
111
102
|
proto_class.descriptor.each do |field_descriptor|
|
@@ -121,12 +112,10 @@ module RapiTapir
|
|
121
112
|
|
122
113
|
create_hash_type(schema_hash)
|
123
114
|
end
|
124
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
125
115
|
|
126
116
|
private
|
127
117
|
|
128
118
|
# Convert JSON Schema field type to RapiTapir type
|
129
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
130
119
|
def convert_json_schema_type(field_schema, required: true)
|
131
120
|
base_type = case field_schema['type']
|
132
121
|
when 'string'
|
@@ -163,7 +152,6 @@ module RapiTapir
|
|
163
152
|
|
164
153
|
required ? base_type : create_optional_type(base_type)
|
165
154
|
end
|
166
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
167
155
|
|
168
156
|
# Convert protobuf field descriptor to RapiTapir type
|
169
157
|
def convert_protobuf_type(field_descriptor)
|
@@ -189,7 +177,6 @@ module RapiTapir
|
|
189
177
|
end
|
190
178
|
|
191
179
|
# Infer RapiTapir type from Ruby value
|
192
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
193
180
|
def infer_type_from_value(value)
|
194
181
|
case value
|
195
182
|
when ::Integer
|
@@ -212,7 +199,6 @@ module RapiTapir
|
|
212
199
|
create_string_type
|
213
200
|
end
|
214
201
|
end
|
215
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
216
202
|
|
217
203
|
# Helper methods to create types without using convenience methods
|
218
204
|
def create_string_type
|
@@ -42,9 +42,7 @@ module RapiTapir
|
|
42
42
|
# Assume Unix timestamp
|
43
43
|
::Time.at(value).to_datetime
|
44
44
|
else
|
45
|
-
unless value.respond_to?(:to_datetime)
|
46
|
-
raise CoercionError.new(value, 'DateTime', 'Value cannot be converted to DateTime')
|
47
|
-
end
|
45
|
+
raise CoercionError.new(value, 'DateTime', 'Value cannot be converted to DateTime') unless value.respond_to?(:to_datetime)
|
48
46
|
|
49
47
|
value.to_datetime
|
50
48
|
|
@@ -39,9 +39,7 @@ module RapiTapir
|
|
39
39
|
def validate_minimum_constraints(float_value)
|
40
40
|
errors = []
|
41
41
|
|
42
|
-
if constraints[:minimum] && float_value < constraints[:minimum]
|
43
|
-
errors << "Value #{float_value} is below minimum #{constraints[:minimum]}"
|
44
|
-
end
|
42
|
+
errors << "Value #{float_value} is below minimum #{constraints[:minimum]}" if constraints[:minimum] && float_value < constraints[:minimum]
|
45
43
|
|
46
44
|
if constraints[:exclusive_minimum] && float_value <= constraints[:exclusive_minimum]
|
47
45
|
errors << "Value #{float_value} must be greater than #{constraints[:exclusive_minimum]}"
|
@@ -53,9 +51,7 @@ module RapiTapir
|
|
53
51
|
def validate_maximum_constraints(float_value)
|
54
52
|
errors = []
|
55
53
|
|
56
|
-
if constraints[:maximum] && float_value > constraints[:maximum]
|
57
|
-
errors << "Value #{float_value} exceeds maximum #{constraints[:maximum]}"
|
58
|
-
end
|
54
|
+
errors << "Value #{float_value} exceeds maximum #{constraints[:maximum]}" if constraints[:maximum] && float_value > constraints[:maximum]
|
59
55
|
|
60
56
|
if constraints[:exclusive_maximum] && float_value >= constraints[:exclusive_maximum]
|
61
57
|
errors << "Value #{float_value} must be less than #{constraints[:exclusive_maximum]}"
|
data/lib/rapitapir/types/hash.rb
CHANGED
@@ -9,7 +9,7 @@ module RapiTapir
|
|
9
9
|
class Hash < Base
|
10
10
|
attr_reader :field_types
|
11
11
|
|
12
|
-
def initialize(field_types = {}, additional_properties:
|
12
|
+
def initialize(field_types = {}, additional_properties: false, **options)
|
13
13
|
@field_types = field_types.freeze
|
14
14
|
super(
|
15
15
|
additional_properties: additional_properties,
|
@@ -39,6 +39,16 @@ module RapiTapir
|
|
39
39
|
|
40
40
|
field_types.each do |field_name, field_type|
|
41
41
|
field_value = extract_field_value(value, field_name)
|
42
|
+
|
43
|
+
# Check for missing required fields
|
44
|
+
if field_value.nil? && !field_type.optional?
|
45
|
+
errors << "Required field '#{field_name}' is missing"
|
46
|
+
next
|
47
|
+
end
|
48
|
+
|
49
|
+
# Skip validation for optional missing fields
|
50
|
+
next if field_value.nil? && field_type.optional?
|
51
|
+
|
42
52
|
field_result = field_type.validate(field_value)
|
43
53
|
next if field_result[:valid]
|
44
54
|
|
@@ -82,6 +92,9 @@ module RapiTapir
|
|
82
92
|
def coerce_hash_value(value)
|
83
93
|
coerced = {}
|
84
94
|
|
95
|
+
# Check for unexpected fields if additional properties are not allowed
|
96
|
+
validate_no_unexpected_fields(value) unless constraints[:additional_properties]
|
97
|
+
|
85
98
|
# Coerce defined fields
|
86
99
|
coerce_defined_fields(value, coerced)
|
87
100
|
|
@@ -91,10 +104,33 @@ module RapiTapir
|
|
91
104
|
coerced
|
92
105
|
end
|
93
106
|
|
107
|
+
def validate_no_unexpected_fields(value)
|
108
|
+
expected_keys = field_types.keys.map { |k| [k, k.to_s, k.to_sym] }.flatten.uniq
|
109
|
+
unexpected_keys = value.keys - expected_keys
|
110
|
+
return if unexpected_keys.empty?
|
111
|
+
|
112
|
+
unexpected_list = unexpected_keys.map(&:inspect).join(', ')
|
113
|
+
raise CoercionError.new(value, 'Hash', "Unexpected fields in hash: #{unexpected_list}. Only these fields are allowed: #{field_types.keys.join(', ')}")
|
114
|
+
end
|
115
|
+
|
94
116
|
def coerce_defined_fields(value, coerced)
|
95
117
|
field_types.each do |field_name, field_type|
|
96
118
|
field_value = find_field_value(value, field_name)
|
97
|
-
|
119
|
+
|
120
|
+
# Check for missing required fields
|
121
|
+
if field_value.nil? && !field_type.optional?
|
122
|
+
raise CoercionError.new(nil, field_type.class.name, "Required field '#{field_name}' is missing from hash")
|
123
|
+
end
|
124
|
+
|
125
|
+
# Only coerce if we have a value or field is optional
|
126
|
+
next unless field_value || !field_type.optional?
|
127
|
+
|
128
|
+
begin
|
129
|
+
coerced[field_name] = field_type.coerce(field_value)
|
130
|
+
rescue CoercionError => e
|
131
|
+
# Re-raise with field context
|
132
|
+
raise CoercionError.new(e.value, e.type, "Field '#{field_name}': #{e.reason}")
|
133
|
+
end
|
98
134
|
end
|
99
135
|
end
|
100
136
|
|
@@ -148,6 +184,8 @@ module RapiTapir
|
|
148
184
|
schema[:additionalProperties] = constraints[:additional_properties]
|
149
185
|
end
|
150
186
|
|
187
|
+
public
|
188
|
+
|
151
189
|
def to_s
|
152
190
|
if field_types.empty?
|
153
191
|
'Hash'
|
@@ -46,9 +46,7 @@ module RapiTapir
|
|
46
46
|
def validate_minimum_constraints(value)
|
47
47
|
errors = []
|
48
48
|
|
49
|
-
if constraints[:minimum] && value < constraints[:minimum]
|
50
|
-
errors << "Value #{value} is below minimum #{constraints[:minimum]}"
|
51
|
-
end
|
49
|
+
errors << "Value #{value} is below minimum #{constraints[:minimum]}" if constraints[:minimum] && value < constraints[:minimum]
|
52
50
|
|
53
51
|
if constraints[:exclusive_minimum] && value <= constraints[:exclusive_minimum]
|
54
52
|
errors << "Value #{value} must be greater than #{constraints[:exclusive_minimum]}"
|
@@ -60,9 +58,7 @@ module RapiTapir
|
|
60
58
|
def validate_maximum_constraints(value)
|
61
59
|
errors = []
|
62
60
|
|
63
|
-
if constraints[:maximum] && value > constraints[:maximum]
|
64
|
-
errors << "Value #{value} exceeds maximum #{constraints[:maximum]}"
|
65
|
-
end
|
61
|
+
errors << "Value #{value} exceeds maximum #{constraints[:maximum]}" if constraints[:maximum] && value > constraints[:maximum]
|
66
62
|
|
67
63
|
if constraints[:exclusive_maximum] && value >= constraints[:exclusive_maximum]
|
68
64
|
errors << "Value #{value} must be less than #{constraints[:exclusive_maximum]}"
|
@@ -74,9 +70,7 @@ module RapiTapir
|
|
74
70
|
def validate_multiple_constraint(value)
|
75
71
|
errors = []
|
76
72
|
|
77
|
-
if constraints[:multiple_of] && (value % constraints[:multiple_of]) != 0
|
78
|
-
errors << "Value #{value} is not a multiple of #{constraints[:multiple_of]}"
|
79
|
-
end
|
73
|
+
errors << "Value #{value} is not a multiple of #{constraints[:multiple_of]}" if constraints[:multiple_of] && (value % constraints[:multiple_of]) != 0
|
80
74
|
|
81
75
|
errors
|
82
76
|
end
|
@@ -103,9 +97,7 @@ module RapiTapir
|
|
103
97
|
end
|
104
98
|
|
105
99
|
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
|
100
|
+
raise CoercionError.new(value, 'Integer', 'Value cannot be converted to integer') unless value.respond_to?(:to_i)
|
109
101
|
|
110
102
|
value.to_i
|
111
103
|
end
|
@@ -9,14 +9,14 @@ module RapiTapir
|
|
9
9
|
class Object < Base
|
10
10
|
attr_reader :fields
|
11
11
|
|
12
|
-
def initialize(**options, &
|
12
|
+
def initialize(**options, &)
|
13
13
|
@fields = {}
|
14
14
|
super(**options)
|
15
15
|
|
16
16
|
return unless block_given?
|
17
17
|
|
18
18
|
builder = ObjectBuilder.new(self)
|
19
|
-
builder.instance_eval(&
|
19
|
+
builder.instance_eval(&)
|
20
20
|
end
|
21
21
|
|
22
22
|
def field(name, type, required: true, **options)
|
@@ -118,6 +118,8 @@ module RapiTapir
|
|
118
118
|
schema[:additionalProperties] = false # Objects are strict by default
|
119
119
|
end
|
120
120
|
|
121
|
+
public
|
122
|
+
|
121
123
|
def to_s
|
122
124
|
if fields.empty?
|
123
125
|
'Object'
|
@@ -127,6 +129,8 @@ module RapiTapir
|
|
127
129
|
end
|
128
130
|
end
|
129
131
|
|
132
|
+
private
|
133
|
+
|
130
134
|
def extract_field_value(hash, field_name)
|
131
135
|
# Try different key formats: symbol, string, and string version of symbol
|
132
136
|
hash[field_name] || hash[field_name.to_s] || hash[field_name.to_sym]
|
data/lib/rapitapir/types.rb
CHANGED
@@ -124,12 +124,16 @@ module RapiTapir
|
|
124
124
|
Hash.new(field_types, **options)
|
125
125
|
end
|
126
126
|
|
127
|
+
def self.open_hash(field_types = {}, **options)
|
128
|
+
Hash.new(field_types, additional_properties: true, **options)
|
129
|
+
end
|
130
|
+
|
127
131
|
def self.optional(type)
|
128
132
|
Optional.new(type)
|
129
133
|
end
|
130
134
|
|
131
|
-
def self.object(&
|
132
|
-
Object.new(&
|
135
|
+
def self.object(&)
|
136
|
+
Object.new(&)
|
133
137
|
end
|
134
138
|
|
135
139
|
# Auto-derivation convenience methods
|
data/lib/rapitapir/version.rb
CHANGED
data/lib/rapitapir.rb
CHANGED
@@ -43,10 +43,12 @@ rescue LoadError
|
|
43
43
|
# Sinatra not available
|
44
44
|
end
|
45
45
|
|
46
|
+
# Rails integration (load unconditionally since Rails apps will define Rails)
|
46
47
|
begin
|
47
|
-
require_relative 'rapitapir/server/
|
48
|
-
rescue LoadError
|
49
|
-
# Rails not available
|
48
|
+
require_relative 'rapitapir/server/rails_integration'
|
49
|
+
rescue LoadError => e
|
50
|
+
# Rails integration files not available
|
51
|
+
warn "Rails integration not available: #{e.message}" if $DEBUG
|
50
52
|
end
|
51
53
|
|
52
54
|
# OpenAPI and client generation (optional)
|
data/rapitapir.gemspec
CHANGED
@@ -41,16 +41,18 @@ Gem::Specification.new do |spec|
|
|
41
41
|
|
42
42
|
# Runtime dependencies
|
43
43
|
spec.add_dependency 'json', '~> 2.0'
|
44
|
-
spec.add_dependency 'rack', '
|
44
|
+
spec.add_dependency 'rack', '>= 2.0', '< 4.0'
|
45
|
+
spec.add_dependency 'webrick', '~> 1.7'
|
45
46
|
|
46
47
|
# Optional framework dependencies
|
47
|
-
spec.add_dependency 'sinatra', '>= 2.0', '<
|
48
|
+
spec.add_dependency 'sinatra', '>= 2.0', '< 5.0'
|
48
49
|
|
49
50
|
# Development dependencies
|
50
|
-
spec.add_development_dependency '
|
51
|
-
spec.add_development_dependency '
|
51
|
+
spec.add_development_dependency 'puma', '~> 6.0'
|
52
|
+
spec.add_development_dependency 'rack-test', '~> 2.1'
|
53
|
+
spec.add_development_dependency 'rspec', '~> 3.13'
|
52
54
|
spec.add_development_dependency 'simplecov', '~> 0.22'
|
53
|
-
spec.add_development_dependency '
|
55
|
+
spec.add_development_dependency 'webmock', '~> 3.19'
|
54
56
|
|
55
57
|
# Documentation
|
56
58
|
spec.add_development_dependency 'redcarpet', '~> 3.6'
|