async-http-capture 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/context/getting-started.md +186 -0
- data/context/index.yaml +12 -0
- data/design.md +771 -0
- data/lib/async/http/capture/cassette.rb +69 -0
- data/lib/async/http/capture/cassette_store.rb +47 -0
- data/lib/async/http/capture/console_store.rb +61 -0
- data/lib/async/http/capture/interaction.rb +269 -0
- data/lib/async/http/capture/interaction_tracker.rb +144 -0
- data/lib/async/http/capture/middleware.rb +117 -0
- data/lib/async/http/capture/version.rb +12 -0
- data/lib/async/http/capture.rb +22 -0
- data/license.md +21 -0
- data/readme.md +133 -0
- data/releases.md +5 -0
- metadata +67 -0
data/design.md
ADDED
@@ -0,0 +1,771 @@
|
|
1
|
+
# Async::HTTP::Record Design Document
|
2
|
+
|
3
|
+
## Overview
|
4
|
+
|
5
|
+
The `async-http-record` gem provides pure, immutable components for recording and replaying HTTP requests. Uses Protocol::HTTP::Middleware for recording and simple iteration for replay. This avoids data loss from Rack's header munging and body conversions, keeping everything in the Protocol::HTTP domain.
|
6
|
+
|
7
|
+
## Goals
|
8
|
+
|
9
|
+
- **Pure Components**: Immutable, stateless classes without global configuration
|
10
|
+
- **High Fidelity Recording**: Capture all relevant aspects of HTTP requests and responses using Protocol::HTTP directly
|
11
|
+
- **Protocol::HTTP Native**: Work directly with Protocol::HTTP objects, avoiding Rack's lossy conversions
|
12
|
+
- **Leverage Existing APIs**: Use Protocol::HTTP's native JSON serialization capabilities
|
13
|
+
- **Simple API**: Clear, explicit interfaces without magic or hidden state
|
14
|
+
- **Performance**: Fast loading and replay of cassettes using simple iteration
|
15
|
+
- **Format Focused**: JSON-first approach leveraging Protocol::HTTP's built-in serialization
|
16
|
+
|
17
|
+
## Architecture
|
18
|
+
|
19
|
+
### Core Components
|
20
|
+
|
21
|
+
```
|
22
|
+
┌─────────────────────────────────────────────────────┐
|
23
|
+
│ Async::HTTP::Record │
|
24
|
+
├─────────────────────────────────────────────────────┤
|
25
|
+
│ ┌───────────────────────────────────────────────┐ │
|
26
|
+
│ │ Interaction │ │
|
27
|
+
│ │ (immutable) │ │
|
28
|
+
│ │ - Protocol::HTTP::Request (with body) │ │
|
29
|
+
│ │ - Protocol::HTTP::Response (with body) │ │
|
30
|
+
│ └───────────────────────────────────────────────┘ │
|
31
|
+
├─────────────────────────────────────────────────────┤
|
32
|
+
│ ┌─────────────┐ ┌──────────────────────┐ │
|
33
|
+
│ │ Cassette │ │ Protocol::HTTP:: │ │
|
34
|
+
│ │ (immutable) │ │ Middleware │ │
|
35
|
+
│ └─────────────┘ │ (Recording) │ │
|
36
|
+
│ └──────────────────────┘ │
|
37
|
+
└─────────────────────────────────────────────────────┘
|
38
|
+
│ │ │
|
39
|
+
▼ ▼ ▼
|
40
|
+
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
|
41
|
+
│ JSON Files │ │ Protocol::HTTP │ │ HTTP │
|
42
|
+
│ (cassettes) │ │ Applications │ │ Requests │
|
43
|
+
└──────────────┘ └──────────────────┘ └──────────────┘
|
44
|
+
```
|
45
|
+
|
46
|
+
### Component Responsibilities
|
47
|
+
|
48
|
+
1. **Interaction**: Data container with lazy Protocol::HTTP object construction via `request` and `response` methods
|
49
|
+
2. **Cassette**: Simple collection of interactions with JSON loading/saving - just iterates, no factory methods needed
|
50
|
+
3. **Protocol::HTTP::Middleware (Recording)**: Records live HTTP interactions using Protocol::HTTP::Body::Buffered, with optional response recording
|
51
|
+
|
52
|
+
## API Design
|
53
|
+
|
54
|
+
### Core Classes
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
require "protocol/http/body/buffered"
|
58
|
+
|
59
|
+
# Simple data container with lazy Protocol::HTTP object construction
|
60
|
+
class Async::HTTP::Record::Interaction
|
61
|
+
def initialize(data)
|
62
|
+
@data = data
|
63
|
+
end
|
64
|
+
|
65
|
+
def make_request
|
66
|
+
if request_data = @data[:request]
|
67
|
+
build_request(**request_data)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def make_response
|
72
|
+
if response_data = @data[:response]
|
73
|
+
build_response(**response_data)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def to_h
|
78
|
+
@data
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def build_request(scheme: nil, authority: nil, method:, path:, version: nil, headers: nil, body: nil, protocol: nil)
|
84
|
+
body = Protocol::HTTP::Body::Buffered.wrap(body) if body
|
85
|
+
headers = Protocol::HTTP::Headers[headers] if headers
|
86
|
+
|
87
|
+
Protocol::HTTP::Request.new(
|
88
|
+
scheme,
|
89
|
+
authority,
|
90
|
+
method,
|
91
|
+
path,
|
92
|
+
version,
|
93
|
+
headers,
|
94
|
+
body,
|
95
|
+
protocol
|
96
|
+
)
|
97
|
+
end
|
98
|
+
|
99
|
+
def build_response(version: nil, status:, headers: nil, body: nil, protocol: nil)
|
100
|
+
body = Protocol::HTTP::Body::Buffered.wrap(body) if body
|
101
|
+
headers = Protocol::HTTP::Headers[headers] if headers
|
102
|
+
|
103
|
+
Protocol::HTTP::Response.new(
|
104
|
+
version,
|
105
|
+
status,
|
106
|
+
headers,
|
107
|
+
body,
|
108
|
+
protocol
|
109
|
+
)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
### Cassette Usage
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
# Load recorded interactions and replay them (no middleware needed)
|
118
|
+
cassette = Async::HTTP::Record::Cassette.load("interactions.json")
|
119
|
+
|
120
|
+
# Simple replay: interactions construct Protocol::HTTP objects lazily
|
121
|
+
cassette.each do |interaction|
|
122
|
+
request = interaction.request # Constructs Protocol::HTTP::Request on first access
|
123
|
+
app_response = app.call(request)
|
124
|
+
# Your app handles the request normally (warming up caches, etc.)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Manual cassette creation using data hashes
|
128
|
+
interactions = [
|
129
|
+
Async::HTTP::Record::Interaction.new({
|
130
|
+
request: {
|
131
|
+
method: "GET",
|
132
|
+
path: "/users/123",
|
133
|
+
headers: [["Accept", "application/json"], ["User-Agent", "MyApp/1.0"]]
|
134
|
+
}
|
135
|
+
}),
|
136
|
+
Async::HTTP::Record::Interaction.new({
|
137
|
+
request: {
|
138
|
+
method: "POST",
|
139
|
+
path: "/orders",
|
140
|
+
headers: [["Content-Type", "application/json"]],
|
141
|
+
body: ['{"product_id": 456}']
|
142
|
+
}
|
143
|
+
})
|
144
|
+
]
|
145
|
+
|
146
|
+
cassette = Async::HTTP::Record::Cassette.new(interactions)
|
147
|
+
cassette.save("interactions.json")
|
148
|
+
```
|
149
|
+
|
150
|
+
### Recording Middleware
|
151
|
+
|
152
|
+
Simplified middleware with optional response recording:
|
153
|
+
|
154
|
+
```ruby
|
155
|
+
require "protocol/http/body/rewindable"
|
156
|
+
require "protocol/http/body/completable"
|
157
|
+
require "protocol/http/body/buffered"
|
158
|
+
|
159
|
+
# Protocol::HTTP::Middleware for recording
|
160
|
+
class Async::HTTP::Record::Middleware < Protocol::HTTP::Middleware
|
161
|
+
def initialize(app, cassette_path:, record_response: false, **options)
|
162
|
+
super(app)
|
163
|
+
@cassette_path = cassette_path
|
164
|
+
@record_response = record_response
|
165
|
+
@interactions = []
|
166
|
+
@options = options
|
167
|
+
end
|
168
|
+
|
169
|
+
def call(request)
|
170
|
+
# Capture request body if present
|
171
|
+
captured_request = capture_request_body(request)
|
172
|
+
|
173
|
+
# Get response from downstream middleware/app
|
174
|
+
response = super(captured_request)
|
175
|
+
|
176
|
+
if @record_response
|
177
|
+
# Capture response body if present and record interaction
|
178
|
+
capture_response_and_record(captured_request, response)
|
179
|
+
else
|
180
|
+
# Record request only
|
181
|
+
record_interaction(captured_request)
|
182
|
+
end
|
183
|
+
|
184
|
+
response
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def capture_request_body(request)
|
190
|
+
return request unless request.body && !request.body.empty?
|
191
|
+
|
192
|
+
# Read request body into array of chunks
|
193
|
+
chunks = []
|
194
|
+
request.body.each { |chunk| chunks << chunk }
|
195
|
+
|
196
|
+
# Create new request with buffered body
|
197
|
+
Protocol::HTTP::Request.new(
|
198
|
+
request.method,
|
199
|
+
request.path,
|
200
|
+
request.headers.dup,
|
201
|
+
Protocol::HTTP::Body::Buffered.new(chunks)
|
202
|
+
)
|
203
|
+
end
|
204
|
+
|
205
|
+
def capture_response_and_record(request, response)
|
206
|
+
if response.body && !response.body.empty?
|
207
|
+
# Use rewindable to capture response body
|
208
|
+
rewindable = ::Protocol::HTTP::Body::Rewindable.wrap(response)
|
209
|
+
|
210
|
+
# Use completable to get callback when body is fully read
|
211
|
+
::Protocol::HTTP::Body::Completable.wrap(response) do |error|
|
212
|
+
unless error
|
213
|
+
record_interaction_with_response_body(request, response, rewindable.buffered)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
else
|
217
|
+
# No response body, record immediately
|
218
|
+
record_interaction(request, response)
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def record_interaction_with_response_body(request, original_response, buffered_body)
|
223
|
+
# Create response with captured body
|
224
|
+
response_with_body = Protocol::HTTP::Response.new(
|
225
|
+
original_response.version,
|
226
|
+
original_response.status,
|
227
|
+
original_response.headers.dup,
|
228
|
+
buffered_body
|
229
|
+
)
|
230
|
+
|
231
|
+
record_interaction(request, response_with_body)
|
232
|
+
end
|
233
|
+
|
234
|
+
def record_interaction(request, response = nil)
|
235
|
+
interaction = Async::HTTP::Record::Interaction.new(
|
236
|
+
request: request,
|
237
|
+
response: response
|
238
|
+
)
|
239
|
+
|
240
|
+
@interactions << interaction
|
241
|
+
save_cassette if should_save?
|
242
|
+
end
|
243
|
+
|
244
|
+
def save_cassette
|
245
|
+
cassette = Async::HTTP::Record::Cassette.new(@interactions)
|
246
|
+
cassette.save(@cassette_path)
|
247
|
+
end
|
248
|
+
|
249
|
+
def should_save?
|
250
|
+
@interactions.size >= (@options[:batch_size] || 1)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
```
|
254
|
+
|
255
|
+
### Usage with Async::HTTP
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
# Request-only recording (for warmup scenarios)
|
259
|
+
require "async/http/record"
|
260
|
+
|
261
|
+
endpoint = Async::HTTP::Endpoint.parse("https://api.example.com")
|
262
|
+
|
263
|
+
middleware = [
|
264
|
+
Async::HTTP::Record::Middleware.new(
|
265
|
+
nil, # will be set by client
|
266
|
+
cassette_path: "recordings/warmup.json"
|
267
|
+
# record_response: false is the default
|
268
|
+
)
|
269
|
+
]
|
270
|
+
|
271
|
+
client = Async::HTTP::Client.new(endpoint, middleware: middleware)
|
272
|
+
|
273
|
+
# Make requests - only requests will be recorded
|
274
|
+
Async do
|
275
|
+
response1 = client.get("/users")
|
276
|
+
response2 = client.post("/users", {"name" => "John"})
|
277
|
+
# etc...
|
278
|
+
end
|
279
|
+
|
280
|
+
# Full request/response recording (for testing/mocking scenarios)
|
281
|
+
full_middleware = [
|
282
|
+
Async::HTTP::Record::Middleware.new(
|
283
|
+
nil,
|
284
|
+
cassette_path: "recordings/full_interactions.json",
|
285
|
+
record_response: true,
|
286
|
+
batch_size: 5
|
287
|
+
)
|
288
|
+
]
|
289
|
+
|
290
|
+
full_client = Async::HTTP::Client.new(endpoint, middleware: full_middleware)
|
291
|
+
```
|
292
|
+
|
293
|
+
### Simplified Recording Examples
|
294
|
+
|
295
|
+
```ruby
|
296
|
+
# Default: Request-only recording (perfect for warmup)
|
297
|
+
middleware = Async::HTTP::Record::Middleware.new(
|
298
|
+
nil,
|
299
|
+
cassette_path: "warmup.json"
|
300
|
+
)
|
301
|
+
|
302
|
+
# With response recording enabled
|
303
|
+
middleware = Async::HTTP::Record::Middleware.new(
|
304
|
+
nil,
|
305
|
+
cassette_path: "full_session.json",
|
306
|
+
record_response: true
|
307
|
+
)
|
308
|
+
|
309
|
+
# With batch saving
|
310
|
+
middleware = Async::HTTP::Record::Middleware.new(
|
311
|
+
nil,
|
312
|
+
cassette_path: "batch_session.json",
|
313
|
+
record_response: true,
|
314
|
+
batch_size: 10
|
315
|
+
)
|
316
|
+
```
|
317
|
+
|
318
|
+
### Cassette Implementation
|
319
|
+
|
320
|
+
```ruby
|
321
|
+
class Async::HTTP::Record::Cassette
|
322
|
+
include Enumerable
|
323
|
+
|
324
|
+
attr_reader :interactions
|
325
|
+
|
326
|
+
def initialize(interactions = [])
|
327
|
+
@interactions = interactions.map do |item|
|
328
|
+
case item
|
329
|
+
when Hash
|
330
|
+
Interaction.from_hash(item)
|
331
|
+
when Interaction
|
332
|
+
item
|
333
|
+
else
|
334
|
+
raise ArgumentError, "Invalid interaction: #{item}"
|
335
|
+
end
|
336
|
+
end.freeze
|
337
|
+
freeze
|
338
|
+
end
|
339
|
+
|
340
|
+
# Simple iteration over interactions
|
341
|
+
def each(&block)
|
342
|
+
@interactions.each(&block)
|
343
|
+
end
|
344
|
+
|
345
|
+
def self.load(path)
|
346
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
347
|
+
interactions = data[:interactions].map { |i| Interaction.from_hash(i) }
|
348
|
+
new(interactions)
|
349
|
+
end
|
350
|
+
|
351
|
+
def save(path)
|
352
|
+
data = {
|
353
|
+
version: "1.0",
|
354
|
+
recorded_at: Time.now.iso8601,
|
355
|
+
interactions: interactions.map(&:to_h)
|
356
|
+
}
|
357
|
+
File.write(path, JSON.pretty_generate(data))
|
358
|
+
end
|
359
|
+
end
|
360
|
+
```
|
361
|
+
|
362
|
+
## Data Format
|
363
|
+
|
364
|
+
### Simplified Cassette Structure
|
365
|
+
|
366
|
+
Using Protocol::HTTP::Body::Buffered with arrays of strings:
|
367
|
+
|
368
|
+
```json
|
369
|
+
{
|
370
|
+
"version": "1.0",
|
371
|
+
"recorded_at": "2025-01-27T10:30:00Z",
|
372
|
+
"interactions": [
|
373
|
+
{
|
374
|
+
"request": {
|
375
|
+
"method": "GET",
|
376
|
+
"uri": "/users/123",
|
377
|
+
"headers": [
|
378
|
+
["Accept", "application/json"],
|
379
|
+
["Authorization", "Bearer token123"],
|
380
|
+
["User-Agent", "MyApp/1.0"]
|
381
|
+
],
|
382
|
+
"body": null
|
383
|
+
},
|
384
|
+
"response": {
|
385
|
+
"status": 200,
|
386
|
+
"headers": [
|
387
|
+
["Content-Type", "application/json"],
|
388
|
+
["Content-Length", "28"]
|
389
|
+
],
|
390
|
+
"body": ["{\"id\":123,\"name\":\"John Doe\"}"]
|
391
|
+
}
|
392
|
+
},
|
393
|
+
{
|
394
|
+
"request": {
|
395
|
+
"method": "POST",
|
396
|
+
"uri": "/orders",
|
397
|
+
"headers": [
|
398
|
+
["Content-Type", "application/json"]
|
399
|
+
],
|
400
|
+
"body": ["{\"product_id\": 456, \"quantity\": 2}"]
|
401
|
+
}
|
402
|
+
}
|
403
|
+
]
|
404
|
+
}
|
405
|
+
```
|
406
|
+
|
407
|
+
### Streaming Response Example
|
408
|
+
|
409
|
+
For streaming responses like SSE, chunks are stored as array elements:
|
410
|
+
|
411
|
+
```json
|
412
|
+
{
|
413
|
+
"version": "1.0",
|
414
|
+
"interactions": [
|
415
|
+
{
|
416
|
+
"request": {
|
417
|
+
"method": "GET",
|
418
|
+
"uri": "/events",
|
419
|
+
"headers": [
|
420
|
+
["Accept", "text/event-stream"]
|
421
|
+
],
|
422
|
+
"body": null
|
423
|
+
},
|
424
|
+
"response": {
|
425
|
+
"status": 200,
|
426
|
+
"headers": [
|
427
|
+
["Content-Type", "text/event-stream"],
|
428
|
+
["Cache-Control", "no-cache"]
|
429
|
+
],
|
430
|
+
"body": [
|
431
|
+
"data: {\"event\": \"start\"}\n\n",
|
432
|
+
"data: {\"event\": \"update\", \"value\": 42}\n\n",
|
433
|
+
"data: {\"event\": \"end\"}\n\n"
|
434
|
+
]
|
435
|
+
}
|
436
|
+
}
|
437
|
+
]
|
438
|
+
}
|
439
|
+
```
|
440
|
+
|
441
|
+
### Request-Only Structure for Warmup
|
442
|
+
|
443
|
+
For warmup scenarios where you only need requests:
|
444
|
+
|
445
|
+
```json
|
446
|
+
{
|
447
|
+
"version": "1.0",
|
448
|
+
"interactions": [
|
449
|
+
{
|
450
|
+
"request": {
|
451
|
+
"method": "GET",
|
452
|
+
"uri": "/health",
|
453
|
+
"headers": [],
|
454
|
+
"body": null
|
455
|
+
}
|
456
|
+
},
|
457
|
+
{
|
458
|
+
"request": {
|
459
|
+
"method": "GET",
|
460
|
+
"uri": "/api/popular-items",
|
461
|
+
"headers": [
|
462
|
+
["Accept", "application/json"]
|
463
|
+
],
|
464
|
+
"body": null
|
465
|
+
}
|
466
|
+
},
|
467
|
+
{
|
468
|
+
"request": {
|
469
|
+
"method": "POST",
|
470
|
+
"uri": "/orders",
|
471
|
+
"headers": [
|
472
|
+
["Content-Type", "application/json"]
|
473
|
+
],
|
474
|
+
"body": ["chunk1", "chunk2", "chunk3"]
|
475
|
+
}
|
476
|
+
}
|
477
|
+
]
|
478
|
+
}
|
479
|
+
```
|
480
|
+
|
481
|
+
## Implementation Strategy
|
482
|
+
|
483
|
+
### Phase 1: Core Components
|
484
|
+
- [ ] `Interaction` class as immutable data holder using Protocol::HTTP objects with bodies
|
485
|
+
- [ ] `Cassette` class with JSON serialization and loading
|
486
|
+
|
487
|
+
### Phase 2: Replay Integration
|
488
|
+
- [ ] Simple replay by iterating through interactions and calling `app.call(request)`
|
489
|
+
- [ ] No middleware needed - direct request sending to your application
|
490
|
+
- [ ] Proper error handling during replay for robust warmup scenarios
|
491
|
+
|
492
|
+
### Phase 3: Recording Middleware
|
493
|
+
- [ ] `Protocol::HTTP::Middleware` base class following async-http-cache patterns
|
494
|
+
- [ ] Request body capture using Protocol::HTTP::Body::Buffered (always)
|
495
|
+
- [ ] Optional response body capture using Rewindable + Completable + Buffered wrappers
|
496
|
+
- [ ] `record_response: false` default for warmup scenarios
|
497
|
+
- [ ] Configurable save strategies (batch size, timing)
|
498
|
+
|
499
|
+
### Phase 4: Utilities
|
500
|
+
- [ ] Cassette creation helpers
|
501
|
+
- [ ] Request builders for common patterns
|
502
|
+
- [ ] Validation and error handling
|
503
|
+
|
504
|
+
## Usage Examples
|
505
|
+
|
506
|
+
### Creating a Warmup Cassette
|
507
|
+
|
508
|
+
```ruby
|
509
|
+
# Manual cassette creation using Protocol::HTTP objects
|
510
|
+
interactions = [
|
511
|
+
Async::HTTP::Record::Interaction.new(
|
512
|
+
request: Protocol::HTTP::Request.new("GET", "/health")
|
513
|
+
),
|
514
|
+
Async::HTTP::Record::Interaction.new(
|
515
|
+
request: Protocol::HTTP::Request.new(
|
516
|
+
"GET",
|
517
|
+
"/api/popular-products",
|
518
|
+
Protocol::HTTP::Headers.new([["Accept", "application/json"]])
|
519
|
+
)
|
520
|
+
),
|
521
|
+
Async::HTTP::Record::Interaction.new(
|
522
|
+
request: Protocol::HTTP::Request.new(
|
523
|
+
"POST",
|
524
|
+
"/api/analytics/pageview",
|
525
|
+
Protocol::HTTP::Headers.new([["Content-Type", "application/json"]]),
|
526
|
+
Protocol::HTTP::Body::Buffered.new(['{"page": "/homepage", "user_agent": "warmup"}'])
|
527
|
+
)
|
528
|
+
)
|
529
|
+
]
|
530
|
+
|
531
|
+
cassette = Async::HTTP::Record::Cassette.new(interactions)
|
532
|
+
cassette.save("warmup.json")
|
533
|
+
```
|
534
|
+
|
535
|
+
### Simple Replay Usage
|
536
|
+
|
537
|
+
```ruby
|
538
|
+
# Load recorded interactions and replay them
|
539
|
+
require "async/http/record"
|
540
|
+
|
541
|
+
# Load recorded interactions
|
542
|
+
cassette = Async::HTTP::Record::Cassette.load("interactions.json")
|
543
|
+
|
544
|
+
# Your application
|
545
|
+
app = MyApplication.new
|
546
|
+
|
547
|
+
# Simple replay: interactions construct Protocol::HTTP objects lazily
|
548
|
+
puts "Replaying #{cassette.interactions.size} interactions..."
|
549
|
+
cassette.each do |interaction|
|
550
|
+
request = interaction.request # Constructs Protocol::HTTP::Request on first access
|
551
|
+
puts "Sending #{request.method} #{request.path}"
|
552
|
+
|
553
|
+
begin
|
554
|
+
app_response = app.call(request)
|
555
|
+
puts " -> #{app_response.status}"
|
556
|
+
rescue => error
|
557
|
+
puts " -> Error: #{error.message}"
|
558
|
+
end
|
559
|
+
end
|
560
|
+
```
|
561
|
+
|
562
|
+
### Complete Recording → Replay Workflow
|
563
|
+
|
564
|
+
```ruby
|
565
|
+
# Step 1: Record requests during development/testing
|
566
|
+
require "async/http/record"
|
567
|
+
|
568
|
+
# Set up client with request-only recording (default)
|
569
|
+
endpoint = Async::HTTP::Endpoint.parse("https://api.example.com")
|
570
|
+
recording_middleware = Async::HTTP::Record::Middleware.new(
|
571
|
+
nil,
|
572
|
+
cassette_path: "interactions.json"
|
573
|
+
)
|
574
|
+
|
575
|
+
client = Async::HTTP::Client.new(endpoint, middleware: [recording_middleware])
|
576
|
+
|
577
|
+
# Make the requests you want to record
|
578
|
+
Async do
|
579
|
+
client.get("/health")
|
580
|
+
client.get("/api/popular-items")
|
581
|
+
client.post("/api/user-sessions", {user_id: 123})
|
582
|
+
client.get("/api/recommendations")
|
583
|
+
end
|
584
|
+
|
585
|
+
# Step 2: Use recorded interactions to warm up your application
|
586
|
+
require "async/http/record"
|
587
|
+
|
588
|
+
# Load recorded interactions
|
589
|
+
cassette = Async::HTTP::Record::Cassette.load("interactions.json")
|
590
|
+
|
591
|
+
# Your application
|
592
|
+
app = MyApplication.new
|
593
|
+
|
594
|
+
# Simple warmup: interactions construct Protocol::HTTP objects lazily
|
595
|
+
puts "Warming up with #{cassette.interactions.size} recorded interactions..."
|
596
|
+
cassette.each do |interaction|
|
597
|
+
request = interaction.request # Constructs Protocol::HTTP::Request on first access
|
598
|
+
begin
|
599
|
+
app_response = app.call(request)
|
600
|
+
puts "Warmed up #{request.method} #{request.path} -> #{app_response.status}"
|
601
|
+
rescue => error
|
602
|
+
puts "Warning: #{request.method} #{request.path} -> #{error.message}"
|
603
|
+
end
|
604
|
+
end
|
605
|
+
|
606
|
+
puts "Warmup complete! Starting server..."
|
607
|
+
```
|
608
|
+
|
609
|
+
## Error Handling
|
610
|
+
|
611
|
+
### Simple Error Strategy
|
612
|
+
- Missing cassette file: raise clear error with path
|
613
|
+
- Invalid JSON: raise parsing error with line number
|
614
|
+
- Invalid request data: raise validation error
|
615
|
+
- Keep error messages focused and actionable
|
616
|
+
|
617
|
+
## Testing Strategy
|
618
|
+
|
619
|
+
### Unit Tests
|
620
|
+
```ruby
|
621
|
+
describe Async::HTTP::Record::Interaction do
|
622
|
+
it "is immutable after initialization" do
|
623
|
+
request = Protocol::HTTP::Request.new("GET", "/test")
|
624
|
+
interaction = described_class.new(request: request)
|
625
|
+
|
626
|
+
expect(interaction).to be_frozen
|
627
|
+
expect(interaction.request).to be_frozen
|
628
|
+
end
|
629
|
+
|
630
|
+
it "applies to Rack app safely" do
|
631
|
+
body = Protocol::HTTP::Body::Buffered.new(['{"name": "test"}'])
|
632
|
+
request = Protocol::HTTP::Request.new(
|
633
|
+
"POST",
|
634
|
+
"/users",
|
635
|
+
Protocol::HTTP::Headers.new([["Content-Type", "application/json"]]),
|
636
|
+
body
|
637
|
+
)
|
638
|
+
interaction = described_class.new(request: request)
|
639
|
+
|
640
|
+
# Mock Rack app
|
641
|
+
app = ->(env) do
|
642
|
+
expect(env["REQUEST_METHOD"]).to eq("POST")
|
643
|
+
expect(env["PATH_INFO"]).to eq("/users")
|
644
|
+
expect(env["HTTP_CONTENT_TYPE"]).to eq("application/json")
|
645
|
+
[200, {}, ["OK"]]
|
646
|
+
end
|
647
|
+
|
648
|
+
adapter = Async::HTTP::Record::Rack::InteractionAdapter.new(interaction)
|
649
|
+
status, headers, body = adapter.apply(app)
|
650
|
+
expect(status).to eq(200)
|
651
|
+
end
|
652
|
+
|
653
|
+
it "serializes request with body to hash" do
|
654
|
+
body = Protocol::HTTP::Body::Buffered.new(["chunk1", "chunk2"])
|
655
|
+
request = Protocol::HTTP::Request.new("POST", "/test", nil, body)
|
656
|
+
interaction = described_class.new(request: request)
|
657
|
+
|
658
|
+
hash = interaction.to_h
|
659
|
+
expect(hash[:request][:body]).to eq(["chunk1", "chunk2"])
|
660
|
+
end
|
661
|
+
|
662
|
+
it "deserializes from hash with body" do
|
663
|
+
hash = {
|
664
|
+
"request" => {
|
665
|
+
"method" => "POST",
|
666
|
+
"uri" => "/test",
|
667
|
+
"headers" => [["Content-Type", "application/json"]],
|
668
|
+
"body" => ["chunk1", "chunk2"]
|
669
|
+
}
|
670
|
+
}
|
671
|
+
|
672
|
+
interaction = described_class.from_hash(hash)
|
673
|
+
expect(interaction.request.method).to eq("POST")
|
674
|
+
expect(interaction.request.body).to be_a(Protocol::HTTP::Body::Buffered)
|
675
|
+
expect(interaction.request.body.chunks).to eq(["chunk1", "chunk2"])
|
676
|
+
end
|
677
|
+
end
|
678
|
+
|
679
|
+
describe Async::HTTP::Record::Cassette do
|
680
|
+
it "loads from JSON file" do
|
681
|
+
cassette = described_class.load("fixtures/sample.json")
|
682
|
+
expect(cassette.interactions.size).to eq(2)
|
683
|
+
end
|
684
|
+
|
685
|
+
it "saves to JSON file" do
|
686
|
+
request = Protocol::HTTP::Request.new("GET", "/")
|
687
|
+
interactions = [described_class::Interaction.new(request: request)]
|
688
|
+
cassette = described_class.new(interactions)
|
689
|
+
|
690
|
+
cassette.save("tmp/test.json")
|
691
|
+
loaded = described_class.load("tmp/test.json")
|
692
|
+
|
693
|
+
expect(loaded.interactions.first.request.path).to eq("/")
|
694
|
+
end
|
695
|
+
|
696
|
+
it "handles interactions with bodies" do
|
697
|
+
body = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])
|
698
|
+
request = Protocol::HTTP::Request.new("POST", "/test", nil, body)
|
699
|
+
interactions = [described_class::Interaction.new(request: request)]
|
700
|
+
cassette = described_class.new(interactions)
|
701
|
+
|
702
|
+
cassette.save("tmp/test_body.json")
|
703
|
+
loaded = described_class.load("tmp/test_body.json")
|
704
|
+
|
705
|
+
expect(loaded.interactions.first.request.body.chunks).to eq(["Hello", " ", "World"])
|
706
|
+
end
|
707
|
+
end
|
708
|
+
```
|
709
|
+
|
710
|
+
## Key Design Decisions
|
711
|
+
|
712
|
+
### 1. Immutable Components
|
713
|
+
All classes are frozen after initialization to prevent accidental mutations and ensure thread safety.
|
714
|
+
|
715
|
+
### 2. Lazy Object Construction
|
716
|
+
`Interaction` stores data and constructs Protocol::HTTP objects lazily via `request` and `response` methods, using `||=` for caching.
|
717
|
+
|
718
|
+
### 3. Leverages Protocol::HTTP APIs
|
719
|
+
Uses `Protocol::HTTP::Body::Buffered.wrap()` and `Protocol::HTTP::Headers[]` for proper object construction following library conventions.
|
720
|
+
|
721
|
+
### 4. Protocol::HTTP::Body Pattern
|
722
|
+
Following async-http-cache's proven approach:
|
723
|
+
- Use `Protocol::HTTP::Body::Rewindable` to wrap responses for chunk capture
|
724
|
+
- Use `Protocol::HTTP::Body::Completable` for completion callbacks
|
725
|
+
- Use `Protocol::HTTP::Body::Buffered` for final storage as arrays of strings
|
726
|
+
|
727
|
+
### 5. Simple Replay Pattern
|
728
|
+
No middleware needed for replay - just iterate through recorded interactions and call `app.call(request)` to warm up your application directly.
|
729
|
+
|
730
|
+
### 6. Optional Response Recording
|
731
|
+
Responses are not recorded by default (`record_response: false`) since many use cases only need request recording for testing or mocking.
|
732
|
+
|
733
|
+
### 7. JSON-Only Storage
|
734
|
+
Simple, human-readable format that's easy to inspect and version control.
|
735
|
+
|
736
|
+
### 8. No Global State
|
737
|
+
All components are explicit about their dependencies and configuration.
|
738
|
+
|
739
|
+
---
|
740
|
+
|
741
|
+
## Questions for Implementation
|
742
|
+
|
743
|
+
1. **URI Handling**: Should we normalize URIs (trailing slashes, query param order) or keep them exactly as provided?
|
744
|
+
|
745
|
+
2. **Header Case**: Should we normalize header names to lowercase or preserve original casing? Protocol-rack preserves case.
|
746
|
+
|
747
|
+
3. **RackInput Interface**: Should we implement the full IO interface or just the minimum required methods (read, gets, each, rewind)?
|
748
|
+
|
749
|
+
4. **Authority Parsing**: How should we handle malformed authority strings in request.authority?
|
750
|
+
|
751
|
+
5. **Content-Length**: Should we always calculate and set CONTENT_LENGTH from body.length, or only when present in headers?
|
752
|
+
|
753
|
+
6. **Memory Efficiency**: For large streaming responses during recording, should we consider streaming-to-disk?
|
754
|
+
|
755
|
+
7. **Save Strategy**: Default to save-after-each-request or batch saves? What's the right balance for the recording use case?
|
756
|
+
|
757
|
+
8. **Protocol-Rack Dependency**: Should we depend on protocol-rack directly or just follow its patterns?
|
758
|
+
|
759
|
+
## Complete Workflow
|
760
|
+
|
761
|
+
This design enables a clean workflow:
|
762
|
+
|
763
|
+
1. **Recording**: Use `Middleware` with default `record_response: false` to capture requests during development/testing
|
764
|
+
2. **Replay**: Simple iteration - `cassette.each { |interaction| app.call(interaction.request) }` with lazy Protocol::HTTP object construction
|
765
|
+
3. **Lazy Construction**: `Interaction` stores data and builds Protocol::HTTP objects on first access via `request`/`response` methods
|
766
|
+
4. **Leverages Existing APIs**: Uses `Protocol::HTTP::Body::Buffered.wrap()` and standard Protocol::HTTP constructors
|
767
|
+
5. **Direct Protocol::HTTP**: No lossy conversions, keeps everything in Protocol::HTTP domain
|
768
|
+
6. **Optional Responses**: Only record what you need - requests by default, responses when needed
|
769
|
+
7. **No Global State**: Everything is explicit and configurable per-instance
|
770
|
+
|
771
|
+
The design is dramatically simplified while maintaining the pure, functional approach and leveraging Protocol::HTTP's native capabilities throughout the recording and replay pipeline.
|