Skip to content

Commit d5ba897

Browse files
Rename Recorder -> Capture.
1 parent e27bf46 commit d5ba897

21 files changed

+597
-281
lines changed

async-http-recorder.gemspec renamed to async-http-capture.gemspec

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
# frozen_string_literal: true
22

3-
require_relative "lib/async/http/recorder/version"
3+
require_relative "lib/async/http/capture/version"
44

55
Gem::Specification.new do |spec|
66
spec.name = "async-http"
7-
spec.version = Async::HTTP::Recorder::VERSION
7+
spec.version = Async::HTTP::Capture::VERSION
88

9-
spec.summary = "A HTTP request and response recorder."
9+
spec.summary = "A HTTP request and response capture."
1010
spec.authors = ["Samuel Williams"]
1111
spec.license = "MIT"
1212

1313
spec.homepage = "https://github.com/socketry/async-http"
1414

1515
spec.metadata = {
16-
"documentation_uri" => "https://socketry.github.io/async-http-recorder/",
17-
"source_code_uri" => "https://github.com/socketry/async-http-recorder.git",
16+
"documentation_uri" => "https://socketry.github.io/async-http-capture/",
17+
"source_code_uri" => "https://github.com/socketry/async-http-capture.git",
1818
}
1919

2020
spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__)

lib/async/http/capture.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require_relative "capture/version"
7+
require_relative "capture/interaction"
8+
require_relative "capture/cassette"
9+
require_relative "capture/cassette_store"
10+
require_relative "capture/console_store"
11+
require_relative "capture/interaction_tracker"
12+
require_relative "capture/middleware"
13+
14+
module Async
15+
module HTTP
16+
# @namespace
17+
module Capture
18+
end
19+
end
20+
end

lib/async/http/recorder/cassette.rb renamed to lib/async/http/capture/cassette.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
module Async
1111
module HTTP
12-
module Recorder
12+
module Capture
1313
# Represents a collection of HTTP interactions using content-addressed storage.
1414
#
1515
# A cassette serves as a container for multiple {Interaction} objects, storing each

lib/async/http/recorder/cassette_store.rb renamed to lib/async/http/capture/cassette_store.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
module Async
1111
module HTTP
12-
module Recorder
12+
module Capture
1313
# Store implementation that saves interactions to content-addressed files in a directory.
1414
#
1515
# Each interaction is saved as a separate JSON file named by its content hash,

lib/async/http/recorder/console_store.rb renamed to lib/async/http/capture/console_store.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
module Async
1010
module HTTP
11-
module Recorder
11+
module Capture
1212
# Store implementation that logs interactions to the console.
1313
#
1414
# This store outputs complete interaction data via the Console gem,

lib/async/http/recorder/interaction.rb renamed to lib/async/http/capture/interaction.rb

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
module Async
1414
module HTTP
15-
module Recorder
15+
module Capture
1616
# Represents a single HTTP interaction containing request and optional response data.
1717
#
1818
# This class serves as a simple data container that stores the raw interaction data
@@ -115,7 +115,7 @@ def serialize_request(request)
115115
end
116116

117117
# Add body chunks if present:
118-
if request.body && request.body.is_a?(Protocol::HTTP::Body::Buffered)
118+
if request.body && request.body.is_a?(::Protocol::HTTP::Body::Buffered)
119119
data[:body] = request.body.chunks
120120
end
121121

@@ -141,7 +141,7 @@ def serialize_response(response)
141141
end
142142

143143
# Add body chunks if present:
144-
if response.body && response.body.is_a?(Protocol::HTTP::Body::Buffered)
144+
if response.body && response.body.is_a?(::Protocol::HTTP::Body::Buffered)
145145
data[:body] = response.body.chunks
146146
end
147147

@@ -175,10 +175,10 @@ def make_response
175175
# @parameter protocol [String | Array | Nil] The protocol information.
176176
# @returns [Protocol::HTTP::Request] The constructed request object.
177177
def build_request(scheme: nil, authority: nil, method:, path:, version: nil, headers: nil, body: nil, protocol: nil)
178-
body = Protocol::HTTP::Body::Buffered.wrap(body) if body
178+
body = ::Protocol::HTTP::Body::Buffered.wrap(body) if body
179179
headers = build_headers(headers) if headers
180180

181-
Protocol::HTTP::Request.new(
181+
::Protocol::HTTP::Request.new(
182182
scheme,
183183
authority,
184184
method,
@@ -198,10 +198,10 @@ def build_request(scheme: nil, authority: nil, method:, path:, version: nil, hea
198198
# @parameter protocol [String | Array | Nil] The protocol information.
199199
# @returns [Protocol::HTTP::Response] The constructed response object.
200200
def build_response(version: nil, status:, headers: nil, body: nil, protocol: nil)
201-
body = Protocol::HTTP::Body::Buffered.wrap(body) if body
201+
body = ::Protocol::HTTP::Body::Buffered.wrap(body) if body
202202
headers = build_headers(headers) if headers
203203

204-
Protocol::HTTP::Response.new(
204+
::Protocol::HTTP::Response.new(
205205
version,
206206
status,
207207
headers,
@@ -220,16 +220,16 @@ def build_headers(headers_data)
220220
if headers_data.key?(:fields) || headers_data.key?("fields")
221221
fields = headers_data[:fields] || headers_data["fields"]
222222
tail = headers_data[:tail] || headers_data["tail"]
223-
Protocol::HTTP::Headers.new(fields, tail)
223+
::Protocol::HTTP::Headers.new(fields, tail)
224224
else
225225
# Simple hash format:
226-
Protocol::HTTP::Headers[headers_data]
226+
::Protocol::HTTP::Headers[headers_data]
227227
end
228228
when Array
229229
# Array format of [name, value] pairs:
230-
Protocol::HTTP::Headers[headers_data]
230+
::Protocol::HTTP::Headers[headers_data]
231231
else
232-
Protocol::HTTP::Headers[headers_data]
232+
::Protocol::HTTP::Headers[headers_data]
233233
end
234234
end
235235
end
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "async/clock"
7+
require "protocol/http/request"
8+
require "protocol/http/response"
9+
10+
require_relative "interaction"
11+
12+
module Async
13+
module HTTP
14+
module Capture
15+
# Tracks the completion of both request and response bodies for a single interaction. Records the interaction only when both sides are complete, capturing rich error context.
16+
class InteractionTracker
17+
# Initialize the tracker.
18+
# @parameter store [Object] The store to record the interaction to.
19+
# @parameter original_request [Protocol::HTTP::Request] The original request.
20+
def initialize(store, original_request)
21+
@store = store
22+
@original_request = original_request
23+
@original_response = nil
24+
@request_complete = false
25+
@response_complete = false
26+
@request_body = nil
27+
@response_body = nil
28+
@error = nil
29+
@clock = Async::Clock.start
30+
end
31+
32+
# Mark the request as ready (no body to process).
33+
# @parameter request [Protocol::HTTP::Request] The request.
34+
# @returns [Protocol::HTTP::Request] The same request.
35+
def mark_request_ready(request)
36+
@request_complete = true
37+
check_completion
38+
request
39+
end
40+
41+
# Mark the response as ready (no body to process).
42+
# @parameter response [Protocol::HTTP::Response] The response.
43+
# @returns [Protocol::HTTP::Response] The same response.
44+
def mark_response_ready(response)
45+
@original_response = response
46+
@response_complete = true
47+
check_completion
48+
response
49+
end
50+
51+
# Called when request body processing completes.
52+
# @parameter body [Protocol::HTTP::Body::Buffered | Nil] The captured body.
53+
# @parameter error [Exception | Nil] Any error that occurred.
54+
def request_completed(body: nil, error: nil)
55+
@request_complete = true
56+
@request_body = body
57+
58+
if error
59+
@error = capture_error_context(error, :request_body)
60+
end
61+
62+
check_completion
63+
end
64+
65+
# Called when response body processing completes.
66+
# @parameter body [Protocol::HTTP::Body::Buffered | Nil] The captured body.
67+
# @parameter error [Exception | Nil] Any error that occurred.
68+
def response_completed(body: nil, error: nil)
69+
@response_complete = true
70+
@response_body = body
71+
72+
if error
73+
@error = capture_error_context(error, :response_body)
74+
end
75+
76+
check_completion
77+
end
78+
79+
# Set the response for this interaction.
80+
# @parameter response [Protocol::HTTP::Response] The response to associate.
81+
def set_response(response)
82+
@original_response = response
83+
end
84+
85+
private
86+
87+
# Capture raw error context for post-processing analysis.
88+
# @parameter error [Exception] The error that occurred.
89+
# @parameter phase [Symbol] The phase where the error occurred.
90+
# @returns [Hash] Raw error context data for external analysis.
91+
def capture_error_context(error, phase)
92+
{
93+
error_type: error.class.name,
94+
error_message: error.message,
95+
phase: phase,
96+
elapsed_ms: (@clock.total * 1000).round(2),
97+
timestamp: Time.now.iso8601
98+
}
99+
end
100+
101+
# Check if both request and response are complete, and record if so.
102+
def check_completion
103+
return unless @request_complete && @response_complete
104+
105+
# Create final request with buffered body:
106+
final_request = ::Protocol::HTTP::Request.new(
107+
@original_request.scheme,
108+
@original_request.authority,
109+
@original_request.method,
110+
@original_request.path,
111+
@original_request.version,
112+
@original_request.headers,
113+
@request_body,
114+
@original_request.protocol
115+
)
116+
117+
# Create final response with buffered body:
118+
final_response = nil
119+
if @original_response
120+
final_response = ::Protocol::HTTP::Response.new(
121+
@original_response.version,
122+
@original_response.status,
123+
@original_response.headers,
124+
@response_body,
125+
@original_response.protocol
126+
)
127+
end
128+
129+
# Create and record the interaction:
130+
interaction_data = {}
131+
interaction_data[:error] = @error if @error
132+
133+
interaction = Interaction.new(
134+
interaction_data,
135+
request: final_request,
136+
response: final_response
137+
)
138+
139+
@store.call(interaction)
140+
end
141+
end
142+
end
143+
end
144+
end

lib/async/http/capture/middleware.rb

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "protocol/http/middleware"
7+
require "protocol/http/body/rewindable"
8+
require "protocol/http/body/completable"
9+
10+
require_relative "interaction_tracker"
11+
12+
module Async
13+
module HTTP
14+
module Capture
15+
# Protocol::HTTP::Middleware for recording complete HTTP interactions.
16+
#
17+
# This middleware captures both HTTP requests and responses, waiting for both
18+
# to be fully processed before recording the complete interaction.
19+
class Middleware < Protocol::HTTP::Middleware
20+
# Initialize the recording middleware.
21+
# @parameter app [Protocol::HTTP::Middleware] The next middleware in the chain.
22+
# @parameter store [Object] An object that responds to #call(interaction) to handle recorded interactions.
23+
def initialize(app, store:)
24+
super(app)
25+
@store = store
26+
end
27+
28+
# Process an HTTP request, capturing both request and response.
29+
# @parameter request [Protocol::HTTP::Request] The incoming HTTP request.
30+
# @returns [Protocol::HTTP::Response] The response from the next middleware.
31+
def call(request)
32+
# Create completion tracker for this interaction:
33+
tracker = create_interaction_tracker(request)
34+
35+
# Capture request body with completion tracking:
36+
captured_request = capture_request_with_completion(request, tracker)
37+
38+
# Get response from downstream middleware/app:
39+
response = super(captured_request)
40+
41+
# Capture response body with completion tracking:
42+
capture_response_with_completion(captured_request, response, tracker)
43+
end
44+
45+
private
46+
47+
# Create a completion tracker for a single interaction.
48+
# @parameter request [Protocol::HTTP::Request] The original request.
49+
# @returns [InteractionTracker] A new tracker instance.
50+
def create_interaction_tracker(request)
51+
InteractionTracker.new(@store, request)
52+
end
53+
54+
# Capture the request body with completion tracking.
55+
# @parameter request [Protocol::HTTP::Request] The original request.
56+
# @parameter tracker [InteractionTracker] The completion tracker.
57+
# @returns [Protocol::HTTP::Request] A request with rewindable body.
58+
def capture_request_with_completion(request, tracker)
59+
return tracker.mark_request_ready(request) unless request.body && !request.body.empty?
60+
61+
# Make the request body rewindable:
62+
rewindable_body = ::Protocol::HTTP::Body::Rewindable.wrap(request)
63+
64+
# Wrap with completion callback:
65+
::Protocol::HTTP::Body::Completable.wrap(request) do |error|
66+
if error
67+
tracker.request_completed(error: error)
68+
else
69+
tracker.request_completed(body: rewindable_body.buffered)
70+
end
71+
end
72+
73+
return request
74+
end
75+
76+
# Capture the response body with completion tracking.
77+
# @parameter request [Protocol::HTTP::Request] The captured request.
78+
# @parameter response [Protocol::HTTP::Response] The response to capture.
79+
# @parameter tracker [InteractionTracker] The completion tracker.
80+
# @returns [Protocol::HTTP::Response] The wrapped response.
81+
def capture_response_with_completion(request, response, tracker)
82+
# Set the response on the tracker:
83+
tracker.set_response(response)
84+
85+
return tracker.mark_response_ready(response) unless response.body && !response.body.empty?
86+
87+
# Make the response body rewindable:
88+
rewindable_body = ::Protocol::HTTP::Body::Rewindable.wrap(response)
89+
90+
# Wrap with completion callback:
91+
::Protocol::HTTP::Body::Completable.wrap(response) do |error|
92+
if error
93+
tracker.response_completed(error: error)
94+
else
95+
tracker.response_completed(body: rewindable_body.buffered)
96+
end
97+
end
98+
99+
return response
100+
end
101+
end
102+
end
103+
end
104+
end

0 commit comments

Comments
 (0)