Skip to content

Commit 80f7f08

Browse files
committed
Add OSLog plugins + rename redactAPIKey to redactAuthHeaders
1 parent ff1ffe9 commit 80f7f08

File tree

5 files changed

+357
-33
lines changed

5 files changed

+357
-33
lines changed

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,32 @@ let client = RESTClient(
123123
let user: User = try await client.fetchAndDecode(method: .get, path: "users/me")
124124
```
125125

126-
When debugging API issues, add print plugins with the `debugOnly: true` parameter:
126+
When debugging API issues, choose the appropriate logging plugins based on your platform:
127+
128+
#### For iOS/macOS/tvOS/watchOS Apps (Recommended)
129+
130+
Use OSLog-based plugins for structured, searchable logging:
131+
132+
```swift
133+
let client = RESTClient(
134+
baseURL: URL(string: "https://api.example.com")!,
135+
requestPlugins: [LogRequestPlugin(debugOnly: true)], // Structured request logging
136+
responsePlugins: [LogResponsePlugin(debugOnly: true)], // Structured response logging
137+
errorBodyToMessage: { try JSONDecoder().decode(YourAPIErrorType.self, from: $0).message }
138+
)
139+
```
140+
141+
These plugins use the modern OSLog framework for structured logging that integrates with Console.app and Instruments for advanced debugging.
142+
143+
#### For Server-Side Swift (Vapor/Linux)
144+
145+
Use print-based plugins for console output where OSLog is not available:
127146

128147
```swift
129148
let client = RESTClient(
130149
baseURL: URL(string: "https://api.example.com")!,
131-
requestPlugins: [PrintRequestPlugin(debugOnly: true)], // Debug requests
132-
responsePlugins: [PrintResponsePlugin(debugOnly: true)], // Debug responses
150+
requestPlugins: [PrintRequestPlugin(debugOnly: true)], // Console request logging
151+
responsePlugins: [PrintResponsePlugin(debugOnly: true)], // Console response logging
133152
errorBodyToMessage: { try JSONDecoder().decode(YourAPIErrorType.self, from: $0).message }
134153
)
135154
```
@@ -156,8 +175,10 @@ These plugins are particularly helpful when adopting new APIs, providing detaile
156175
### Networking & Debugging
157176

158177
- ``RESTClient``
159-
- ``PrintRequestPlugin``
160-
- ``PrintResponsePlugin``
178+
- ``LogRequestPlugin`` (for iOS/macOS/tvOS/watchOS apps)
179+
- ``LogResponsePlugin`` (for iOS/macOS/tvOS/watchOS apps)
180+
- ``PrintRequestPlugin`` (for server-side Swift/Vapor)
181+
- ``PrintResponsePlugin`` (for server-side Swift/Vapor)
161182

162183
### Other
163184

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#if canImport(OSLog)
2+
import Foundation
3+
import OSLog
4+
5+
#if canImport(FoundationNetworking)
6+
import FoundationNetworking
7+
#endif
8+
9+
/// A plugin for debugging HTTP requests using OSLog structured logging.
10+
///
11+
/// This plugin logs comprehensive request information including URL, HTTP method, headers, and body content
12+
/// using the modern OSLog framework for structured, searchable logging in apps.
13+
/// It's designed as a debugging tool and should only be used temporarily during development.
14+
///
15+
/// ## Usage
16+
///
17+
/// Add to your RESTClient for debugging:
18+
///
19+
/// ```swift
20+
/// let client = RESTClient(
21+
/// baseURL: URL(string: "https://api.example.com")!,
22+
/// requestPlugins: [LogRequestPlugin()], // debugOnly: true, redactAuthHeaders: true by default
23+
/// errorBodyToMessage: { _ in "Error" }
24+
/// )
25+
/// ```
26+
///
27+
/// Both `debugOnly` and `redactAuthHeaders` default to `true` for security. You can disable these built-in protections if needed:
28+
///
29+
/// ```swift
30+
/// // Default behavior (recommended)
31+
/// LogRequestPlugin() // debugOnly: true, redactAuthHeaders: true
32+
///
33+
/// // Disable debugOnly to log in production (discouraged)
34+
/// LogRequestPlugin(debugOnly: false)
35+
///
36+
/// // Disable redactAuthHeaders for debugging auth issues (use carefully)
37+
/// LogRequestPlugin(redactAuthHeaders: false)
38+
/// ```
39+
///
40+
/// ## Log Output
41+
///
42+
/// Logs are sent to the unified logging system with subsystem "RESTClient" and category "requests".
43+
/// Use Console.app or Instruments to view structured logs with searchable metadata.
44+
///
45+
/// Example log entry:
46+
/// ```
47+
/// [RESTClient] Sending POST request to 'https://api.example.com/v1/users'
48+
/// Headers: Authorization=[redacted], Content-Type=application/json
49+
/// Body: {"name": "John Doe", "email": "john@example.com"}
50+
/// ```
51+
///
52+
/// - Note: By default, logging only occurs in DEBUG builds and authentication headers are redacted for security.
53+
/// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection.
54+
/// - Important: For server-side Swift (Vapor), use ``PrintRequestPlugin`` instead as OSLog is not available on Linux.
55+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
56+
public struct LogRequestPlugin: RESTClient.RequestPlugin {
57+
/// Whether logging should only occur in DEBUG builds.
58+
///
59+
/// When `true` (default), requests are only logged in DEBUG builds.
60+
/// When `false`, requests are logged in both DEBUG and release builds (not recommended for production).
61+
public let debugOnly: Bool
62+
63+
/// Whether to redact authentication headers in output.
64+
///
65+
/// When `true` (default), authentication headers are replaced with "[redacted]" for security.
66+
/// When `false`, the full header value is shown (use carefully for debugging auth issues).
67+
public let redactAuthHeaders: Bool
68+
69+
/// The logger instance used for structured logging.
70+
private let logger = Logger(subsystem: "RESTClient", category: "requests")
71+
72+
/// Creates a new log request plugin.
73+
///
74+
/// - Parameters:
75+
/// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`.
76+
/// - redactAuthHeaders: Whether to redact authentication headers. Defaults to `true`.
77+
public init(debugOnly: Bool = true, redactAuthHeaders: Bool = true) {
78+
self.debugOnly = debugOnly
79+
self.redactAuthHeaders = redactAuthHeaders
80+
}
81+
82+
/// Applies the plugin to the request, logging request details if conditions are met.
83+
///
84+
/// This method is called automatically by RESTClient before sending the request.
85+
///
86+
/// - Parameter request: The URLRequest to potentially log and pass through unchanged.
87+
public func apply(to request: inout URLRequest) {
88+
if self.debugOnly {
89+
#if DEBUG
90+
self.logRequest(request)
91+
#endif
92+
} else {
93+
self.logRequest(request)
94+
}
95+
}
96+
97+
/// Logs detailed request information using OSLog.
98+
///
99+
/// - Parameter request: The URLRequest to log details for.
100+
private func logRequest(_ request: URLRequest) {
101+
let method = request.httpMethod ?? "UNKNOWN"
102+
let url = request.url?.absoluteString ?? "Unknown URL"
103+
104+
// Format headers for logging
105+
let headers = (request.allHTTPHeaderFields ?? [:])
106+
.sorted { $0.key < $1.key }
107+
.map { "\($0.key)=\(self.shouldRedactHeader($0.key) ? "[redacted]" : $0.value)" }
108+
.joined(separator: ", ")
109+
110+
// Format body for logging
111+
var bodyString = "No body"
112+
if let bodyData = request.httpBody,
113+
let body = String(data: bodyData, encoding: .utf8)
114+
{
115+
bodyString = body
116+
}
117+
118+
// Log with structured data
119+
self.logger.info(
120+
"[RESTClient] Sending \(method, privacy: .public) request to '\(url, privacy: .public)'"
121+
)
122+
self.logger.debug("Headers: \(headers, privacy: .private)")
123+
self.logger.debug("Body: \(bodyString, privacy: .private)")
124+
}
125+
126+
/// Determines whether a header should be redacted for security.
127+
///
128+
/// - Parameter headerName: The header name to check.
129+
/// - Returns: `true` if the header should be redacted when `redactAuthHeaders` is enabled.
130+
private func shouldRedactHeader(_ headerName: String) -> Bool {
131+
guard self.redactAuthHeaders else { return false }
132+
133+
let lowercasedName = headerName.lowercased()
134+
135+
// Exact header name matches
136+
let exactMatches = [
137+
"authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token",
138+
"x-access-token", "bearer", "apikey", "api-key", "access-token",
139+
"refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id",
140+
]
141+
142+
// Substring patterns that indicate sensitive content
143+
let sensitivePatterns = ["password", "secret", "token"]
144+
145+
return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) }
146+
}
147+
}
148+
#endif
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#if canImport(OSLog)
2+
import Foundation
3+
import OSLog
4+
5+
#if canImport(FoundationNetworking)
6+
import FoundationNetworking
7+
#endif
8+
9+
/// A plugin for debugging HTTP responses using OSLog structured logging.
10+
///
11+
/// This plugin logs comprehensive response information including status code, headers, and body content
12+
/// using the modern OSLog framework for structured, searchable logging in apps.
13+
/// It's designed as a debugging tool and should only be used temporarily during development.
14+
///
15+
/// ## Usage
16+
///
17+
/// Add to your RESTClient for debugging:
18+
///
19+
/// ```swift
20+
/// let client = RESTClient(
21+
/// baseURL: URL(string: "https://api.example.com")!,
22+
/// responsePlugins: [LogResponsePlugin()], // debugOnly: true, redactAuthHeaders: true by default
23+
/// errorBodyToMessage: { _ in "Error" }
24+
/// )
25+
/// ```
26+
///
27+
/// Both `debugOnly` and `redactAuthHeaders` default to `true` for security. You can disable these built-in protections if needed:
28+
///
29+
/// ```swift
30+
/// // Default behavior (recommended)
31+
/// LogResponsePlugin() // debugOnly: true, redactAuthHeaders: true
32+
///
33+
/// // Disable debugOnly to log in production (discouraged)
34+
/// LogResponsePlugin(debugOnly: false)
35+
///
36+
/// // Disable redactAuthHeaders for debugging auth issues (use carefully)
37+
/// LogResponsePlugin(redactAuthHeaders: false)
38+
/// ```
39+
///
40+
/// ## Log Output
41+
///
42+
/// Logs are sent to the unified logging system with subsystem "RESTClient" and category "responses".
43+
/// Use Console.app or Instruments to view structured logs with searchable metadata.
44+
///
45+
/// Example log entry:
46+
/// ```
47+
/// [RESTClient] Response 200 from 'https://api.example.com/v1/users/123'
48+
/// Headers: Content-Type=application/json, Set-Cookie=[redacted]
49+
/// Body: {"id": 123, "name": "John Doe", "email": "john@example.com"}
50+
/// ```
51+
///
52+
/// - Note: By default, logging only occurs in DEBUG builds and authentication headers are redacted for security.
53+
/// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection.
54+
/// - Important: For server-side Swift (Vapor), use ``PrintResponsePlugin`` instead as OSLog is not available on Linux.
55+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
56+
public struct LogResponsePlugin: RESTClient.ResponsePlugin {
57+
/// Whether logging should only occur in DEBUG builds.
58+
///
59+
/// When `true` (default), responses are only logged in DEBUG builds.
60+
/// When `false`, responses are logged in both DEBUG and release builds (not recommended for production).
61+
public let debugOnly: Bool
62+
63+
/// Whether to redact authentication headers in output.
64+
///
65+
/// When `true` (default), authentication headers like Authorization and Set-Cookie are replaced with "[redacted]" for security.
66+
/// When `false`, the full header value is shown (use carefully for debugging auth issues).
67+
public let redactAuthHeaders: Bool
68+
69+
/// The logger instance used for structured logging.
70+
private let logger = Logger(subsystem: "RESTClient", category: "responses")
71+
72+
/// Creates a new log response plugin.
73+
///
74+
/// - Parameters:
75+
/// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`.
76+
/// - redactAuthHeaders: Whether to redact authentication headers. Defaults to `true`.
77+
public init(debugOnly: Bool = true, redactAuthHeaders: Bool = true) {
78+
self.debugOnly = debugOnly
79+
self.redactAuthHeaders = redactAuthHeaders
80+
}
81+
82+
/// Applies the plugin to the response, logging response details if conditions are met.
83+
///
84+
/// This method is called automatically by RESTClient after receiving the response.
85+
/// The response and data are passed through unchanged.
86+
///
87+
/// - Parameters:
88+
/// - response: The HTTPURLResponse to potentially log.
89+
/// - data: The response body data to potentially log.
90+
/// - Throws: Does not throw errors, but passes through any errors from the response processing.
91+
public func apply(to response: inout HTTPURLResponse, data: inout Data) throws {
92+
if self.debugOnly {
93+
#if DEBUG
94+
self.logResponse(response, data: data)
95+
#endif
96+
} else {
97+
self.logResponse(response, data: data)
98+
}
99+
}
100+
101+
/// Logs detailed response information using OSLog.
102+
///
103+
/// - Parameters:
104+
/// - response: The HTTPURLResponse to log details for.
105+
/// - data: The response body data to log.
106+
private func logResponse(_ response: HTTPURLResponse, data: Data) {
107+
let statusCode = response.statusCode
108+
let url = response.url?.absoluteString ?? "Unknown URL"
109+
110+
// Format headers for logging
111+
let headers = response.allHeaderFields
112+
.compactMapValues { "\($0)" }
113+
.sorted { "\($0.key)" < "\($1.key)" }
114+
.map { "\($0.key)=\(self.shouldRedactHeader("\($0.key)") ? "[redacted]" : $0.value)" }
115+
.joined(separator: ", ")
116+
117+
// Format body for logging
118+
var bodyString = "No body"
119+
if !data.isEmpty,
120+
let body = String(data: data, encoding: .utf8)
121+
{
122+
bodyString = body
123+
}
124+
125+
// Log with structured data
126+
self.logger.info(
127+
"[RESTClient] Response \(statusCode, privacy: .public) from '\(url, privacy: .public)'"
128+
)
129+
self.logger.debug("Headers: \(headers, privacy: .private)")
130+
self.logger.debug("Body: \(bodyString, privacy: .private)")
131+
}
132+
133+
/// Determines whether a header should be redacted for security.
134+
///
135+
/// - Parameter headerName: The header name to check.
136+
/// - Returns: `true` if the header should be redacted when `redactAuthHeaders` is enabled.
137+
private func shouldRedactHeader(_ headerName: String) -> Bool {
138+
guard self.redactAuthHeaders else { return false }
139+
140+
let lowercasedName = headerName.lowercased()
141+
142+
// Exact header name matches
143+
let exactMatches = [
144+
"authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token",
145+
"x-access-token", "bearer", "apikey", "api-key", "access-token",
146+
"refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id",
147+
]
148+
149+
// Substring patterns that indicate sensitive content
150+
let sensitivePatterns = ["password", "secret", "token"]
151+
152+
return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) }
153+
}
154+
}
155+
#endif

0 commit comments

Comments
 (0)