restless-sdk 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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mask"
4
+ require_relative "redact"
5
+ require_relative "fingerprint"
6
+ require_relative "stack_frames"
7
+ require_relative "har"
8
+ require_relative "request_id"
9
+ require_relative "injection"
10
+
11
+ module Restless
12
+ # The shared operation table behind the conformance driver.
13
+ #
14
+ # Both `exe/restless-conformance` (the stdio driver the cross-language
15
+ # harness drives) and `test/test_vectors.rb` (the in-process replay) go
16
+ # through this one file, so it is impossible for the vectors to describe
17
+ # behaviour the driver does not exhibit.
18
+ #
19
+ # See node-sdk/spec/driver/PROTOCOL.md. Internal: never part of the public
20
+ # API, never shipped behaviour a customer depends on.
21
+ module Conformance
22
+ # An input this implementation's language cannot represent or parse. The
23
+ # harness records it as a SKIP, not a failure. See CONTRACT.md FP-046 and
24
+ # PRIM-035.
25
+ class UnsupportedDialect < StandardError; end
26
+
27
+ class UnknownOp < StandardError; end
28
+
29
+ module_function
30
+
31
+ def dispatch(op, input)
32
+ input ||= {}
33
+ case op
34
+ # --- masking (section 3) ---
35
+ when "mask"
36
+ Mask.mask(str_or_nil(input["apiKey"]))
37
+
38
+ # --- redaction (section 4) ---
39
+ when "redactValue"
40
+ Redact.redact_value(str(input["value"]))
41
+ when "redactHeaders"
42
+ Redact.redact_headers(hash(input["headers"]), strs(input["extra"]))
43
+ when "redactUrl"
44
+ Redact.redact_url(str(input["url"]), strs(input["extra"]))
45
+ when "redactBody"
46
+ body = str_or_nil(input["body"])
47
+ # An empty string is a VALUE here, distinct from an absent body, so
48
+ # this is deliberately not collapsed to nil.
49
+ body.nil? ? nil : Redact.redact_body(body, str_or_nil(input["contentType"]), strs(input["extra"]))
50
+ when "truncateBody"
51
+ body = str_or_nil(input["body"])
52
+ body.nil? ? nil : Redact.truncate_body(body, int(input["maxBytes"]))
53
+
54
+ # --- fingerprinting (section 5) ---
55
+ when "fingerprint"
56
+ fingerprint(input)
57
+ when "normalizeRoute"
58
+ Fingerprint.normalize_route(str_or_nil(input["route"]))
59
+ when "normalizeMessage"
60
+ Fingerprint.normalize_message(str(input["message"]))
61
+ when "projectRelative"
62
+ # FP-042 is shared across every SDK even though frame PARSING is not
63
+ # (FP-044/FP-046), so path normalization gets its own dialect-free op.
64
+ Fingerprint.project_relative(str(input["file"]))
65
+
66
+ # --- request ids (section 6) ---
67
+ when "formatRequestId"
68
+ RequestId.format_request_id(str(input["rawId"]), str_or_nil(input["prefix"]))
69
+ when "stripRequestIdPrefix"
70
+ RequestId.strip_request_id_prefix(str(input["requestId"]))
71
+ when "requestIdHeaders"
72
+ RequestId.response_headers(
73
+ str(input["ourId"]),
74
+ hash(input["incomingHeaders"]),
75
+ str_or_nil(input["prefix"]),
76
+ input.key?("hasApiKey") ? input["hasApiKey"] == true : true
77
+ )
78
+
79
+ # --- injection (section 10) ---
80
+ when "recoverySlug"
81
+ Injection.recovery_slug(str_or_nil(input["method"]), str_or_nil(input["path"]))
82
+
83
+ # --- HAR (section 7) ---
84
+ when "harEntry"
85
+ Har.to_har_entry(hash(input["captured"]))
86
+
87
+ else
88
+ raise UnknownOp, "unknown op: #{op}"
89
+ end
90
+ end
91
+
92
+ def fingerprint(input)
93
+ stack = stack_text(input["stackTrace"])
94
+ if !stack.empty? && v8_dialect?(stack)
95
+ raise UnsupportedDialect,
96
+ "unsupported stack dialect: v8 (this SDK parses Ruby backtraces; FP-044/FP-046)"
97
+ end
98
+
99
+ # Parse the stack the way the Rack adapter does. This used to pass
100
+ # nil, which made the driver structurally incapable of reaching the
101
+ # stack strategy: the whole path was dead through the harness while
102
+ # the SDK's own tests, which call StackFrames directly, still passed.
103
+ # Nothing caught it, because every v8-shaped stack vector is skipped
104
+ # under FP-046 so the harness never exercises this branch. A driver
105
+ # that short-circuits the SDK is testing itself.
106
+ result = Fingerprint.compute(
107
+ status: int(input["status"]),
108
+ method: str_or_nil(input["method"]),
109
+ route: str_or_nil(input["route"]),
110
+ response_headers: input["responseHeaders"].is_a?(Hash) ? input["responseHeaders"] : nil,
111
+ response_body: input["responseBody"],
112
+ stack_frame: stack.empty? ? nil : StackFrames.top_user_frame(stack.split("\n"))
113
+ )
114
+ # FP-003: `reason` is human-facing prose, explicitly not contract
115
+ # surface, so drivers must not emit it.
116
+ { "strategy" => result.strategy, "key" => result.key }
117
+ end
118
+
119
+ def stack_text(raw)
120
+ case raw
121
+ when String then raw
122
+ when Array then raw.map(&:to_s).join("\n")
123
+ else ""
124
+ end
125
+ end
126
+
127
+ # A v8 frame is ` at fn (/file.js:12:34)`. Ruby backtraces are
128
+ # `/file.rb:12:in 'fn'`, so anything shaped like the former is out of
129
+ # dialect and the driver says so rather than guessing.
130
+ def v8_dialect?(stack)
131
+ stack.split("\n").any? { |line| line.strip.start_with?("at ") }
132
+ end
133
+
134
+ # --- input coercion -----------------------------------------------------
135
+ # The protocol represents absence as JSON null.
136
+
137
+ def str(value)
138
+ value.is_a?(String) ? value : ""
139
+ end
140
+
141
+ def str_or_nil(value)
142
+ value.is_a?(String) ? value : nil
143
+ end
144
+
145
+ def strs(value)
146
+ return [] unless value.is_a?(Array)
147
+
148
+ value.select { |v| v.is_a?(String) }
149
+ end
150
+
151
+ def hash(value)
152
+ value.is_a?(Hash) ? value : {}
153
+ end
154
+
155
+ def int(value)
156
+ case value
157
+ when Integer then value
158
+ when Float then value.to_i
159
+ when String then value.to_i
160
+ else 0
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Restless
4
+ # Environment probing: CONFIG-003, CONFIG-004, BATCH-003, BATCH-008.
5
+ #
6
+ # All of it is per-language by design (CONTRACT.md section 14); what is
7
+ # normative is only the effect.
8
+ module Env
9
+ # WIRE-005.
10
+ DEFAULT_BASE_URL = "https://ingress.restless.ai"
11
+
12
+ module_function
13
+
14
+ # BATCH-003's "language equivalent environment indicator". Ruby has three
15
+ # spellings in the wild and no single winner, so all three are consulted,
16
+ # most specific first.
17
+ def app_env
18
+ %w[RESTLESS_ENV RACK_ENV RAILS_ENV].each do |name|
19
+ value = ENV[name]
20
+ return value if value && !value.empty?
21
+ end
22
+ ""
23
+ end
24
+
25
+ def production?
26
+ app_env == "production"
27
+ end
28
+
29
+ # BATCH-008. Under a test runner, captures are dropped rather than
30
+ # uploaded, unless RESTLESS_SETUP_MODE=1. Test suites must not hammer
31
+ # production ingest.
32
+ #
33
+ # The constants are checked rather than merely `defined?(Minitest)` at the
34
+ # top level, because a library can pull minitest in without the process
35
+ # actually being a test run.
36
+ def test_run?
37
+ return true if app_env == "test"
38
+ return true if defined?(::RSpec::Core)
39
+ return true if defined?(::Minitest::Test)
40
+ return true if defined?(::Test::Unit::TestCase)
41
+
42
+ false
43
+ end
44
+
45
+ def setup_mode?
46
+ ENV["RESTLESS_SETUP_MODE"] == "1"
47
+ end
48
+
49
+ # CONFIG-004. `DEBUG` containing `restless` as a whitespace- or
50
+ # comma-delimited token, or the literal `*`.
51
+ def debug?
52
+ flag = ENV["DEBUG"].to_s
53
+ return true if flag == "*" || flag == "restless"
54
+
55
+ flag.split(/[\s,]+/).include?("restless")
56
+ end
57
+
58
+ def debug_log(message)
59
+ warn("[restless] #{message}") if debug?
60
+ end
61
+
62
+ # CONFIG-001. Explicit key, then RESTLESS_KEY, then README_API_KEY.
63
+ def resolve_api_key(explicit = nil)
64
+ [explicit, ENV["RESTLESS_KEY"], ENV["README_API_KEY"]]
65
+ .find { |v| v && !v.empty? } || ""
66
+ end
67
+
68
+ # CONFIG-003.
69
+ def resolve_base_url(explicit = nil)
70
+ return explicit if explicit && !explicit.empty?
71
+
72
+ from_env = ENV["RESTLESS_BASE_URL"]
73
+ return from_env if from_env && !from_env.empty?
74
+
75
+ DEFAULT_BASE_URL
76
+ end
77
+
78
+ def localhost?(base_url)
79
+ base_url.include?("//localhost") || base_url.include?("//127.0.0.1")
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text"
4
+
5
+ module Restless
6
+ # CONTRACT.md section 5. A stable identifier for an HTTP error response,
7
+ # computed at capture time and shipped with the log.
8
+ #
9
+ # The ingest stores it, the dashboard groups by it, a customer attaches a
10
+ # recovery message to a group, and the SDK injects that message into
11
+ # matching responses. Nothing re-derives it, so every SDK must agree.
12
+ module Fingerprint
13
+ # FP-016. Checked in this order, matched EXACTLY (case-sensitively, no
14
+ # REDACT-010 normalization).
15
+ CODE_FIELDS = %w[code error_code errorCode type].freeze
16
+ NESTED_PATHS = [
17
+ %w[error code],
18
+ %w[error type],
19
+ %w[error error_code]
20
+ ].freeze
21
+
22
+ # FP-015. 1 to 64 code points, identifier-shaped. Keeps `card_declined`
23
+ # and `AUTH_MISMATCH`, rejects `Your card was declined.` and bare UUIDs.
24
+ #
25
+ # `\A...\z` rather than `^...$` (PRIM-005): Ruby's `$` also matches before
26
+ # a trailing newline, so `"boom\n"` would look like a code here and not in
27
+ # JavaScript.
28
+ CODE_RE = /\A[a-zA-Z][a-zA-Z0-9_.\-]*\z/.freeze
29
+
30
+ # FP-030. A single path segment that collapses to `:id`. Each pattern is
31
+ # fully anchored, so this is a whole-segment test rather than a scan.
32
+ #
33
+ # The hex ranges are written out rather than using the `i` flag: Ruby's
34
+ # case-insensitive matching is Unicode case folding, which is a wider
35
+ # relation than the ASCII-only canonicalization a non-`u` JavaScript regex
36
+ # performs.
37
+ SEG_UUID = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
38
+ SEG_NUMERIC = /\A[0-9]+\z/.freeze
39
+ SEG_LONG_HEX = /\A[0-9a-fA-F]{16,}\z/.freeze
40
+
41
+ # FP-042. Project-directory markers.
42
+ PROJECT_DIRS = %w[src lib app api routes controllers handlers].freeze
43
+
44
+ # FP-020. The classes are enumerated (PRIM-001, PRIM-002) rather than
45
+ # spelled `\w` / `\s`, because those mean different things in different
46
+ # engines and this key has to be byte-identical in every SDK.
47
+ WS = Text::WS_CLASS
48
+ WORD = Text::WORD_CLASS
49
+
50
+ RE_URL = Regexp.new("https?://[^#{WS}]+").freeze
51
+ RE_EMAIL = Regexp.new("[^#{WS}]+@[^#{WS}]+\\.[^#{WS}]+").freeze
52
+ RE_QUOTED = /['"`][^'"`]*['"`]/.freeze
53
+ RE_PUNCT = Regexp.new("[^#{WORD}#{WS}\\-]").freeze
54
+ RE_WS_RUN = Regexp.new("[#{WS}]+").freeze
55
+
56
+ # FP-020 step 5, and the one regex in this file that cannot be written
57
+ # against a UTF-8 string.
58
+ #
59
+ # Ruby's `\w` is ASCII-only (correct here) but its `\b` is NOT: Onigmo
60
+ # defines the boundary against the Unicode word property, so `"eA1"` with
61
+ # a leading accented letter has no boundary before the `A` and the whole
62
+ # digit-word survives, where JavaScript, Go and Python-with-re.ASCII all
63
+ # strip it. Applying the match to the UTF-8 BYTES restores JavaScript's
64
+ # semantics exactly: every non-ASCII character becomes a run of non-word
65
+ # bytes, which is what a non-`u` JS regex sees in UTF-16 too. The pattern
66
+ # can only ever match ASCII bytes, so byte slicing and character slicing
67
+ # coincide and the result is still well-formed UTF-8.
68
+ RE_DIGIT_WORD = Regexp.new("\\b[#{WORD}\\-]*[0-9][#{WORD}\\-]*\\b").freeze
69
+
70
+ Result = Struct.new(:strategy, :key, :reason)
71
+
72
+ module_function
73
+
74
+ # FP-010. Strategies are tried in this exact order; the first that yields
75
+ # a key wins.
76
+ #
77
+ # `stack_frame` is the already-extracted `{file:, fn:}` for the frame
78
+ # nearest the throw site (FP-043). Frame PARSING is per-language
79
+ # (FP-044) and lives in `Restless::StackFrames`, so this function stays
80
+ # dialect-free.
81
+ def compute(status:, method: nil, route: nil, response_headers: nil,
82
+ response_body: nil, stack_frame: nil)
83
+ method = "GET" if method.nil? || method.empty? # FP-011
84
+
85
+ # FP-012. 404 is intercepted BEFORE the code-based strategies: a generic
86
+ # `not_found` code is the same on every route, so grouping 404s by code
87
+ # is useless for recovery.
88
+ if status == 404
89
+ normalized = route.nil? || route.empty? ? "" : normalize_route(route)
90
+ # FP-014: the parameter test runs on the NORMALIZED route.
91
+ if normalized.include?(":") || normalized.include?("{")
92
+ return Result.new(
93
+ "resource", "404:resource",
94
+ "404 on a parameterized route (#{method} #{normalized}); " \
95
+ "the addressed resource was not found"
96
+ )
97
+ end
98
+ reason = if normalized.empty?
99
+ "404 on a path that matched no route; the endpoint does not exist"
100
+ else
101
+ "404 on #{method} #{normalized}; no resource at this path"
102
+ end
103
+ return Result.new("endpoint", "404:endpoint", reason)
104
+ end
105
+
106
+ # 1. Explicit header. Fully deterministic; the customer opted in.
107
+ header_code = read_header_code(response_headers)
108
+ if header_code
109
+ return Result.new("header", "#{status}:#{header_code}",
110
+ %(x-restless-error-code header: "#{header_code}"))
111
+ end
112
+
113
+ # 2. Code-like field in the body. Stripe/AWS/Twilio-shaped APIs land here.
114
+ body_code = read_body_code(response_body)
115
+ if body_code
116
+ return Result.new("body-code", "#{status}:#{body_code}",
117
+ %(code field in body: "#{body_code}"))
118
+ end
119
+
120
+ # 3. Stack trace (5xx with a thrown exception). File + function only:
121
+ # FP-041 forbids a line number, so adding a comment above a `raise`
122
+ # cannot split an error group.
123
+ if status >= 500 && stack_frame
124
+ return Result.new(
125
+ "stack", "#{status}:#{stack_frame[:file]}:#{stack_frame[:fn]}",
126
+ "top user frame: #{stack_frame[:fn]} in #{stack_frame[:file]}"
127
+ )
128
+ end
129
+
130
+ # 4. Normalized message + templated route.
131
+ normalized_route = normalize_route(route)
132
+ msg = normalize_message(extract_message(response_body))
133
+ unless msg.empty?
134
+ return Result.new("message", "#{status}:#{method}:#{normalized_route}:#{msg}",
135
+ %(message normalized to "#{msg}"))
136
+ end
137
+
138
+ # 5. Status + route only. Coarse, but it groups all unhandled responses.
139
+ Result.new("route-only", "#{status}:#{method}:#{normalized_route}",
140
+ "no usable code or message; falling back to status + route")
141
+ end
142
+
143
+ # FP-017. The header name is matched case-insensitively.
144
+ def read_header_code(headers)
145
+ return nil unless headers.is_a?(Hash)
146
+
147
+ lower = {}
148
+ headers.each { |k, v| lower[Text.full_lower(k.to_s)] = v }
149
+ value = lower["x-restless-error-code"]
150
+ looks_like_code?(value) ? value : nil
151
+ end
152
+
153
+ def read_body_code(body)
154
+ return nil unless body.is_a?(Hash)
155
+
156
+ CODE_FIELDS.each do |field|
157
+ value = body[field]
158
+ return value if looks_like_code?(value)
159
+ end
160
+
161
+ NESTED_PATHS.each do |path|
162
+ value = body
163
+ path.each { |segment| value = value.is_a?(Hash) ? value[segment] : nil }
164
+ return value if looks_like_code?(value)
165
+ end
166
+
167
+ nil
168
+ end
169
+
170
+ # FP-015.
171
+ def looks_like_code?(value)
172
+ value.is_a?(String) &&
173
+ !value.empty? &&
174
+ value.length <= 64 &&
175
+ CODE_RE.match?(value)
176
+ end
177
+
178
+ # FP-018. Body itself if it is a string; then `message`; then `error` if
179
+ # it is a string; then `error.message`. Anything else yields no message.
180
+ def extract_message(body)
181
+ return "" if body.nil? || body == false
182
+ return body if body.is_a?(String)
183
+ return "" unless body.is_a?(Hash)
184
+
185
+ message = body["message"]
186
+ return message if message.is_a?(String)
187
+
188
+ nested = body["error"]
189
+ return nested if nested.is_a?(String)
190
+ if nested.is_a?(Hash) && nested["message"].is_a?(String)
191
+ return nested["message"]
192
+ end
193
+
194
+ ""
195
+ end
196
+
197
+ # FP-020. The 12 steps, in exactly this order.
198
+ def normalize_message(msg)
199
+ return "" if msg.nil? || msg.empty?
200
+
201
+ s = Text.full_lower(msg) # 1
202
+ s = s.gsub(RE_URL, " ") # 2
203
+ s = s.gsub(RE_EMAIL, " ") # 3
204
+ s = s.gsub(RE_QUOTED, " ") # 4
205
+ s = strip_digit_words(s) # 5
206
+ s = s.gsub(RE_PUNCT, " ") # 6
207
+ s = s.gsub(RE_WS_RUN, " ") # 7
208
+ s = Text.ws_trim(s) # 8
209
+ s.split(/ /, -1) # 9 (never `split(" ")`: awk mode)
210
+ .reject { |w| w.length <= 1 } # 10
211
+ .first(6) # 11
212
+ .join("-") # 12
213
+ end
214
+
215
+ # FP-021. Whole words containing a digit are stripped, not bare digits:
216
+ # `abc123` reduced to `abc` would still influence the key, so grouping
217
+ # would break whenever the surrounding id changed.
218
+ #
219
+ # See RE_DIGIT_WORD for why this runs against the byte string.
220
+ def strip_digit_words(str)
221
+ str.dup
222
+ .force_encoding(Encoding::ASCII_8BIT)
223
+ .gsub(RE_DIGIT_WORD, " ")
224
+ .force_encoding(Encoding::UTF_8)
225
+ end
226
+
227
+ # FP-030..FP-032.
228
+ def normalize_route(route)
229
+ return "/" if route.nil? || route.empty?
230
+
231
+ # `split("/", -1)`: Ruby drops trailing empty fields without the
232
+ # negative limit, so `/users/` would lose its trailing segment and stop
233
+ # matching JavaScript.
234
+ segments = route.split("/", -1)
235
+ # FP-031: index 0 is the text BEFORE the first "/" and is never
236
+ # normalized, which keeps a bare "123" route untouched.
237
+ (1...segments.length).each do |i|
238
+ segments[i] = ":id" if id_segment?(segments[i])
239
+ end
240
+ segments.join("/")
241
+ end
242
+
243
+ def id_segment?(segment)
244
+ SEG_UUID.match?(segment) ||
245
+ SEG_NUMERIC.match?(segment) ||
246
+ SEG_LONG_HEX.match?(segment)
247
+ end
248
+
249
+ # FP-042. Strip the machine-specific path prefix down to a project-relative
250
+ # path, so the same source file produces the same key on a laptop and in
251
+ # production.
252
+ #
253
+ # The LAST project directory wins, not the first, and the difference is the
254
+ # whole point:
255
+ #
256
+ # /Users/dev/proj/src/db/users.rb -> src/db/users.rb
257
+ # /app/src/db/users.rb -> src/db/users.rb
258
+ # /opt/render/project/src/db/users.rb -> src/db/users.rb
259
+ #
260
+ # A first-match rule returns `app/src/db/users.rb` for the middle one,
261
+ # because the deployment root IS the first match. Docker's conventional
262
+ # `WORKDIR /app` and Heroku both root there, so first-match made production
263
+ # disagree with a laptop for the same file across the most common
264
+ # containerized layout there is, defeating the only thing this function
265
+ # exists to do.
266
+ #
267
+ # The trade is that a nested layout (`/proj/src/a/src/x.rb`) collapses to
268
+ # `src/x.rb` rather than `src/a/src/x.rb`. That is far rarer than an `/app`
269
+ # root and is still machine-independent, which is the property being
270
+ # protected.
271
+ def project_relative(file)
272
+ # `split("/", -1)`: Ruby drops trailing empty fields without the negative
273
+ # limit, so `/proj/src/` would come back as ["", "proj", "src"] and take
274
+ # the fallback branch where JavaScript, which keeps the empty field,
275
+ # matches `src` and returns "src/".
276
+ segments = file.split("/", -1)
277
+ # Stop before the final component: a project dir has to have something
278
+ # after it to be a directory at all. A negative start makes `downto`
279
+ # yield nothing, which is correct for a bare filename.
280
+ (segments.length - 2).downto(0) do |i|
281
+ return segments[i..-1].join("/") if PROJECT_DIRS.include?(segments[i])
282
+ end
283
+
284
+ segments.last(2).join("/")
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text"
4
+ require_relative "redact"
5
+
6
+ module Restless
7
+ # CONTRACT.md section 7. Captured traffic rides inside a HAR 1.2 envelope.
8
+ module Har
9
+ module_function
10
+
11
+ # HAR-005. Parse the query into ordered name/value pairs.
12
+ #
13
+ # Hand-rolled rather than delegating to a URI library so it shares the
14
+ # exact decoding rule with `redact_url`, and so a port does not have to
15
+ # reproduce WHATWG URL parsing to agree with us. Duplicate names are
16
+ # preserved, which a hash-based parse would lose.
17
+ def parse_query_string(url)
18
+ q = url.index("?")
19
+ return [] if q.nil?
20
+
21
+ rest = url[(q + 1)..-1] || ""
22
+ hash = rest.index("#")
23
+ query = hash.nil? ? rest : rest[0, hash]
24
+ return [] if query.empty?
25
+
26
+ out = []
27
+ query.split("&", -1).each do |pair|
28
+ next if pair.empty?
29
+
30
+ eq = pair.index("=")
31
+ if eq.nil?
32
+ out << { "name" => Redact.percent_decode(pair), "value" => "" }
33
+ else
34
+ out << {
35
+ "name" => Redact.percent_decode(pair[0, eq]),
36
+ "value" => Redact.percent_decode(pair[(eq + 1)..-1] || "")
37
+ }
38
+ end
39
+ end
40
+ out
41
+ end
42
+
43
+ def headers_to_list(headers)
44
+ headers.map { |name, value| { "name" => name, "value" => value } }
45
+ end
46
+
47
+ # `captured` is a hash with string keys:
48
+ # requestId, startedAt, duration, routePattern?,
49
+ # request: {method, url, headers, body?}, response: {status, headers, body?}
50
+ #
51
+ # A nil body means "no body captured" and is distinct from an empty one:
52
+ # HAR-011 makes the first report -1 and the second 0.
53
+ def to_har_entry(captured)
54
+ request = captured["request"] || {}
55
+ response = captured["response"] || {}
56
+ request_headers = request["headers"] || {}
57
+ response_headers = response["headers"] || {}
58
+
59
+ request_body = request["body"]
60
+ response_body = response["body"]
61
+
62
+ req_content_type = request_headers["content-type"]
63
+ req_content_type = "" if req_content_type.nil? || req_content_type == ""
64
+ # HAR-007: mimeType falls back when the response has no content type.
65
+ res_content_type = response_headers["content-type"]
66
+ if res_content_type.nil? || res_content_type == ""
67
+ res_content_type = "application/octet-stream"
68
+ end
69
+
70
+ duration = captured["duration"] || 0
71
+
72
+ har_request = {
73
+ "method" => request["method"],
74
+ "url" => request["url"],
75
+ "httpVersion" => "HTTP/1.1", # HAR-003 (reserved)
76
+ "headers" => headers_to_list(request_headers), # HAR-004
77
+ "queryString" => parse_query_string(request["url"].to_s)
78
+ }
79
+ # HAR-006: postData is present ONLY when a request body was captured.
80
+ # The reference tests truthiness, so an empty-string body produces no
81
+ # postData but still reports bodySize 0.
82
+ unless request_body.nil? || request_body.empty?
83
+ har_request["postData"] = {
84
+ "mimeType" => req_content_type,
85
+ "text" => request_body
86
+ }
87
+ end
88
+ har_request["headersSize"] = -1 # HAR-012
89
+ har_request["bodySize"] =
90
+ request_body.nil? ? -1 : Text.utf8_length(request_body) # HAR-010/011
91
+
92
+ {
93
+ "startedDateTime" => captured["startedAt"], # HAR-001
94
+ "time" => duration, # HAR-002
95
+ "request" => har_request,
96
+ "response" => {
97
+ "status" => response["status"],
98
+ "statusText" => "", # HAR-008 (reserved)
99
+ "httpVersion" => "HTTP/1.1",
100
+ "headers" => headers_to_list(response_headers),
101
+ "content" => {
102
+ "size" => response_body.nil? ? 0 : Text.utf8_length(response_body),
103
+ "mimeType" => res_content_type,
104
+ "text" => response_body.nil? ? "" : response_body
105
+ },
106
+ "headersSize" => -1,
107
+ "bodySize" => response_body.nil? ? -1 : Text.utf8_length(response_body)
108
+ },
109
+ "timings" => { "send" => 0, "wait" => duration, "receive" => 0 }
110
+ }
111
+ end
112
+ end
113
+ end