Skip to content

Commit ff1ffe9

Browse files
committed
Add print plugins for RESTClient debugging with security redaction
1 parent e9af0e9 commit ff1ffe9

File tree

4 files changed

+323
-32
lines changed

4 files changed

+323
-32
lines changed

Sources/HandySwift/HandySwift.docc/Essentials/New Types.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,32 @@ Note that the ``Debouncer`` was stored in a property so ``Debouncer/cancelAll()`
109109

110110
> Note: If you need multiple debouncing operations in one view, you don't need multiple debouncers. Just pass an `id` to the delay function.
111111
112+
### Networking & Debugging
113+
114+
Building REST API clients is common in modern apps. HandySwift provides ``RESTClient`` to simplify this:
115+
116+
```swift
117+
let client = RESTClient(
118+
baseURL: URL(string: "https://api.example.com")!,
119+
baseHeaders: ["Authorization": "Bearer \(token)"],
120+
errorBodyToMessage: { _ in "Error" }
121+
)
122+
123+
let user: User = try await client.fetchAndDecode(method: .get, path: "users/me")
124+
```
125+
126+
When debugging API issues, add print plugins with the `debugOnly: true` parameter:
127+
128+
```swift
129+
let client = RESTClient(
130+
baseURL: URL(string: "https://api.example.com")!,
131+
requestPlugins: [PrintRequestPlugin(debugOnly: true)], // Debug requests
132+
responsePlugins: [PrintResponsePlugin(debugOnly: true)], // Debug responses
133+
errorBodyToMessage: { try JSONDecoder().decode(YourAPIErrorType.self, from: $0).message }
134+
)
135+
```
136+
137+
These plugins are particularly helpful when adopting new APIs, providing detailed request/response information to help diagnose issues. The `debugOnly: true` parameter ensures they only operate in DEBUG builds, making them safe to leave in your code.
112138

113139
## Topics
114140

@@ -127,6 +153,12 @@ Note that the ``Debouncer`` was stored in a property so ``Debouncer/cancelAll()`
127153
- ``Debouncer``
128154
- ``OperatingSystem`` (short: ``OS``)
129155

156+
### Networking & Debugging
157+
158+
- ``RESTClient``
159+
- ``PrintRequestPlugin``
160+
- ``PrintResponsePlugin``
161+
130162
### Other
131163

132164
- ``delay(by:qosClass:_:)-8iw4f``
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import Foundation
2+
3+
#if canImport(FoundationNetworking)
4+
import FoundationNetworking
5+
#endif
6+
7+
/// A plugin for debugging HTTP requests by printing request details to the console.
8+
///
9+
/// This plugin prints comprehensive request information including URL, HTTP method, headers, and body content.
10+
/// It's designed as a debugging tool and should only be used temporarily during development.
11+
///
12+
/// ## Usage
13+
///
14+
/// Add to your RESTClient for debugging:
15+
///
16+
/// ```swift
17+
/// let client = RESTClient(
18+
/// baseURL: URL(string: "https://api.example.com")!,
19+
/// requestPlugins: [PrintRequestPlugin()], // debugOnly: true, redactAPIKey: true by default
20+
/// errorBodyToMessage: { _ in "Error" }
21+
/// )
22+
/// ```
23+
///
24+
/// Both `debugOnly` and `redactAPIKey` default to `true` for security. You can disable these built-in protections if needed:
25+
///
26+
/// ```swift
27+
/// // Default behavior (recommended)
28+
/// PrintRequestPlugin() // debugOnly: true, redactAPIKey: true
29+
///
30+
/// // Disable debugOnly to log in production (discouraged)
31+
/// PrintRequestPlugin(debugOnly: false)
32+
///
33+
/// // Disable redactAPIKey for debugging auth issues (use carefully)
34+
/// PrintRequestPlugin(redactAPIKey: false)
35+
/// ```
36+
///
37+
/// ## Output Example
38+
///
39+
/// ```
40+
/// [RESTClient] Sending POST request to 'https://api.example.com/v1/users'
41+
///
42+
/// Headers:
43+
/// Authorization: Bearer [redacted]
44+
/// Content-Type: application/json
45+
/// User-Agent: MyApp/1.0
46+
///
47+
/// Body:
48+
/// {
49+
/// "name": "John Doe",
50+
/// "email": "[email protected]"
51+
/// }
52+
/// ```
53+
///
54+
/// - Note: By default, logging only occurs in DEBUG builds and API keys are redacted for security.
55+
/// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection.
56+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
57+
public struct PrintRequestPlugin: RESTClient.RequestPlugin {
58+
/// Whether logging should only occur in DEBUG builds.
59+
///
60+
/// When `true` (default), requests are only logged in DEBUG builds.
61+
/// When `false`, requests are logged in both DEBUG and release builds (not recommended for production).
62+
public let debugOnly: Bool
63+
64+
/// Whether to redact API keys from Authorization headers in output.
65+
///
66+
/// When `true` (default), Authorization headers are replaced with "[redacted]" for security.
67+
/// When `false`, the full header value is shown (use carefully for debugging auth issues).
68+
public let redactAPIKey: Bool
69+
70+
/// Creates a new print request plugin.
71+
///
72+
/// - Parameters:
73+
/// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`.
74+
/// - redactAPIKey: Whether to redact API keys from Authorization headers. Defaults to `true`.
75+
public init(debugOnly: Bool = true, redactAPIKey: Bool = true) {
76+
self.debugOnly = debugOnly
77+
self.redactAPIKey = redactAPIKey
78+
}
79+
80+
/// Applies the plugin to the request, printing request details if conditions are met.
81+
///
82+
/// This method is called automatically by RESTClient before sending the request.
83+
///
84+
/// - Parameter request: The URLRequest to potentially log and pass through unchanged.
85+
public func apply(to request: inout URLRequest) {
86+
if self.debugOnly {
87+
#if DEBUG
88+
self.printRequest(request)
89+
#endif
90+
} else {
91+
self.printRequest(request)
92+
}
93+
}
94+
95+
/// Prints detailed request information to the console.
96+
///
97+
/// - Parameter request: The URLRequest to print details for.
98+
private func printRequest(_ request: URLRequest) {
99+
var requestBodyString: String?
100+
if let bodyData = request.httpBody {
101+
requestBodyString = String(data: bodyData, encoding: .utf8)
102+
}
103+
104+
// Clean headers formatting - sorted alphabetically for consistency
105+
let cleanHeaders = (request.allHTTPHeaderFields ?? [:])
106+
.sorted { $0.key < $1.key }
107+
.map { " \($0.key): \(self.shouldRedactHeader($0.key) ? "[redacted]" : $0.value)" }
108+
.joined(separator: "\n")
109+
let headersString = cleanHeaders.isEmpty ? " (none)" : "\n\(cleanHeaders)"
110+
111+
print(
112+
"[RESTClient] Sending \(request.httpMethod!) request to '\(request.url!)'\n\nHeaders:\(headersString)\n\nBody:\n\(requestBodyString ?? "No body")"
113+
)
114+
}
115+
116+
/// Determines whether a header should be redacted for security.
117+
///
118+
/// - Parameter headerName: The header name to check.
119+
/// - Returns: `true` if the header should be redacted when `redactAPIKey` is enabled.
120+
private func shouldRedactHeader(_ headerName: String) -> Bool {
121+
guard self.redactAPIKey else { return false }
122+
123+
let lowercasedName = headerName.lowercased()
124+
125+
// Exact header name matches
126+
let exactMatches = [
127+
"authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token",
128+
"x-access-token", "bearer", "apikey", "api-key", "access-token",
129+
"refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id",
130+
]
131+
132+
// Substring patterns that indicate sensitive content
133+
let sensitivePatterns = ["password", "secret", "token"]
134+
135+
return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) }
136+
}
137+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import Foundation
2+
3+
#if canImport(FoundationNetworking)
4+
import FoundationNetworking
5+
#endif
6+
7+
/// A plugin for debugging HTTP responses by printing response details to the console.
8+
///
9+
/// This plugin prints comprehensive response information including status code, headers, and body content.
10+
/// It's designed as a debugging tool and should only be used temporarily during development.
11+
///
12+
/// ## Usage
13+
///
14+
/// Add to your RESTClient for debugging:
15+
///
16+
/// ```swift
17+
/// let client = RESTClient(
18+
/// baseURL: URL(string: "https://api.example.com")!,
19+
/// responsePlugins: [PrintResponsePlugin()], // debugOnly: true, redactAPIKey: true by default
20+
/// errorBodyToMessage: { _ in "Error" }
21+
/// )
22+
/// ```
23+
///
24+
/// Both `debugOnly` and `redactAPIKey` default to `true` for security. You can disable these built-in protections if needed:
25+
///
26+
/// ```swift
27+
/// // Default behavior (recommended)
28+
/// PrintResponsePlugin() // debugOnly: true, redactAPIKey: true
29+
///
30+
/// // Disable debugOnly to log in production (discouraged)
31+
/// PrintResponsePlugin(debugOnly: false)
32+
///
33+
/// // Disable redactAPIKey for debugging auth issues (use carefully)
34+
/// PrintResponsePlugin(redactAPIKey: false)
35+
/// ```
36+
///
37+
/// ## Output Example
38+
///
39+
/// ```
40+
/// [RESTClient] Response 200 from 'https://api.example.com/v1/users/123'
41+
///
42+
/// Response headers:
43+
/// Content-Type: application/json
44+
/// Date: Wed, 08 Aug 2025 10:30:00 GMT
45+
/// Server: nginx/1.18.0
46+
/// Set-Cookie: session_token=[redacted]
47+
/// X-Request-ID: req_abc123def456
48+
///
49+
/// Response body:
50+
/// {
51+
/// "id": 123,
52+
/// "name": "John Doe",
53+
/// "email": "[email protected]",
54+
/// "created_at": "2023-08-01T10:30:00Z"
55+
/// }
56+
/// ```
57+
///
58+
/// - Note: By default, logging only occurs in DEBUG builds and API keys are redacted for security.
59+
/// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection.
60+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
61+
public struct PrintResponsePlugin: RESTClient.ResponsePlugin {
62+
/// Whether logging should only occur in DEBUG builds.
63+
///
64+
/// When `true` (default), responses are only logged in DEBUG builds.
65+
/// When `false`, responses are logged in both DEBUG and release builds (not recommended for production).
66+
public let debugOnly: Bool
67+
68+
/// Whether to redact API keys from sensitive headers in output.
69+
///
70+
/// When `true` (default), sensitive headers like Authorization and Set-Cookie are replaced with "[redacted]" for security.
71+
/// When `false`, the full header value is shown (use carefully for debugging auth issues).
72+
public let redactAPIKey: Bool
73+
74+
/// Creates a new print response plugin.
75+
///
76+
/// - Parameters:
77+
/// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`.
78+
/// - redactAPIKey: Whether to redact API keys from sensitive headers. Defaults to `true`.
79+
public init(debugOnly: Bool = true, redactAPIKey: Bool = true) {
80+
self.debugOnly = debugOnly
81+
self.redactAPIKey = redactAPIKey
82+
}
83+
84+
/// Applies the plugin to the response, printing response details if conditions are met.
85+
///
86+
/// This method is called automatically by RESTClient after receiving the response.
87+
/// The response and data are passed through unchanged.
88+
///
89+
/// - Parameters:
90+
/// - response: The HTTPURLResponse to potentially log.
91+
/// - data: The response body data to potentially log.
92+
/// - Throws: Does not throw errors, but passes through any errors from the response processing.
93+
public func apply(to response: inout HTTPURLResponse, data: inout Data) throws {
94+
if self.debugOnly {
95+
#if DEBUG
96+
self.printResponse(response, data: data)
97+
#endif
98+
} else {
99+
self.printResponse(response, data: data)
100+
}
101+
}
102+
103+
/// Prints detailed response information to the console.
104+
///
105+
/// - Parameters:
106+
/// - response: The HTTPURLResponse to print details for.
107+
/// - data: The response body data to print.
108+
private func printResponse(_ response: HTTPURLResponse, data: Data) {
109+
var responseBodyString: String?
110+
if !data.isEmpty {
111+
responseBodyString = String(data: data, encoding: .utf8)
112+
}
113+
114+
// Clean headers formatting - sorted alphabetically for consistency, no AnyHashable wrappers
115+
var headersString = ""
116+
let cleanHeaders = response.allHeaderFields
117+
.compactMapValues { "\($0)" }
118+
.sorted { "\($0.key)" < "\($1.key)" }
119+
.map { " \($0.key): \(self.shouldRedactHeader("\($0.key)") ? "[redacted]" : $0.value)" }
120+
.joined(separator: "\n")
121+
headersString = cleanHeaders.isEmpty ? " (none)" : "\n\(cleanHeaders)"
122+
123+
print(
124+
"[RESTClient] Response \(response.statusCode) from '\(response.url!)'\n\nResponse headers:\(headersString)\n\nResponse body:\n\(responseBodyString ?? "No body")"
125+
)
126+
}
127+
128+
/// Determines whether a header should be redacted for security.
129+
///
130+
/// - Parameter headerName: The header name to check.
131+
/// - Returns: `true` if the header should be redacted when `redactAPIKey` is enabled.
132+
private func shouldRedactHeader(_ headerName: String) -> Bool {
133+
guard self.redactAPIKey else { return false }
134+
135+
let lowercasedName = headerName.lowercased()
136+
137+
// Exact header name matches
138+
let exactMatches = [
139+
"authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token",
140+
"x-access-token", "bearer", "apikey", "api-key", "access-token",
141+
"refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id",
142+
]
143+
144+
// Substring patterns that indicate sensitive content
145+
let sensitivePatterns = ["password", "secret", "token"]
146+
147+
return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) }
148+
}
149+
}

Sources/HandySwift/Types/RESTClient.swift

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,11 @@ public final class RESTClient: Sendable {
188188
extraQueryItems: [URLQueryItem] = [],
189189
errorContext: String? = nil
190190
) async throws(APIError) -> Data {
191-
let url = self.baseURL
192-
.appending(path: path)
193-
.appending(queryItems: self.baseQueryItems + extraQueryItems)
191+
let allQueryItems = self.baseQueryItems + extraQueryItems
192+
var url = self.baseURL.appending(path: path)
193+
if !allQueryItems.isEmpty {
194+
url = url.appending(queryItems: allQueryItems)
195+
}
194196

195197
var request = URLRequest(url: url)
196198
request.httpMethod = method.rawValue
@@ -226,8 +228,6 @@ public final class RESTClient: Sendable {
226228
}
227229

228230
private func performRequest(_ request: URLRequest, errorContext: String?) async throws(APIError) -> (Data, URLResponse) {
229-
self.logRequestIfDebug(request)
230-
231231
let data: Data
232232
let response: URLResponse
233233
do {
@@ -236,7 +236,6 @@ public final class RESTClient: Sendable {
236236
throw APIError.failedToLoadData(error, self.errorContext(requestContext: errorContext))
237237
}
238238

239-
self.logResponseIfDebug(response, data: data)
240239
return (data, response)
241240
}
242241

@@ -305,30 +304,4 @@ public final class RESTClient: Sendable {
305304
throw .unexpectedStatusCode(httpResponse.statusCode, self.errorContext(requestContext: errorContext))
306305
}
307306
}
308-
309-
private func logRequestIfDebug(_ request: URLRequest) {
310-
#if DEBUG
311-
var requestBodyString: String?
312-
if let bodyData = request.httpBody {
313-
requestBodyString = String(data: bodyData, encoding: .utf8)
314-
}
315-
316-
print(
317-
"[\(self)] Sending \(request.httpMethod!) request to '\(request.url!)': \(request)\n\nHeaders:\n\(request.allHTTPHeaderFields ?? [:])\n\nBody:\n\(requestBodyString ?? "No body")"
318-
)
319-
#endif
320-
}
321-
322-
private func logResponseIfDebug(_ response: URLResponse, data: Data?) {
323-
#if DEBUG
324-
var responseBodyString: String?
325-
if let data = data {
326-
responseBodyString = String(data: data, encoding: .utf8)
327-
}
328-
329-
print(
330-
"[\(self)] Received response & body from '\(response.url!)': \(response)\n\nResponse headers:\n\((response as? HTTPURLResponse)?.allHeaderFields ?? [:])\n\nResponse body:\n\(responseBodyString ?? "No body")"
331-
)
332-
#endif
333-
}
334307
}

0 commit comments

Comments
 (0)