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