Skip to content

Commit 6e5c5bb

Browse files
committed
feat(network-details): Extract headers
Casts to lower-case before comparing headers. ObjC setters now accept raw allHeaders and configuredHeaders instead of pre-filtered headers, keeping the filtering logic in Swift.
1 parent ecb9e1d commit 6e5c5bb

File tree

4 files changed

+286
-7
lines changed

4 files changed

+286
-7
lines changed

Sources/Swift/Integrations/SessionReplay/SentryReplayNetworkDetails.swift

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,14 @@ enum NetworkBodyWarning: String {
180180
/// - Parameters:
181181
/// - size: Request body size in bytes, or nil if unknown.
182182
/// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured.
183-
/// - headers: Filtered HTTP request headers.
183+
/// - allHeaders: All headers from the request (e.g. from `NSURLRequest.allHTTPHeaderFields`).
184+
/// - configuredHeaders: Header names to extract, matched case-insensitively.
184185
@objc
185-
public func setRequest(size: NSNumber?, body: Any?, headers: [String: String]) {
186+
public func setRequest(size: NSNumber?, body: Any?, allHeaders: [String: Any]?, configuredHeaders: [String]?) {
186187
self.request = Detail(
187188
size: size,
188189
body: body.map { Body(content: $0) },
189-
headers: headers
190+
headers: SentryReplayNetworkDetails.extractHeaders(from: allHeaders, matching: configuredHeaders)
190191
)
191192
}
192193

@@ -196,17 +197,43 @@ enum NetworkBodyWarning: String {
196197
/// - statusCode: HTTP status code.
197198
/// - size: Response body size in bytes, or nil if unknown.
198199
/// - body: Pre-parsed body content (dictionary, array, or string), or nil if not captured.
199-
/// - headers: Filtered HTTP response headers.
200+
/// - allHeaders: All headers from the response (e.g. from `NSHTTPURLResponse.allHeaderFields`).
201+
/// - configuredHeaders: Header names to extract, matched case-insensitively.
200202
@objc
201-
public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, headers: [String: String]) {
203+
public func setResponse(statusCode: Int, size: NSNumber?, body: Any?, allHeaders: [String: Any]?, configuredHeaders: [String]?) {
202204
self.statusCode = NSNumber(value: statusCode)
203205
self.response = Detail(
204206
size: size,
205207
body: body.map { Body(content: $0) },
206-
headers: headers
208+
headers: SentryReplayNetworkDetails.extractHeaders(from: allHeaders, matching: configuredHeaders)
207209
)
208210
}
209211

212+
// MARK: - Header Extraction
213+
214+
/// Extracts headers from a source dictionary using case-insensitive matching.
215+
/// Preserves the original casing of the header key as seen in the source.
216+
///
217+
/// - Parameters:
218+
/// - sourceHeaders: All available headers (e.g. from `NSURLRequest` or `NSHTTPURLResponse`).
219+
/// - configuredHeaders: Header names to extract, matched case-insensitively.
220+
/// - Returns: Dictionary containing matched headers with original key casing preserved.
221+
static func extractHeaders(from sourceHeaders: [String: Any]?, matching configuredHeaders: [String]?) -> [String: String] {
222+
guard let sourceHeaders, let configuredHeaders else { return [:] }
223+
224+
var extracted = [String: String]()
225+
for configured in configuredHeaders {
226+
let lowered = configured.lowercased()
227+
for (key, value) in sourceHeaders {
228+
if key.lowercased() == lowered {
229+
extracted[key] = (value as? String) ?? "\(value)"
230+
break
231+
}
232+
}
233+
}
234+
return extracted
235+
}
236+
210237
// MARK: - Serialization
211238

212239
/// Serializes to dictionary for inclusion in breadcrumb data.

Tests/SentryTests/Networking/SentryReplayNetworkDetailsBodyTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@testable import Sentry
1+
@_spi(Private) @testable import Sentry
22
import XCTest
33

44
class SentryReplayNetworkDetailsBodyTests: XCTestCase {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
@_spi(Private) @testable import Sentry
2+
import XCTest
3+
4+
class SentryReplayNetworkDetailsHeaderTests: XCTestCase {
5+
6+
// MARK: - Header Extraction Tests
7+
8+
func testExtractHeaders_caseInsensitiveMatching() {
9+
// -- Arrange --
10+
let sourceHeaders: [String: Any] = [
11+
"Content-Type": "application/json",
12+
"AUTHORIZATION": "Bearer token",
13+
"x-request-id": "123"
14+
]
15+
let configuredHeaders = ["content-type", "Authorization", "X-Request-ID"]
16+
17+
// -- Act --
18+
let extracted = SentryReplayNetworkDetails.extractHeaders(
19+
from: sourceHeaders,
20+
matching: configuredHeaders
21+
)
22+
23+
// -- Assert --
24+
XCTAssertEqual(extracted.count, 3)
25+
// Should preserve original casing from source
26+
XCTAssertEqual(extracted["Content-Type"], "application/json")
27+
XCTAssertEqual(extracted["AUTHORIZATION"], "Bearer token")
28+
XCTAssertEqual(extracted["x-request-id"], "123")
29+
}
30+
31+
func testExtractHeaders_withNilInputs_returnsEmptyDict() {
32+
// Test nil source headers
33+
XCTAssertEqual(
34+
SentryReplayNetworkDetails.extractHeaders(from: nil, matching: ["test"]),
35+
[:]
36+
)
37+
38+
// Test nil configured headers
39+
XCTAssertEqual(
40+
SentryReplayNetworkDetails.extractHeaders(from: ["test": "value"], matching: nil),
41+
[:]
42+
)
43+
44+
// Test both nil
45+
XCTAssertEqual(
46+
SentryReplayNetworkDetails.extractHeaders(from: nil, matching: nil),
47+
[:]
48+
)
49+
}
50+
51+
func testExtractHeaders_nonStringValues_convertedToStrings() {
52+
// -- Arrange --
53+
let sourceHeaders: [String: Any] = [
54+
"Content-Length": NSNumber(value: 9_876),
55+
"Retry-After": 60,
56+
"X-Bool": true,
57+
"X-Double": 3.14159
58+
]
59+
let configuredHeaders = ["Content-Length", "Retry-After", "X-Bool", "X-Double"]
60+
61+
// -- Act --
62+
let extracted = SentryReplayNetworkDetails.extractHeaders(
63+
from: sourceHeaders,
64+
matching: configuredHeaders
65+
)
66+
67+
// -- Assert --
68+
XCTAssertEqual(extracted.count, 4)
69+
XCTAssertEqual(extracted["Content-Length"], "9876")
70+
XCTAssertEqual(extracted["Retry-After"], "60")
71+
XCTAssertEqual(extracted["X-Bool"], "true")
72+
XCTAssertEqual(extracted["X-Double"], "3.14159")
73+
}
74+
75+
func testExtractHeaders_unconfiguredHeadersAreExcluded() {
76+
// -- Arrange --
77+
let sourceHeaders: [String: Any] = [
78+
"Content-Type": "application/json",
79+
"Authorization": "Bearer token",
80+
"X-Custom": "should not appear"
81+
]
82+
let configuredHeaders = ["Content-Type", "Authorization"]
83+
84+
// -- Act --
85+
let extracted = SentryReplayNetworkDetails.extractHeaders(
86+
from: sourceHeaders,
87+
matching: configuredHeaders
88+
)
89+
90+
// -- Assert --
91+
XCTAssertEqual(extracted.count, 2)
92+
XCTAssertEqual(extracted["Content-Type"], "application/json")
93+
XCTAssertEqual(extracted["Authorization"], "Bearer token")
94+
XCTAssertNil(extracted["X-Custom"])
95+
}
96+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
@_spi(Private) @testable import Sentry
2+
import XCTest
3+
4+
class SentryReplayNetworkDetailsIntegrationTests: XCTestCase {
5+
6+
private typealias Body = SentryReplayNetworkDetails.Body
7+
8+
// MARK: - Initialization Tests
9+
10+
func testInit_withMethod_shouldSetMethod() {
11+
// -- Arrange & Act --
12+
let details = SentryReplayNetworkDetails(method: "POST")
13+
14+
// -- Assert --
15+
XCTAssertEqual(details.method, "POST")
16+
XCTAssertNil(details.statusCode)
17+
XCTAssertNil(details.requestBodySize)
18+
XCTAssertNil(details.responseBodySize)
19+
}
20+
21+
// MARK: - Serialization Tests
22+
23+
func testSerialize_withFullData_shouldReturnCompleteDictionary() {
24+
// -- Arrange --
25+
let details = SentryReplayNetworkDetails(method: "PUT")
26+
27+
details.setRequest(
28+
size: 100,
29+
body: ["name": "test"],
30+
allHeaders: ["Content-Type": "application/json", "Authorization": "Bearer token", "Accept": "*/*"],
31+
configuredHeaders: ["Content-Type", "Authorization"]
32+
)
33+
details.setResponse(
34+
statusCode: 201,
35+
size: 150,
36+
body: ["id": 123, "name": "test"],
37+
allHeaders: ["Content-Type": "application/json", "Cache-Control": "no-cache", "Set-Cookie": "session=123"],
38+
configuredHeaders: ["Content-Type", "Cache-Control"]
39+
)
40+
41+
// -- Act --
42+
let result = details.serialize()
43+
44+
// -- Assert --
45+
let expectedJSON = """
46+
{
47+
"method": "PUT",
48+
"statusCode": 201,
49+
"requestBodySize": 100,
50+
"responseBodySize": 150,
51+
"request": {
52+
"size": 100,
53+
"headers": {
54+
"Authorization": "Bearer token",
55+
"Content-Type": "application/json"
56+
},
57+
"body": {
58+
"body": {
59+
"name": "test"
60+
}
61+
}
62+
},
63+
"response": {
64+
"size": 150,
65+
"headers": {
66+
"Cache-Control": "no-cache",
67+
"Content-Type": "application/json"
68+
},
69+
"body": {
70+
"body": {
71+
"id": 123,
72+
"name": "test"
73+
}
74+
}
75+
}
76+
}
77+
"""
78+
79+
assertJSONEqual(result, expectedJSON: expectedJSON)
80+
}
81+
82+
func testSerialize_withPartialData_shouldOnlyIncludeSetFields() {
83+
// -- Arrange --
84+
let details = SentryReplayNetworkDetails(method: "GET")
85+
details.setResponse(
86+
statusCode: 404,
87+
size: nil,
88+
body: nil,
89+
allHeaders: ["Cache-Control": "no-cache", "Content-Type": "text/plain", "X-Custom": "value"],
90+
configuredHeaders: ["Cache-Control", "Content-Type"]
91+
)
92+
93+
// -- Act --
94+
let result = details.serialize()
95+
96+
// -- Assert --
97+
let expectedJSON = """
98+
{
99+
"method": "GET",
100+
"statusCode": 404,
101+
"response": {
102+
"headers": {
103+
"Cache-Control": "no-cache",
104+
"Content-Type": "text/plain"
105+
}
106+
}
107+
}
108+
"""
109+
110+
assertJSONEqual(result, expectedJSON: expectedJSON)
111+
}
112+
113+
func testSerialize_withHeaderFiltering_shouldOnlyIncludeConfiguredHeaders() {
114+
// -- Arrange --
115+
let details = SentryReplayNetworkDetails(method: "GET")
116+
details.setRequest(
117+
size: nil,
118+
body: nil,
119+
allHeaders: [
120+
"Content-Type": "application/json",
121+
"Authorization": "Bearer secret",
122+
"X-Internal": "hidden",
123+
"Cookie": "session=abc"
124+
],
125+
configuredHeaders: ["Content-Type"]
126+
)
127+
128+
// -- Act --
129+
let result = details.serialize()
130+
131+
// -- Assert --
132+
guard let request = result["request"] as? [String: Any],
133+
let headers = request["headers"] as? [String: String] else {
134+
return XCTFail("Expected request with headers")
135+
}
136+
XCTAssertEqual(headers.count, 1)
137+
XCTAssertEqual(headers["Content-Type"], "application/json")
138+
XCTAssertNil(headers["Authorization"])
139+
}
140+
141+
// MARK: - Test Helpers
142+
143+
private func assertJSONEqual(_ result: [String: Any], expectedJSON: String) {
144+
guard let expectedData = expectedJSON.data(using: .utf8) else {
145+
return XCTFail("Failed to convert expected JSON string to data")
146+
}
147+
148+
do {
149+
let expectedDict = try JSONSerialization.jsonObject(with: expectedData, options: []) as? NSDictionary
150+
let actualDict = result as NSDictionary
151+
XCTAssertEqual(actualDict, expectedDict)
152+
} catch {
153+
XCTFail("Failed to parse expected JSON: \(error)")
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)