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
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
5
|
+
|
6
|
+
require "json"
|
7
|
+
require "time"
|
8
|
+
require "fileutils"
|
9
|
+
|
10
|
+
module Async
|
11
|
+
module HTTP
|
12
|
+
module Capture
|
13
|
+
# Represents a collection of HTTP interactions using content-addressed storage.
|
14
|
+
#
|
15
|
+
# A cassette serves as a container for multiple {Interaction} objects, storing each
|
16
|
+
# interaction as a separate JSON file in a directory. Files are named using the
|
17
|
+
# content hash of the interaction, providing automatic de-duplication and
|
18
|
+
# parallel-safe recording.
|
19
|
+
class Cassette
|
20
|
+
include Enumerable
|
21
|
+
|
22
|
+
# @attribute [Array(Interaction)] The collection of interactions.
|
23
|
+
attr_reader :interactions
|
24
|
+
|
25
|
+
# Initialize a new cassette with the provided interactions.
|
26
|
+
# @parameter interactions [Array(Interaction)] The interactions to include in the cassette.
|
27
|
+
def initialize(interactions = [])
|
28
|
+
@interactions = interactions
|
29
|
+
end
|
30
|
+
|
31
|
+
# Iterate over each interaction in the cassette.
|
32
|
+
# @yields {|interaction| ...} Each interaction in the cassette.
|
33
|
+
# @parameter interaction [Interaction] The current interaction being yielded.
|
34
|
+
def each(&block)
|
35
|
+
@interactions.each(&block)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Load a cassette from a directory of JSON interaction files.
|
39
|
+
# @parameter directory_path [String] The path to the directory containing JSON interaction files.
|
40
|
+
# @returns [Cassette] A new cassette instance with the loaded interactions.
|
41
|
+
# @raises [JSON::ParserError] If any file contains invalid JSON.
|
42
|
+
def self.load(directory_path)
|
43
|
+
return new([]) unless File.directory?(directory_path)
|
44
|
+
|
45
|
+
json_files = Dir.glob(File.join(directory_path, "*.json"))
|
46
|
+
interactions = json_files.map do |file_path|
|
47
|
+
data = JSON.parse(File.read(file_path), symbolize_names: true)
|
48
|
+
Interaction.new(data)
|
49
|
+
end
|
50
|
+
new(interactions)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Save the cassette to a directory using content-addressed storage.
|
54
|
+
# Each interaction is saved as a separate JSON file named by its content hash.
|
55
|
+
# This approach provides de-duplication and parallel-safe recording.
|
56
|
+
# @parameter directory_path [String] The path to the directory where interactions should be saved.
|
57
|
+
def save(directory_path)
|
58
|
+
FileUtils.mkdir_p(directory_path)
|
59
|
+
|
60
|
+
@interactions.each do |interaction|
|
61
|
+
filename = "#{interaction.content_hash}.json"
|
62
|
+
file_path = File.join(directory_path, filename)
|
63
|
+
File.write(file_path, JSON.pretty_generate(interaction.to_h))
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
5
|
+
|
6
|
+
require "fileutils"
|
7
|
+
require "json"
|
8
|
+
require "time"
|
9
|
+
|
10
|
+
require_relative "cassette"
|
11
|
+
|
12
|
+
module Async
|
13
|
+
module HTTP
|
14
|
+
module Capture
|
15
|
+
# Store implementation that saves interactions to content-addressed files in a directory.
|
16
|
+
#
|
17
|
+
# Each interaction is saved as a separate JSON file named by its content hash,
|
18
|
+
# providing automatic de-duplication and parallel-safe recording.
|
19
|
+
class CassetteStore
|
20
|
+
# Initialize the cassette store.
|
21
|
+
# @parameter directory_path [String] The directory path where interactions should be saved.
|
22
|
+
def initialize(directory_path)
|
23
|
+
@directory_path = directory_path
|
24
|
+
end
|
25
|
+
|
26
|
+
# @returns [Cassette] A cassette object representing the recorded interactions.
|
27
|
+
def cassette
|
28
|
+
Cassette.load(@directory_path)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Save an interaction to a content-addressed file with timestamp prefix.
|
32
|
+
# @parameter interaction [Interaction] The interaction to save.
|
33
|
+
def call(interaction)
|
34
|
+
FileUtils.mkdir_p(@directory_path)
|
35
|
+
|
36
|
+
# Create filename with timestamp prefix for chronological ordering:
|
37
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S-%6N") # Include microseconds
|
38
|
+
content_hash = interaction.content_hash
|
39
|
+
filename = "#{timestamp}-#{content_hash}.json"
|
40
|
+
file_path = File.join(@directory_path, filename)
|
41
|
+
|
42
|
+
File.write(file_path, JSON.pretty_generate(interaction.serialize))
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
5
|
+
|
6
|
+
require "console"
|
7
|
+
require "json"
|
8
|
+
|
9
|
+
module Async
|
10
|
+
module HTTP
|
11
|
+
module Capture
|
12
|
+
# Store implementation that logs interactions to the console.
|
13
|
+
#
|
14
|
+
# This store outputs complete interaction data via the Console gem,
|
15
|
+
# particularly useful for debugging network errors and monitoring HTTP traffic.
|
16
|
+
class ConsoleStore
|
17
|
+
|
18
|
+
# Initialize the console store.
|
19
|
+
# @parameter logger [Console::Logger] The logger to use (defaults to Console).
|
20
|
+
def initialize(logger: Console)
|
21
|
+
@logger = logger
|
22
|
+
end
|
23
|
+
|
24
|
+
# Log an interaction to the console with full fidelity.
|
25
|
+
# The output includes complete interaction data to enable debugging and reconstruction.
|
26
|
+
# @parameter interaction [Interaction] The interaction to log.
|
27
|
+
def call(interaction)
|
28
|
+
# Let the interaction serialize itself:
|
29
|
+
serializable_data = interaction.serialize
|
30
|
+
|
31
|
+
request_info = extract_request_info(serializable_data)
|
32
|
+
|
33
|
+
if serializable_data[:error]
|
34
|
+
# Log errors with full interaction data:
|
35
|
+
@logger.error(self, "HTTP Error: #{request_info}") {JSON.pretty_generate(serializable_data)}
|
36
|
+
elsif serializable_data[:response]
|
37
|
+
# Log successful interactions with summary + full data:
|
38
|
+
status = serializable_data[:response][:status]
|
39
|
+
@logger.info(self, "#{request_info} -> #{status}") {JSON.pretty_generate(serializable_data)}
|
40
|
+
else
|
41
|
+
# Log request-only interactions:
|
42
|
+
@logger.debug(self, "Recorded: #{request_info}") {JSON.pretty_generate(serializable_data)}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Extract request info for summary logging.
|
49
|
+
# @parameter data [Hash] The serialized interaction data.
|
50
|
+
# @returns [String] A summary of the request.
|
51
|
+
def extract_request_info(data)
|
52
|
+
return "Unknown request" unless data[:request]
|
53
|
+
|
54
|
+
method = data[:request][:method] || "UNKNOWN"
|
55
|
+
path = data[:request][:path] || "/"
|
56
|
+
"#{method} #{path}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,269 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
5
|
+
|
6
|
+
require "digest/sha2"
|
7
|
+
require "json"
|
8
|
+
require "protocol/http/request"
|
9
|
+
require "protocol/http/response"
|
10
|
+
require "protocol/http/headers"
|
11
|
+
require "protocol/http/body/buffered"
|
12
|
+
|
13
|
+
module Async
|
14
|
+
module HTTP
|
15
|
+
module Capture
|
16
|
+
# Represents a single HTTP interaction containing request and optional response data.
|
17
|
+
#
|
18
|
+
# This class serves as a simple data container that stores the raw interaction data
|
19
|
+
# and provides factory methods to construct {Protocol::HTTP::Request} and
|
20
|
+
# {Protocol::HTTP::Response} objects on demand. May also contain error information
|
21
|
+
# if the interaction failed.
|
22
|
+
class Interaction
|
23
|
+
# Initialize a new interaction with the provided data.
|
24
|
+
# @parameter data [Hash] The interaction data containing serialized request and optional response information.
|
25
|
+
# @parameter request [Protocol::HTTP::Request | Nil] A pre-constructed request object.
|
26
|
+
# @parameter response [Protocol::HTTP::Response | Nil] A pre-constructed response object.
|
27
|
+
def initialize(data, request: nil, response: nil)
|
28
|
+
@data = data
|
29
|
+
@request = request
|
30
|
+
@response = response
|
31
|
+
end
|
32
|
+
|
33
|
+
# Get the Protocol::HTTP::Request object, constructing it lazily if not already provided.
|
34
|
+
# @returns [Protocol::HTTP::Request | Nil] The request object, or nil if no request data is present.
|
35
|
+
def request
|
36
|
+
@request ||= make_request
|
37
|
+
end
|
38
|
+
|
39
|
+
# Get the Protocol::HTTP::Response object, constructing it lazily if not already provided.
|
40
|
+
# @returns [Protocol::HTTP::Response | Nil] The response object, or nil if no response data is present.
|
41
|
+
def response
|
42
|
+
@response ||= make_response
|
43
|
+
end
|
44
|
+
|
45
|
+
# Get any error that occurred during the interaction.
|
46
|
+
# @returns [Exception | String | Nil] The error information, or nil if no error occurred.
|
47
|
+
def error
|
48
|
+
@data[:error]
|
49
|
+
end
|
50
|
+
|
51
|
+
# Convert the interaction to a hash representation.
|
52
|
+
# @returns [Hash] The raw interaction data.
|
53
|
+
def to_h
|
54
|
+
@data
|
55
|
+
end
|
56
|
+
|
57
|
+
# Create an interaction from a hash of data.
|
58
|
+
# @parameter hash [Hash] The interaction data hash.
|
59
|
+
# @returns [Interaction] A new interaction instance.
|
60
|
+
def self.from_hash(hash)
|
61
|
+
new(hash)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Generate a content-addressed hash for this interaction.
|
65
|
+
# This hash can be used as a unique filename for content-addressed storage.
|
66
|
+
# @returns [String] A 16-character hexadecimal hash of the interaction content.
|
67
|
+
def content_hash
|
68
|
+
# Create a consistent JSON representation for hashing:
|
69
|
+
json_string = JSON.generate(serialize, sort_keys: true)
|
70
|
+
Digest::SHA256.hexdigest(json_string)[0, 16]
|
71
|
+
end
|
72
|
+
|
73
|
+
# Serialize the interaction to a data format suitable for storage or hashing.
|
74
|
+
# Converts Protocol::HTTP objects to plain data structures.
|
75
|
+
# @returns [Hash] The serialized interaction data.
|
76
|
+
def serialize
|
77
|
+
data = @data.dup
|
78
|
+
|
79
|
+
if request_obj = self.request
|
80
|
+
data[:request] = serialize_request(request_obj)
|
81
|
+
end
|
82
|
+
|
83
|
+
if response_obj = self.response
|
84
|
+
data[:response] = serialize_response(response_obj)
|
85
|
+
end
|
86
|
+
|
87
|
+
if error = self.error
|
88
|
+
data[:error] = error.is_a?(Exception) ? error.message : error.to_s
|
89
|
+
end
|
90
|
+
|
91
|
+
data
|
92
|
+
end
|
93
|
+
|
94
|
+
# Provide a reasonable string representation of this interaction.
|
95
|
+
# @returns [String] A human-readable description of the interaction.
|
96
|
+
def to_s
|
97
|
+
parts = []
|
98
|
+
|
99
|
+
# Add request information
|
100
|
+
if request_data = @data[:request]
|
101
|
+
method = request_data[:method] || "?"
|
102
|
+
path = request_data[:path] || "/"
|
103
|
+
parts << "#{method} #{path}"
|
104
|
+
end
|
105
|
+
|
106
|
+
# Add response status if available
|
107
|
+
if response_data = @data[:response]
|
108
|
+
status = response_data[:status]
|
109
|
+
parts << "-> #{status}" if status
|
110
|
+
end
|
111
|
+
|
112
|
+
# Add error if present
|
113
|
+
if error_data = @data[:error]
|
114
|
+
parts << "(ERROR: #{error_data})"
|
115
|
+
end
|
116
|
+
|
117
|
+
# Add timestamp if available
|
118
|
+
if timestamp = @data[:timestamp]
|
119
|
+
parts << "[#{timestamp}]"
|
120
|
+
end
|
121
|
+
|
122
|
+
parts.join(" ")
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
# Serialize a Protocol::HTTP::Request to a hash.
|
128
|
+
# @parameter request [Protocol::HTTP::Request] The request to serialize.
|
129
|
+
# @returns [Hash] The serialized request data.
|
130
|
+
def serialize_request(request)
|
131
|
+
data = {
|
132
|
+
scheme: request.scheme,
|
133
|
+
authority: request.authority,
|
134
|
+
method: request.method,
|
135
|
+
path: request.path,
|
136
|
+
version: request.version,
|
137
|
+
protocol: request.protocol
|
138
|
+
}
|
139
|
+
|
140
|
+
# Add headers if present:
|
141
|
+
if request.headers && !request.headers.empty?
|
142
|
+
data[:headers] = {
|
143
|
+
fields: request.headers.fields,
|
144
|
+
tail: request.headers.tail
|
145
|
+
}
|
146
|
+
end
|
147
|
+
|
148
|
+
# Add body chunks if present:
|
149
|
+
if request.body && request.body.is_a?(::Protocol::HTTP::Body::Buffered)
|
150
|
+
data[:body] = request.body.chunks
|
151
|
+
end
|
152
|
+
|
153
|
+
data
|
154
|
+
end
|
155
|
+
|
156
|
+
# Serialize a Protocol::HTTP::Response to a hash.
|
157
|
+
# @parameter response [Protocol::HTTP::Response] The response to serialize.
|
158
|
+
# @returns [Hash] The serialized response data.
|
159
|
+
def serialize_response(response)
|
160
|
+
data = {
|
161
|
+
version: response.version,
|
162
|
+
status: response.status,
|
163
|
+
protocol: response.protocol
|
164
|
+
}
|
165
|
+
|
166
|
+
# Add headers if present:
|
167
|
+
if response.headers && !response.headers.empty?
|
168
|
+
data[:headers] = {
|
169
|
+
fields: response.headers.fields,
|
170
|
+
tail: response.headers.tail
|
171
|
+
}
|
172
|
+
end
|
173
|
+
|
174
|
+
# Add body chunks if present:
|
175
|
+
if response.body && response.body.is_a?(::Protocol::HTTP::Body::Buffered)
|
176
|
+
data[:body] = response.body.chunks
|
177
|
+
end
|
178
|
+
|
179
|
+
data
|
180
|
+
end
|
181
|
+
|
182
|
+
# Create a Protocol::HTTP::Request from the stored request data.
|
183
|
+
# @returns [Protocol::HTTP::Request] The constructed request object.
|
184
|
+
def make_request
|
185
|
+
if request_data = @data[:request]
|
186
|
+
build_request(**request_data)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Create a Protocol::HTTP::Response from the stored response data.
|
191
|
+
# @returns [Protocol::HTTP::Response] The constructed response object.
|
192
|
+
def make_response
|
193
|
+
if response_data = @data[:response]
|
194
|
+
build_response(**response_data)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Build a Protocol::HTTP::Request from the provided parameters.
|
199
|
+
# @parameter scheme [String | Nil] The request scheme (e.g. "https").
|
200
|
+
# @parameter authority [String | Nil] The request authority (e.g. "example.com").
|
201
|
+
# @parameter method [String] The HTTP method (e.g. "GET", "POST").
|
202
|
+
# @parameter path [String] The request path (e.g. "/users/123").
|
203
|
+
# @parameter version [String | Nil] The HTTP version (e.g. "HTTP/1.1").
|
204
|
+
# @parameter headers [Array | Nil] Array of header name-value pairs.
|
205
|
+
# @parameter body [Array | Nil] Array of body chunks.
|
206
|
+
# @parameter protocol [String | Array | Nil] The protocol information.
|
207
|
+
# @returns [Protocol::HTTP::Request] The constructed request object.
|
208
|
+
def build_request(scheme: nil, authority: nil, method:, path:, version: nil, headers: nil, body: nil, protocol: nil)
|
209
|
+
body = ::Protocol::HTTP::Body::Buffered.wrap(body) if body
|
210
|
+
headers = build_headers(headers) if headers
|
211
|
+
|
212
|
+
::Protocol::HTTP::Request.new(
|
213
|
+
scheme,
|
214
|
+
authority,
|
215
|
+
method,
|
216
|
+
path,
|
217
|
+
version,
|
218
|
+
headers,
|
219
|
+
body,
|
220
|
+
protocol
|
221
|
+
)
|
222
|
+
end
|
223
|
+
|
224
|
+
# Build a Protocol::HTTP::Response from the provided parameters.
|
225
|
+
# @parameter version [String | Nil] The HTTP version (e.g. "HTTP/1.1").
|
226
|
+
# @parameter status [Integer] The HTTP status code (e.g. 200, 404).
|
227
|
+
# @parameter headers [Array | Nil] Array of header name-value pairs.
|
228
|
+
# @parameter body [Array | Nil] Array of body chunks.
|
229
|
+
# @parameter protocol [String | Array | Nil] The protocol information.
|
230
|
+
# @returns [Protocol::HTTP::Response] The constructed response object.
|
231
|
+
def build_response(version: nil, status:, headers: nil, body: nil, protocol: nil)
|
232
|
+
body = ::Protocol::HTTP::Body::Buffered.wrap(body) if body
|
233
|
+
headers = build_headers(headers) if headers
|
234
|
+
|
235
|
+
::Protocol::HTTP::Response.new(
|
236
|
+
version,
|
237
|
+
status,
|
238
|
+
headers,
|
239
|
+
body,
|
240
|
+
protocol
|
241
|
+
)
|
242
|
+
end
|
243
|
+
|
244
|
+
# Build Protocol::HTTP::Headers from serialized data.
|
245
|
+
# @parameter headers_data [Hash | Array] The serialized headers data.
|
246
|
+
# @returns [Protocol::HTTP::Headers] The constructed headers object.
|
247
|
+
def build_headers(headers_data)
|
248
|
+
case headers_data
|
249
|
+
when Hash
|
250
|
+
# Hash format with fields and tail for complete header reconstruction:
|
251
|
+
if headers_data.key?(:fields) || headers_data.key?("fields")
|
252
|
+
fields = headers_data[:fields] || headers_data["fields"]
|
253
|
+
tail = headers_data[:tail] || headers_data["tail"]
|
254
|
+
::Protocol::HTTP::Headers.new(fields, tail)
|
255
|
+
else
|
256
|
+
# Simple hash format:
|
257
|
+
::Protocol::HTTP::Headers[headers_data]
|
258
|
+
end
|
259
|
+
when Array
|
260
|
+
# Array format of [name, value] pairs:
|
261
|
+
::Protocol::HTTP::Headers[headers_data]
|
262
|
+
else
|
263
|
+
::Protocol::HTTP::Headers[headers_data]
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
@@ -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
|