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.
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.