tsikol 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/CHANGELOG.md +22 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +579 -0
- data/Rakefile +12 -0
- data/docs/README.md +69 -0
- data/docs/api/middleware.md +721 -0
- data/docs/api/prompt.md +858 -0
- data/docs/api/resource.md +651 -0
- data/docs/api/server.md +509 -0
- data/docs/api/test-helpers.md +591 -0
- data/docs/api/tool.md +527 -0
- data/docs/cookbook/authentication.md +651 -0
- data/docs/cookbook/caching.md +877 -0
- data/docs/cookbook/dynamic-tools.md +970 -0
- data/docs/cookbook/error-handling.md +887 -0
- data/docs/cookbook/logging.md +1044 -0
- data/docs/cookbook/rate-limiting.md +717 -0
- data/docs/examples/code-assistant.md +922 -0
- data/docs/examples/complete-server.md +726 -0
- data/docs/examples/database-manager.md +1198 -0
- data/docs/examples/devops-tools.md +1382 -0
- data/docs/examples/echo-server.md +501 -0
- data/docs/examples/weather-service.md +822 -0
- data/docs/guides/completion.md +472 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/guides/middleware.md +823 -0
- data/docs/guides/project-structure.md +434 -0
- data/docs/guides/prompts.md +920 -0
- data/docs/guides/resources.md +720 -0
- data/docs/guides/sampling.md +804 -0
- data/docs/guides/testing.md +863 -0
- data/docs/guides/tools.md +627 -0
- data/examples/README.md +92 -0
- data/examples/advanced_features.rb +129 -0
- data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
- data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
- data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
- data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
- data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
- data/examples/basic-migrated/server.rb +25 -0
- data/examples/basic.rb +73 -0
- data/examples/full_featured.rb +175 -0
- data/examples/middleware_example.rb +112 -0
- data/examples/sampling_example.rb +104 -0
- data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
- data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
- data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
- data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
- data/examples/weather-service/server.rb +28 -0
- data/exe/tsikol +6 -0
- data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
- data/lib/tsikol/cli/templates/README.md.erb +38 -0
- data/lib/tsikol/cli/templates/gitignore.erb +49 -0
- data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
- data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
- data/lib/tsikol/cli/templates/server.rb.erb +24 -0
- data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
- data/lib/tsikol/cli.rb +203 -0
- data/lib/tsikol/error_handler.rb +141 -0
- data/lib/tsikol/health.rb +198 -0
- data/lib/tsikol/http_transport.rb +72 -0
- data/lib/tsikol/lifecycle.rb +149 -0
- data/lib/tsikol/middleware.rb +168 -0
- data/lib/tsikol/prompt.rb +101 -0
- data/lib/tsikol/resource.rb +53 -0
- data/lib/tsikol/router.rb +190 -0
- data/lib/tsikol/server.rb +660 -0
- data/lib/tsikol/stdio_transport.rb +108 -0
- data/lib/tsikol/test_helpers.rb +261 -0
- data/lib/tsikol/tool.rb +111 -0
- data/lib/tsikol/version.rb +5 -0
- data/lib/tsikol.rb +72 -0
- metadata +219 -0
@@ -0,0 +1,721 @@
|
|
1
|
+
# Middleware API Reference
|
2
|
+
|
3
|
+
The `Tsikol::Middleware` class provides the foundation for creating middleware components that intercept and process requests/responses.
|
4
|
+
|
5
|
+
## Class: Tsikol::Middleware
|
6
|
+
|
7
|
+
### Constructor
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
class MyMiddleware < Tsikol::Middleware
|
11
|
+
def initialize(app, options = {})
|
12
|
+
@app = app
|
13
|
+
@options = options
|
14
|
+
# Additional initialization
|
15
|
+
end
|
16
|
+
end
|
17
|
+
```
|
18
|
+
|
19
|
+
#### Parameters
|
20
|
+
|
21
|
+
- `app`: The next middleware or handler in the chain
|
22
|
+
- `options` (Hash): Configuration options for the middleware
|
23
|
+
|
24
|
+
### Core Method
|
25
|
+
|
26
|
+
#### `#call(request)`
|
27
|
+
|
28
|
+
Process a request and optionally modify the response.
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
def call(request)
|
32
|
+
# Before request processing
|
33
|
+
modified_request = process_request(request)
|
34
|
+
|
35
|
+
# Call next middleware/handler
|
36
|
+
response = @app.call(modified_request)
|
37
|
+
|
38
|
+
# After response processing
|
39
|
+
process_response(response)
|
40
|
+
end
|
41
|
+
```
|
42
|
+
|
43
|
+
## Built-in Middleware
|
44
|
+
|
45
|
+
### Tsikol::LoggingMiddleware
|
46
|
+
|
47
|
+
Logs all requests and responses.
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
server.use Tsikol::LoggingMiddleware,
|
51
|
+
level: :info, # Log level (:debug, :info, :warning, :error)
|
52
|
+
include_params: true, # Log request parameters
|
53
|
+
include_response: true, # Log response data
|
54
|
+
max_response_length: 1000 # Truncate long responses
|
55
|
+
```
|
56
|
+
|
57
|
+
Example implementation:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
class LoggingMiddleware < Tsikol::Middleware
|
61
|
+
def initialize(app, options = {})
|
62
|
+
super
|
63
|
+
@level = options[:level] || :info
|
64
|
+
@include_params = options.fetch(:include_params, true)
|
65
|
+
@include_response = options.fetch(:include_response, true)
|
66
|
+
@max_response_length = options[:max_response_length] || 1000
|
67
|
+
end
|
68
|
+
|
69
|
+
def call(request)
|
70
|
+
start_time = Time.now
|
71
|
+
request_id = request["id"]
|
72
|
+
method = request["method"]
|
73
|
+
|
74
|
+
# Log request
|
75
|
+
log_data = { method: method, id: request_id }
|
76
|
+
log_data[:params] = request["params"] if @include_params && request["params"]
|
77
|
+
|
78
|
+
log @level, "Request received", log_data
|
79
|
+
|
80
|
+
# Process request
|
81
|
+
response = @app.call(request)
|
82
|
+
|
83
|
+
# Log response
|
84
|
+
duration = ((Time.now - start_time) * 1000).round(2)
|
85
|
+
|
86
|
+
response_data = {
|
87
|
+
method: method,
|
88
|
+
id: request_id,
|
89
|
+
duration_ms: duration,
|
90
|
+
status: response[:error] ? "error" : "success"
|
91
|
+
}
|
92
|
+
|
93
|
+
if @include_response
|
94
|
+
if response[:error]
|
95
|
+
response_data[:error] = response[:error]
|
96
|
+
elsif response[:result]
|
97
|
+
result_str = response[:result].to_s
|
98
|
+
if result_str.length > @max_response_length
|
99
|
+
response_data[:result] = result_str[0...@max_response_length] + "..."
|
100
|
+
response_data[:truncated] = true
|
101
|
+
else
|
102
|
+
response_data[:result] = response[:result]
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
log @level, "Request completed", response_data
|
108
|
+
|
109
|
+
response
|
110
|
+
rescue => e
|
111
|
+
log :error, "Request failed",
|
112
|
+
method: method,
|
113
|
+
id: request_id,
|
114
|
+
error: e.class.name,
|
115
|
+
message: e.message
|
116
|
+
raise
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def log(level, message, data)
|
122
|
+
return unless @app.respond_to?(:log)
|
123
|
+
@app.log(level, message, data: data)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
### Tsikol::ErrorHandlingMiddleware
|
129
|
+
|
130
|
+
Provides consistent error handling and formatting.
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
server.use Tsikol::ErrorHandlingMiddleware,
|
134
|
+
log_errors: true, # Log errors
|
135
|
+
include_backtrace: false, # Include stack traces in development
|
136
|
+
error_handler: ->(e) { } # Custom error handler callback
|
137
|
+
```
|
138
|
+
|
139
|
+
Example implementation:
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
class ErrorHandlingMiddleware < Tsikol::Middleware
|
143
|
+
def initialize(app, options = {})
|
144
|
+
super
|
145
|
+
@log_errors = options.fetch(:log_errors, true)
|
146
|
+
@include_backtrace = options.fetch(:include_backtrace, false)
|
147
|
+
@error_handler = options[:error_handler]
|
148
|
+
end
|
149
|
+
|
150
|
+
def call(request)
|
151
|
+
@app.call(request)
|
152
|
+
rescue Tsikol::ValidationError => e
|
153
|
+
handle_validation_error(request, e)
|
154
|
+
rescue Tsikol::NotFoundError => e
|
155
|
+
handle_not_found_error(request, e)
|
156
|
+
rescue StandardError => e
|
157
|
+
handle_internal_error(request, e)
|
158
|
+
end
|
159
|
+
|
160
|
+
private
|
161
|
+
|
162
|
+
def handle_validation_error(request, error)
|
163
|
+
log_error(request, error) if @log_errors
|
164
|
+
|
165
|
+
{
|
166
|
+
jsonrpc: "2.0",
|
167
|
+
id: request["id"],
|
168
|
+
error: {
|
169
|
+
code: -32602,
|
170
|
+
message: "Invalid params",
|
171
|
+
data: { details: error.message }
|
172
|
+
}
|
173
|
+
}
|
174
|
+
end
|
175
|
+
|
176
|
+
def handle_not_found_error(request, error)
|
177
|
+
log_error(request, error) if @log_errors
|
178
|
+
|
179
|
+
{
|
180
|
+
jsonrpc: "2.0",
|
181
|
+
id: request["id"],
|
182
|
+
error: {
|
183
|
+
code: -32601,
|
184
|
+
message: "Method not found",
|
185
|
+
data: { details: error.message }
|
186
|
+
}
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
190
|
+
def handle_internal_error(request, error)
|
191
|
+
log_error(request, error) if @log_errors
|
192
|
+
@error_handler&.call(error)
|
193
|
+
|
194
|
+
error_data = { message: error.message }
|
195
|
+
error_data[:backtrace] = error.backtrace.first(10) if @include_backtrace
|
196
|
+
|
197
|
+
{
|
198
|
+
jsonrpc: "2.0",
|
199
|
+
id: request["id"],
|
200
|
+
error: {
|
201
|
+
code: -32603,
|
202
|
+
message: "Internal error",
|
203
|
+
data: error_data
|
204
|
+
}
|
205
|
+
}
|
206
|
+
end
|
207
|
+
|
208
|
+
def log_error(request, error)
|
209
|
+
return unless @app.respond_to?(:log)
|
210
|
+
|
211
|
+
@app.log :error, "Request error",
|
212
|
+
data: {
|
213
|
+
method: request["method"],
|
214
|
+
error_class: error.class.name,
|
215
|
+
error_message: error.message,
|
216
|
+
backtrace: error.backtrace&.first(5)
|
217
|
+
}
|
218
|
+
end
|
219
|
+
end
|
220
|
+
```
|
221
|
+
|
222
|
+
### Tsikol::RateLimitMiddleware
|
223
|
+
|
224
|
+
Implements rate limiting to prevent abuse.
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
server.use Tsikol::RateLimitMiddleware,
|
228
|
+
max_requests: 100, # Maximum requests per window
|
229
|
+
window_seconds: 60, # Time window in seconds
|
230
|
+
by: :client_id, # Group requests by this key
|
231
|
+
skip_methods: [] # Methods to exclude from rate limiting
|
232
|
+
```
|
233
|
+
|
234
|
+
Example implementation:
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
class RateLimitMiddleware < Tsikol::Middleware
|
238
|
+
def initialize(app, options = {})
|
239
|
+
super
|
240
|
+
@max_requests = options[:max_requests] || 60
|
241
|
+
@window_seconds = options[:window_seconds] || 60
|
242
|
+
@by = options[:by] || :client_id
|
243
|
+
@skip_methods = options[:skip_methods] || []
|
244
|
+
@buckets = {}
|
245
|
+
@mutex = Mutex.new
|
246
|
+
end
|
247
|
+
|
248
|
+
def call(request)
|
249
|
+
method = request["method"]
|
250
|
+
|
251
|
+
# Skip rate limiting for certain methods
|
252
|
+
return @app.call(request) if @skip_methods.include?(method)
|
253
|
+
|
254
|
+
# Get client identifier
|
255
|
+
client_id = get_client_id(request)
|
256
|
+
|
257
|
+
# Check rate limit
|
258
|
+
if rate_limited?(client_id)
|
259
|
+
return rate_limit_response(request)
|
260
|
+
end
|
261
|
+
|
262
|
+
# Track request
|
263
|
+
track_request(client_id)
|
264
|
+
|
265
|
+
# Process request
|
266
|
+
@app.call(request)
|
267
|
+
end
|
268
|
+
|
269
|
+
private
|
270
|
+
|
271
|
+
def get_client_id(request)
|
272
|
+
case @by
|
273
|
+
when :client_id
|
274
|
+
request.dig("params", "_client_id") || "anonymous"
|
275
|
+
when :ip
|
276
|
+
request.dig("meta", "remote_ip") || "unknown"
|
277
|
+
when Proc
|
278
|
+
@by.call(request)
|
279
|
+
else
|
280
|
+
"global"
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
def rate_limited?(client_id)
|
285
|
+
@mutex.synchronize do
|
286
|
+
bucket = get_bucket(client_id)
|
287
|
+
clean_bucket(bucket)
|
288
|
+
bucket[:requests].size >= @max_requests
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
def track_request(client_id)
|
293
|
+
@mutex.synchronize do
|
294
|
+
bucket = get_bucket(client_id)
|
295
|
+
bucket[:requests] << Time.now
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
def get_bucket(client_id)
|
300
|
+
@buckets[client_id] ||= { requests: [] }
|
301
|
+
end
|
302
|
+
|
303
|
+
def clean_bucket(bucket)
|
304
|
+
cutoff = Time.now - @window_seconds
|
305
|
+
bucket[:requests].reject! { |time| time < cutoff }
|
306
|
+
end
|
307
|
+
|
308
|
+
def rate_limit_response(request)
|
309
|
+
{
|
310
|
+
jsonrpc: "2.0",
|
311
|
+
id: request["id"],
|
312
|
+
error: {
|
313
|
+
code: -32000,
|
314
|
+
message: "Rate limit exceeded",
|
315
|
+
data: {
|
316
|
+
max_requests: @max_requests,
|
317
|
+
window_seconds: @window_seconds,
|
318
|
+
retry_after: @window_seconds
|
319
|
+
}
|
320
|
+
}
|
321
|
+
}
|
322
|
+
end
|
323
|
+
end
|
324
|
+
```
|
325
|
+
|
326
|
+
### Tsikol::MetricsMiddleware
|
327
|
+
|
328
|
+
Collects performance metrics.
|
329
|
+
|
330
|
+
```ruby
|
331
|
+
server.use Tsikol::MetricsMiddleware,
|
332
|
+
include_histogram: true, # Track response time distribution
|
333
|
+
track_errors: true, # Count errors by type
|
334
|
+
custom_tags: {} # Additional tags for metrics
|
335
|
+
```
|
336
|
+
|
337
|
+
## Creating Custom Middleware
|
338
|
+
|
339
|
+
### Basic Template
|
340
|
+
|
341
|
+
```ruby
|
342
|
+
class CustomMiddleware < Tsikol::Middleware
|
343
|
+
def initialize(app, options = {})
|
344
|
+
@app = app
|
345
|
+
# Initialize with options
|
346
|
+
end
|
347
|
+
|
348
|
+
def call(request)
|
349
|
+
# Pre-processing
|
350
|
+
|
351
|
+
# Call next middleware
|
352
|
+
response = @app.call(request)
|
353
|
+
|
354
|
+
# Post-processing
|
355
|
+
|
356
|
+
response
|
357
|
+
end
|
358
|
+
end
|
359
|
+
```
|
360
|
+
|
361
|
+
### Request Modification
|
362
|
+
|
363
|
+
```ruby
|
364
|
+
class RequestEnrichmentMiddleware < Tsikol::Middleware
|
365
|
+
def call(request)
|
366
|
+
# Add metadata
|
367
|
+
request["meta"] ||= {}
|
368
|
+
request["meta"]["timestamp"] = Time.now.iso8601
|
369
|
+
request["meta"]["request_id"] = SecureRandom.uuid
|
370
|
+
|
371
|
+
# Add default parameters
|
372
|
+
if request["params"]
|
373
|
+
request["params"]["_server_version"] = Tsikol::VERSION
|
374
|
+
end
|
375
|
+
|
376
|
+
@app.call(request)
|
377
|
+
end
|
378
|
+
end
|
379
|
+
```
|
380
|
+
|
381
|
+
### Response Modification
|
382
|
+
|
383
|
+
```ruby
|
384
|
+
class ResponseFormattingMiddleware < Tsikol::Middleware
|
385
|
+
def call(request)
|
386
|
+
start_time = Time.now
|
387
|
+
|
388
|
+
response = @app.call(request)
|
389
|
+
|
390
|
+
# Add timing information
|
391
|
+
duration = Time.now - start_time
|
392
|
+
|
393
|
+
if response[:result]
|
394
|
+
response[:result] = {
|
395
|
+
data: response[:result],
|
396
|
+
meta: {
|
397
|
+
duration_seconds: duration,
|
398
|
+
timestamp: Time.now.iso8601
|
399
|
+
}
|
400
|
+
}
|
401
|
+
end
|
402
|
+
|
403
|
+
response
|
404
|
+
end
|
405
|
+
end
|
406
|
+
```
|
407
|
+
|
408
|
+
### Conditional Processing
|
409
|
+
|
410
|
+
```ruby
|
411
|
+
class ConditionalMiddleware < Tsikol::Middleware
|
412
|
+
def initialize(app, options = {})
|
413
|
+
super
|
414
|
+
@condition = options[:if] || ->(_) { true }
|
415
|
+
@unless = options[:unless] || ->(_) { false }
|
416
|
+
end
|
417
|
+
|
418
|
+
def call(request)
|
419
|
+
if should_process?(request)
|
420
|
+
# Apply middleware logic
|
421
|
+
process_with_middleware(request)
|
422
|
+
else
|
423
|
+
# Skip middleware
|
424
|
+
@app.call(request)
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
private
|
429
|
+
|
430
|
+
def should_process?(request)
|
431
|
+
@condition.call(request) && !@unless.call(request)
|
432
|
+
end
|
433
|
+
|
434
|
+
def process_with_middleware(request)
|
435
|
+
# Middleware logic here
|
436
|
+
@app.call(request)
|
437
|
+
end
|
438
|
+
end
|
439
|
+
```
|
440
|
+
|
441
|
+
### Short-Circuit Responses
|
442
|
+
|
443
|
+
```ruby
|
444
|
+
class CacheMiddleware < Tsikol::Middleware
|
445
|
+
def initialize(app, options = {})
|
446
|
+
super
|
447
|
+
@cache = {}
|
448
|
+
@ttl = options[:ttl] || 300
|
449
|
+
end
|
450
|
+
|
451
|
+
def call(request)
|
452
|
+
# Only cache certain methods
|
453
|
+
return @app.call(request) unless cacheable?(request)
|
454
|
+
|
455
|
+
cache_key = generate_key(request)
|
456
|
+
|
457
|
+
# Check cache
|
458
|
+
if cached = get_cached(cache_key)
|
459
|
+
# Return cached response without calling app
|
460
|
+
return cached
|
461
|
+
end
|
462
|
+
|
463
|
+
# Process and cache
|
464
|
+
response = @app.call(request)
|
465
|
+
|
466
|
+
if response[:result]
|
467
|
+
set_cached(cache_key, response)
|
468
|
+
end
|
469
|
+
|
470
|
+
response
|
471
|
+
end
|
472
|
+
|
473
|
+
private
|
474
|
+
|
475
|
+
def cacheable?(request)
|
476
|
+
["resources/read", "prompts/get"].include?(request["method"])
|
477
|
+
end
|
478
|
+
|
479
|
+
def generate_key(request)
|
480
|
+
"#{request['method']}:#{request['params'].to_json}"
|
481
|
+
end
|
482
|
+
|
483
|
+
def get_cached(key)
|
484
|
+
entry = @cache[key]
|
485
|
+
return nil unless entry
|
486
|
+
return nil if Time.now - entry[:time] > @ttl
|
487
|
+
entry[:response]
|
488
|
+
end
|
489
|
+
|
490
|
+
def set_cached(key, response)
|
491
|
+
@cache[key] = {
|
492
|
+
response: response,
|
493
|
+
time: Time.now
|
494
|
+
}
|
495
|
+
end
|
496
|
+
end
|
497
|
+
```
|
498
|
+
|
499
|
+
## Middleware Composition
|
500
|
+
|
501
|
+
### Combining Middleware
|
502
|
+
|
503
|
+
```ruby
|
504
|
+
class CompositeMiddleware < Tsikol::Middleware
|
505
|
+
def initialize(app, *middleware_classes)
|
506
|
+
@app = build_chain(app, middleware_classes)
|
507
|
+
end
|
508
|
+
|
509
|
+
def call(request)
|
510
|
+
@app.call(request)
|
511
|
+
end
|
512
|
+
|
513
|
+
private
|
514
|
+
|
515
|
+
def build_chain(app, middleware_classes)
|
516
|
+
middleware_classes.reverse.reduce(app) do |next_app, (klass, options)|
|
517
|
+
klass.new(next_app, options || {})
|
518
|
+
end
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
# Usage
|
523
|
+
server.use CompositeMiddleware,
|
524
|
+
[LoggingMiddleware, { level: :debug }],
|
525
|
+
[RateLimitMiddleware, { max_requests: 100 }],
|
526
|
+
[CacheMiddleware, { ttl: 600 }]
|
527
|
+
```
|
528
|
+
|
529
|
+
### Middleware Stack
|
530
|
+
|
531
|
+
```ruby
|
532
|
+
class MiddlewareStack
|
533
|
+
def initialize
|
534
|
+
@middlewares = []
|
535
|
+
end
|
536
|
+
|
537
|
+
def use(middleware_class, options = {})
|
538
|
+
@middlewares << [middleware_class, options]
|
539
|
+
end
|
540
|
+
|
541
|
+
def build(app)
|
542
|
+
@middlewares.reverse.reduce(app) do |next_app, (klass, opts)|
|
543
|
+
klass.new(next_app, opts)
|
544
|
+
end
|
545
|
+
end
|
546
|
+
end
|
547
|
+
```
|
548
|
+
|
549
|
+
## Testing Middleware
|
550
|
+
|
551
|
+
### Basic Middleware Test
|
552
|
+
|
553
|
+
```ruby
|
554
|
+
require 'minitest/autorun'
|
555
|
+
|
556
|
+
class MiddlewareTest < Minitest::Test
|
557
|
+
def setup
|
558
|
+
@app = MockApp.new
|
559
|
+
@middleware = MyMiddleware.new(@app, option: "value")
|
560
|
+
end
|
561
|
+
|
562
|
+
def test_passes_request_through
|
563
|
+
request = build_request("test.method")
|
564
|
+
response = @middleware.call(request)
|
565
|
+
|
566
|
+
assert_equal "success", response[:result]
|
567
|
+
assert @app.called_with?(request)
|
568
|
+
end
|
569
|
+
|
570
|
+
def test_modifies_request
|
571
|
+
request = build_request("test.method")
|
572
|
+
@middleware.call(request)
|
573
|
+
|
574
|
+
# Check that middleware modified the request
|
575
|
+
assert request["meta"]["added_by_middleware"]
|
576
|
+
end
|
577
|
+
|
578
|
+
def test_handles_app_errors
|
579
|
+
@app.raise_error = true
|
580
|
+
request = build_request("test.method")
|
581
|
+
|
582
|
+
assert_raises(StandardError) do
|
583
|
+
@middleware.call(request)
|
584
|
+
end
|
585
|
+
end
|
586
|
+
|
587
|
+
private
|
588
|
+
|
589
|
+
def build_request(method, params = {})
|
590
|
+
{
|
591
|
+
"jsonrpc" => "2.0",
|
592
|
+
"id" => 1,
|
593
|
+
"method" => method,
|
594
|
+
"params" => params
|
595
|
+
}
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
class MockApp
|
600
|
+
attr_accessor :raise_error
|
601
|
+
attr_reader :last_request
|
602
|
+
|
603
|
+
def call(request)
|
604
|
+
@last_request = request
|
605
|
+
raise StandardError, "Mock error" if raise_error
|
606
|
+
{ jsonrpc: "2.0", id: request["id"], result: "success" }
|
607
|
+
end
|
608
|
+
|
609
|
+
def called_with?(request)
|
610
|
+
@last_request == request
|
611
|
+
end
|
612
|
+
end
|
613
|
+
```
|
614
|
+
|
615
|
+
### Integration Testing
|
616
|
+
|
617
|
+
```ruby
|
618
|
+
class MiddlewareIntegrationTest < Minitest::Test
|
619
|
+
def setup
|
620
|
+
@server = Tsikol::Server.new(name: "test")
|
621
|
+
|
622
|
+
# Add middleware stack
|
623
|
+
@server.use TimingMiddleware
|
624
|
+
@server.use LoggingMiddleware, level: :debug
|
625
|
+
@server.use ErrorHandlingMiddleware
|
626
|
+
|
627
|
+
# Add a test tool
|
628
|
+
@server.register_tool("echo") do |message:|
|
629
|
+
message
|
630
|
+
end
|
631
|
+
end
|
632
|
+
|
633
|
+
def test_middleware_chain_execution_order
|
634
|
+
# Create a tracking middleware
|
635
|
+
call_order = []
|
636
|
+
|
637
|
+
tracking_middleware = Class.new(Tsikol::Middleware) do
|
638
|
+
define_method :initialize do |app, name:|
|
639
|
+
@app = app
|
640
|
+
@name = name
|
641
|
+
end
|
642
|
+
|
643
|
+
define_method :call do |request|
|
644
|
+
call_order << "#{@name}:before"
|
645
|
+
response = @app.call(request)
|
646
|
+
call_order << "#{@name}:after"
|
647
|
+
response
|
648
|
+
end
|
649
|
+
end
|
650
|
+
|
651
|
+
server = Tsikol::Server.new(name: "test")
|
652
|
+
server.use tracking_middleware, name: "first"
|
653
|
+
server.use tracking_middleware, name: "second"
|
654
|
+
server.use tracking_middleware, name: "third"
|
655
|
+
|
656
|
+
server.handle_request({
|
657
|
+
"jsonrpc" => "2.0",
|
658
|
+
"id" => 1,
|
659
|
+
"method" => "initialize"
|
660
|
+
})
|
661
|
+
|
662
|
+
# Verify execution order
|
663
|
+
expected = [
|
664
|
+
"first:before",
|
665
|
+
"second:before",
|
666
|
+
"third:before",
|
667
|
+
"third:after",
|
668
|
+
"second:after",
|
669
|
+
"first:after"
|
670
|
+
]
|
671
|
+
|
672
|
+
assert_equal expected, call_order
|
673
|
+
end
|
674
|
+
end
|
675
|
+
```
|
676
|
+
|
677
|
+
## Thread Safety
|
678
|
+
|
679
|
+
Middleware should be thread-safe:
|
680
|
+
|
681
|
+
```ruby
|
682
|
+
class ThreadSafeMiddleware < Tsikol::Middleware
|
683
|
+
def initialize(app, options = {})
|
684
|
+
super
|
685
|
+
@mutex = Mutex.new
|
686
|
+
@counter = 0
|
687
|
+
end
|
688
|
+
|
689
|
+
def call(request)
|
690
|
+
# Thread-safe increment
|
691
|
+
@mutex.synchronize { @counter += 1 }
|
692
|
+
|
693
|
+
# Process request
|
694
|
+
response = @app.call(request)
|
695
|
+
|
696
|
+
# Thread-safe operation
|
697
|
+
@mutex.synchronize do
|
698
|
+
# Critical section
|
699
|
+
end
|
700
|
+
|
701
|
+
response
|
702
|
+
end
|
703
|
+
end
|
704
|
+
```
|
705
|
+
|
706
|
+
## Best Practices
|
707
|
+
|
708
|
+
1. **Single Responsibility**: Each middleware should have one clear purpose
|
709
|
+
2. **Minimize Overhead**: Middleware runs on every request, keep it efficient
|
710
|
+
3. **Error Handling**: Always handle errors gracefully, don't swallow exceptions
|
711
|
+
4. **Thread Safety**: Ensure middleware is thread-safe for concurrent requests
|
712
|
+
5. **Configuration**: Make middleware configurable through options
|
713
|
+
6. **Logging**: Use appropriate log levels and don't log sensitive data
|
714
|
+
7. **Testing**: Test middleware in isolation and as part of the stack
|
715
|
+
|
716
|
+
## See Also
|
717
|
+
|
718
|
+
- [Middleware Guide](../guides/middleware.md)
|
719
|
+
- [Server API](server.md)
|
720
|
+
- [Error Handling](../cookbook/error-handling.md)
|
721
|
+
- [Testing Guide](../guides/testing.md)
|