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,379 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack'
|
4
|
+
require 'json'
|
5
|
+
require_relative '../core/enhanced_endpoint'
|
6
|
+
require_relative '../dsl/enhanced_endpoint_dsl'
|
7
|
+
require_relative 'path_matcher'
|
8
|
+
|
9
|
+
module RapiTapir
|
10
|
+
module Server
|
11
|
+
# Enhanced Rack adapter that integrates with the new type system
|
12
|
+
class EnhancedRackAdapter
|
13
|
+
attr_reader :endpoints, :middleware_stack, :error_handlers
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@endpoints = []
|
17
|
+
@middleware_stack = []
|
18
|
+
@error_handlers = {}
|
19
|
+
end
|
20
|
+
|
21
|
+
# Register an endpoint with a handler
|
22
|
+
def mount(endpoint, &handler)
|
23
|
+
raise ArgumentError, 'Endpoint must be provided' unless endpoint
|
24
|
+
raise ArgumentError, 'Handler block must be provided' unless block_given?
|
25
|
+
|
26
|
+
# Ensure we're working with an enhanced endpoint
|
27
|
+
enhanced_endpoint = case endpoint
|
28
|
+
when Core::EnhancedEndpoint
|
29
|
+
endpoint
|
30
|
+
when Core::Endpoint
|
31
|
+
# Convert regular endpoint to enhanced endpoint
|
32
|
+
convert_to_enhanced(endpoint)
|
33
|
+
else
|
34
|
+
raise ArgumentError, 'Endpoint must be a RapiTapir::Core::Endpoint'
|
35
|
+
end
|
36
|
+
|
37
|
+
enhanced_endpoint.validate!
|
38
|
+
path_matcher = PathMatcher.new(enhanced_endpoint.path)
|
39
|
+
|
40
|
+
@endpoints << {
|
41
|
+
endpoint: enhanced_endpoint,
|
42
|
+
handler: handler,
|
43
|
+
path_matcher: path_matcher
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Add middleware to the processing stack
|
48
|
+
def use(middleware_class, *args, &block)
|
49
|
+
@middleware_stack << [middleware_class, args, block]
|
50
|
+
end
|
51
|
+
|
52
|
+
# Register error handlers
|
53
|
+
def on_error(error_class, &handler)
|
54
|
+
@error_handlers[error_class] = handler
|
55
|
+
end
|
56
|
+
|
57
|
+
# Main Rack application call method
|
58
|
+
def call(env)
|
59
|
+
app = build_app
|
60
|
+
app.call(env)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Build the complete middleware stack
|
64
|
+
def build_app
|
65
|
+
app = method(:handle_request)
|
66
|
+
|
67
|
+
@middleware_stack.reverse.each do |middleware_class, args, block|
|
68
|
+
app = middleware_class.new(app, *args, &block)
|
69
|
+
end
|
70
|
+
|
71
|
+
app
|
72
|
+
end
|
73
|
+
|
74
|
+
# Core request handler with enhanced type validation
|
75
|
+
def handle_request(env)
|
76
|
+
request = Rack::Request.new(env)
|
77
|
+
|
78
|
+
# Find matching endpoint
|
79
|
+
endpoint_match = find_matching_endpoint(request)
|
80
|
+
return not_found_response unless endpoint_match
|
81
|
+
|
82
|
+
begin
|
83
|
+
# Process the request through the endpoint with full validation
|
84
|
+
process_enhanced_request(request, endpoint_match)
|
85
|
+
rescue Core::EnhancedEndpoint::ValidationError => e
|
86
|
+
validation_error_response(e)
|
87
|
+
rescue Types::ValidationError => e
|
88
|
+
type_validation_error_response(e)
|
89
|
+
rescue Types::CoercionError => e
|
90
|
+
coercion_error_response(e)
|
91
|
+
rescue StandardError => e
|
92
|
+
handle_custom_error(e) || error_response(e)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def find_matching_endpoint(request)
|
99
|
+
@endpoints.find do |endpoint_data|
|
100
|
+
endpoint = endpoint_data[:endpoint]
|
101
|
+
path_matcher = endpoint_data[:path_matcher]
|
102
|
+
matches_method?(endpoint, request) && path_matcher.matches?(request.path_info)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def matches_method?(endpoint, request)
|
107
|
+
endpoint.method.to_s.upcase == request.request_method
|
108
|
+
end
|
109
|
+
|
110
|
+
def process_enhanced_request(request, endpoint_match)
|
111
|
+
endpoint = endpoint_match[:endpoint]
|
112
|
+
handler = endpoint_match[:handler]
|
113
|
+
path_matcher = endpoint_match[:path_matcher]
|
114
|
+
|
115
|
+
# Extract path parameters
|
116
|
+
path_params = path_matcher.match(request.path_info) || {}
|
117
|
+
|
118
|
+
# Extract and validate inputs using the enhanced type system
|
119
|
+
processed_inputs = extract_and_validate_inputs(request, endpoint, path_params)
|
120
|
+
|
121
|
+
# Validate the complete request
|
122
|
+
validated_inputs = endpoint.process_request(processed_inputs)
|
123
|
+
|
124
|
+
# Call the handler with validated inputs
|
125
|
+
result = handler.call(validated_inputs)
|
126
|
+
|
127
|
+
# Validate and serialize response
|
128
|
+
serialize_validated_response(result, endpoint)
|
129
|
+
end
|
130
|
+
|
131
|
+
def extract_and_validate_inputs(request, endpoint, path_params = {})
|
132
|
+
inputs = {}
|
133
|
+
|
134
|
+
endpoint.inputs.each do |input|
|
135
|
+
processed_input = process_single_input(request, input, path_params)
|
136
|
+
inputs.merge!(processed_input) if processed_input
|
137
|
+
end
|
138
|
+
|
139
|
+
inputs
|
140
|
+
end
|
141
|
+
|
142
|
+
def process_single_input(request, input, path_params)
|
143
|
+
value = extract_input_value(request, input, path_params)
|
144
|
+
|
145
|
+
# Handle required inputs
|
146
|
+
validate_required_input(input, value)
|
147
|
+
|
148
|
+
# Skip optional nil values
|
149
|
+
return nil if value.nil? && input.optional?
|
150
|
+
|
151
|
+
# Coerce and validate the value
|
152
|
+
coerced_value = coerce_and_validate_input(input, value)
|
153
|
+
{ input.name => coerced_value }
|
154
|
+
end
|
155
|
+
|
156
|
+
def validate_required_input(input, value)
|
157
|
+
return unless value.nil? && input.required?
|
158
|
+
|
159
|
+
raise Core::EnhancedEndpoint::ValidationError, "Required input '#{input.name}' is missing"
|
160
|
+
end
|
161
|
+
|
162
|
+
def coerce_and_validate_input(input, value)
|
163
|
+
coerced_value = input.coerce(value)
|
164
|
+
validation_result = input.validate(coerced_value)
|
165
|
+
|
166
|
+
unless validation_result[:valid]
|
167
|
+
errors = validation_result[:errors].join(', ')
|
168
|
+
raise Core::EnhancedEndpoint::ValidationError, "Input '#{input.name}' validation failed: #{errors}"
|
169
|
+
end
|
170
|
+
|
171
|
+
coerced_value
|
172
|
+
rescue Types::CoercionError => e
|
173
|
+
raise Core::EnhancedEndpoint::ValidationError, "Input '#{input.name}' coercion failed: #{e.message}"
|
174
|
+
end
|
175
|
+
|
176
|
+
def extract_input_value(request, input, path_params)
|
177
|
+
case input.kind
|
178
|
+
when :query
|
179
|
+
request.params[input.name.to_s]
|
180
|
+
when :header
|
181
|
+
extract_header_value(request, input)
|
182
|
+
when :path
|
183
|
+
path_params[input.name]
|
184
|
+
when :body
|
185
|
+
parse_request_body(request, input)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def extract_header_value(request, input)
|
190
|
+
header_name = case input.name
|
191
|
+
when :authorization
|
192
|
+
'HTTP_AUTHORIZATION'
|
193
|
+
else
|
194
|
+
"HTTP_#{input.name.to_s.upcase.gsub('-', '_')}"
|
195
|
+
end
|
196
|
+
request.get_header(header_name)
|
197
|
+
end
|
198
|
+
|
199
|
+
def parse_request_body(request, input)
|
200
|
+
body = request.body.read
|
201
|
+
request.body.rewind
|
202
|
+
|
203
|
+
return nil if body.empty?
|
204
|
+
|
205
|
+
content_type = request.content_type&.downcase
|
206
|
+
format = input.options[:format] || detect_format_from_content_type(content_type)
|
207
|
+
|
208
|
+
case format
|
209
|
+
when :json
|
210
|
+
JSON.parse(body)
|
211
|
+
when :form
|
212
|
+
Rack::Utils.parse_nested_query(body)
|
213
|
+
else
|
214
|
+
body
|
215
|
+
end
|
216
|
+
rescue JSON::ParserError => e
|
217
|
+
raise Core::EnhancedEndpoint::ValidationError, "Invalid JSON in request body: #{e.message}"
|
218
|
+
end
|
219
|
+
|
220
|
+
def detect_format_from_content_type(content_type)
|
221
|
+
case content_type
|
222
|
+
when %r{application/json}
|
223
|
+
:json
|
224
|
+
when %r{application/x-www-form-urlencoded}, %r{multipart/form-data}
|
225
|
+
:form
|
226
|
+
else
|
227
|
+
:text
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def serialize_validated_response(result, endpoint)
|
232
|
+
output, status_code = find_response_configuration(endpoint)
|
233
|
+
|
234
|
+
if output
|
235
|
+
build_validated_response(result, output, status_code)
|
236
|
+
else
|
237
|
+
build_default_response(result, status_code)
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
def find_response_configuration(endpoint)
|
242
|
+
output = find_response_output(endpoint)
|
243
|
+
status_code = find_status_code(endpoint)
|
244
|
+
|
245
|
+
[output, status_code]
|
246
|
+
end
|
247
|
+
|
248
|
+
def find_response_output(endpoint)
|
249
|
+
endpoint.outputs.find { |o| o.kind == :json } ||
|
250
|
+
endpoint.outputs.find { |o| o.kind == :text } ||
|
251
|
+
endpoint.outputs.first
|
252
|
+
end
|
253
|
+
|
254
|
+
def find_status_code(endpoint)
|
255
|
+
status_output = endpoint.outputs.find { |o| o.kind == :status }
|
256
|
+
status_output&.type || determine_default_status(endpoint.method)
|
257
|
+
end
|
258
|
+
|
259
|
+
def build_validated_response(result, output, status_code)
|
260
|
+
validate_response_result(result, output)
|
261
|
+
|
262
|
+
serialized = output.serialize(result)
|
263
|
+
content_type = determine_content_type(output)
|
264
|
+
|
265
|
+
[status_code, { 'Content-Type' => content_type }, [serialized]]
|
266
|
+
end
|
267
|
+
|
268
|
+
def validate_response_result(result, output)
|
269
|
+
validation_result = output.validate(result)
|
270
|
+
return if validation_result[:valid]
|
271
|
+
|
272
|
+
errors = validation_result[:errors].join(', ')
|
273
|
+
raise StandardError, "Response validation failed: #{errors}"
|
274
|
+
end
|
275
|
+
|
276
|
+
def build_default_response(result, status_code)
|
277
|
+
[status_code, { 'Content-Type' => 'application/json' }, [JSON.generate(result)]]
|
278
|
+
end
|
279
|
+
|
280
|
+
def determine_content_type(output)
|
281
|
+
case output.kind
|
282
|
+
when :text
|
283
|
+
'text/plain'
|
284
|
+
else # Default to JSON for :json and unknown formats
|
285
|
+
'application/json'
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
def determine_default_status(method)
|
290
|
+
case method
|
291
|
+
when :post
|
292
|
+
201
|
293
|
+
when :delete
|
294
|
+
204
|
295
|
+
else
|
296
|
+
200
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
def convert_to_enhanced(endpoint)
|
301
|
+
# Convert a regular endpoint to an enhanced endpoint
|
302
|
+
Core::EnhancedEndpoint.new(
|
303
|
+
method: endpoint.method,
|
304
|
+
path: endpoint.path,
|
305
|
+
inputs: endpoint.inputs,
|
306
|
+
outputs: endpoint.outputs,
|
307
|
+
errors: endpoint.errors,
|
308
|
+
metadata: endpoint.metadata
|
309
|
+
)
|
310
|
+
end
|
311
|
+
|
312
|
+
def handle_custom_error(error)
|
313
|
+
handler = @error_handlers[error.class] || @error_handlers[StandardError]
|
314
|
+
return nil unless handler
|
315
|
+
|
316
|
+
begin
|
317
|
+
result = handler.call(error)
|
318
|
+
[500, { 'Content-Type' => 'application/json' }, [JSON.generate(result)]]
|
319
|
+
rescue StandardError
|
320
|
+
# If custom error handler fails, fall back to default
|
321
|
+
nil
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
# Error response helpers
|
326
|
+
def not_found_response
|
327
|
+
error_data = {
|
328
|
+
error: 'Not Found',
|
329
|
+
message: 'The requested endpoint was not found',
|
330
|
+
code: 404
|
331
|
+
}
|
332
|
+
[404, { 'Content-Type' => 'application/json' }, [JSON.generate(error_data)]]
|
333
|
+
end
|
334
|
+
|
335
|
+
def validation_error_response(error)
|
336
|
+
error_data = {
|
337
|
+
error: 'Validation Error',
|
338
|
+
message: error.message,
|
339
|
+
code: 400
|
340
|
+
}
|
341
|
+
[400, { 'Content-Type' => 'application/json' }, [JSON.generate(error_data)]]
|
342
|
+
end
|
343
|
+
|
344
|
+
def type_validation_error_response(error)
|
345
|
+
error_data = {
|
346
|
+
error: 'Type Validation Error',
|
347
|
+
message: error.message,
|
348
|
+
type: error.type.to_s,
|
349
|
+
value: error.value,
|
350
|
+
errors: error.errors,
|
351
|
+
code: 400
|
352
|
+
}
|
353
|
+
[400, { 'Content-Type' => 'application/json' }, [JSON.generate(error_data)]]
|
354
|
+
end
|
355
|
+
|
356
|
+
def coercion_error_response(error)
|
357
|
+
error_data = {
|
358
|
+
error: 'Type Coercion Error',
|
359
|
+
message: error.message,
|
360
|
+
type: error.type.to_s,
|
361
|
+
value: error.value,
|
362
|
+
reason: error.reason,
|
363
|
+
code: 400
|
364
|
+
}
|
365
|
+
[400, { 'Content-Type' => 'application/json' }, [JSON.generate(error_data)]]
|
366
|
+
end
|
367
|
+
|
368
|
+
def error_response(error)
|
369
|
+
error_data = {
|
370
|
+
error: error.class.name,
|
371
|
+
message: error.message,
|
372
|
+
code: 500
|
373
|
+
}
|
374
|
+
|
375
|
+
[500, { 'Content-Type' => 'application/json' }, [JSON.generate(error_data)]]
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RapiTapir
|
4
|
+
module Server
|
5
|
+
module Middleware
|
6
|
+
# Base middleware class for RapiTapir middleware
|
7
|
+
class Base
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
@app.call(env)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Logging middleware for request/response logging
|
18
|
+
class Logger < Base
|
19
|
+
def initialize(app, logger = nil)
|
20
|
+
super(app)
|
21
|
+
@logger = logger || default_logger
|
22
|
+
end
|
23
|
+
|
24
|
+
def call(env)
|
25
|
+
start_time = Time.now
|
26
|
+
request = Rack::Request.new(env)
|
27
|
+
|
28
|
+
@logger.info("Started #{request.request_method} #{request.fullpath}")
|
29
|
+
|
30
|
+
status, headers, body = @app.call(env)
|
31
|
+
|
32
|
+
duration = ((Time.now - start_time) * 1000).round(2)
|
33
|
+
@logger.info("Completed #{status} in #{duration}ms")
|
34
|
+
|
35
|
+
[status, headers, body]
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def default_logger
|
41
|
+
require 'logger'
|
42
|
+
Logger.new($stdout).tap do |logger|
|
43
|
+
logger.level = Logger::INFO
|
44
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
45
|
+
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# CORS middleware for cross-origin requests
|
52
|
+
class CORS < Base
|
53
|
+
def initialize(app, options = {})
|
54
|
+
super(app)
|
55
|
+
@options = {
|
56
|
+
allow_origin: '*',
|
57
|
+
allow_methods: %w[GET POST PUT DELETE OPTIONS PATCH],
|
58
|
+
allow_headers: %w[Content-Type Authorization],
|
59
|
+
max_age: 86_400
|
60
|
+
}.merge(options)
|
61
|
+
end
|
62
|
+
|
63
|
+
def call(env)
|
64
|
+
if env['REQUEST_METHOD'] == 'OPTIONS'
|
65
|
+
# Preflight request
|
66
|
+
[200, cors_headers, ['']]
|
67
|
+
else
|
68
|
+
status, headers, body = @app.call(env)
|
69
|
+
[status, headers.merge(cors_headers), body]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def cors_headers
|
76
|
+
{
|
77
|
+
'Access-Control-Allow-Origin' => @options[:allow_origin],
|
78
|
+
'Access-Control-Allow-Methods' => @options[:allow_methods].join(', '),
|
79
|
+
'Access-Control-Allow-Headers' => @options[:allow_headers].join(', '),
|
80
|
+
'Access-Control-Max-Age' => @options[:max_age].to_s
|
81
|
+
}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Exception handling middleware
|
86
|
+
class ExceptionHandler < Base
|
87
|
+
def initialize(app, options = {})
|
88
|
+
super(app)
|
89
|
+
@show_exceptions = options.fetch(:show_exceptions, false)
|
90
|
+
@logger = options[:logger]
|
91
|
+
end
|
92
|
+
|
93
|
+
def call(env)
|
94
|
+
@app.call(env)
|
95
|
+
rescue StandardError => e
|
96
|
+
@logger&.error("Unhandled exception: #{e.class}: #{e.message}")
|
97
|
+
@logger&.error(e.backtrace.join("\n")) if @show_exceptions
|
98
|
+
|
99
|
+
error_response(e)
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def error_response(error)
|
105
|
+
error_data = { error: 'Internal Server Error' }
|
106
|
+
|
107
|
+
if @show_exceptions
|
108
|
+
error_data.merge!(
|
109
|
+
exception: error.class.name,
|
110
|
+
message: error.message,
|
111
|
+
backtrace: error.backtrace
|
112
|
+
)
|
113
|
+
end
|
114
|
+
|
115
|
+
[500, { 'Content-Type' => 'application/json' }, [JSON.generate(error_data)]]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RapiTapir
|
4
|
+
module Server
|
5
|
+
# Path matching utility for HTTP routes
|
6
|
+
# Matches request paths against endpoint path patterns with parameter extraction
|
7
|
+
class PathMatcher
|
8
|
+
attr_reader :path_pattern, :param_names
|
9
|
+
|
10
|
+
def initialize(path_pattern)
|
11
|
+
@path_pattern = path_pattern
|
12
|
+
@param_names = extract_param_names(path_pattern)
|
13
|
+
@regex = build_regex(path_pattern)
|
14
|
+
end
|
15
|
+
|
16
|
+
def match(path)
|
17
|
+
match_data = @regex.match(path)
|
18
|
+
return nil unless match_data
|
19
|
+
|
20
|
+
params = {}
|
21
|
+
@param_names.each_with_index do |param_name, index|
|
22
|
+
params[param_name.to_sym] = match_data[index + 1]
|
23
|
+
end
|
24
|
+
|
25
|
+
params
|
26
|
+
end
|
27
|
+
|
28
|
+
def matches?(path)
|
29
|
+
@regex.match?(path)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def extract_param_names(pattern)
|
35
|
+
pattern.scan(/:(\w+)/).flatten
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_regex(pattern)
|
39
|
+
# Convert "/users/:id/posts/:post_id" to /^\/users\/([^\/]+)\/posts\/([^\/]+)$/
|
40
|
+
regex_pattern = pattern.gsub(/:(\w+)/, '([^/]+)')
|
41
|
+
Regexp.new("^#{regex_pattern}$")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|