From 903c7acfe4d7129e20e1303b1590d26b6c016b24 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:27:26 -0600 Subject: [PATCH 1/2] feat: add verbose logging to EditorHTTPClient on both platforms Logs request method, URL, headers, response status, size, and headers for every HTTP call. Network errors, HTTP errors, response bodies, and parsed WP error details are logged at the error level. Sensitive header values (Authorization, Cookie, Set-Cookie) are redacted. On Android, introduces EditorResponseData sealed class (Bytes/File) for the delegate callback, matching the existing iOS pattern. Also adds delegate notification after download requests complete. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/EditorHTTPClient.kt | 81 +++++++++++++++++-- .../gutenberg/EditorHTTPClientTest.kt | 7 +- .../Sources/EditorHTTPClient.swift | 59 +++++++++++++- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index d015ec94b..4c0623ded 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -24,13 +24,28 @@ interface EditorHTTPClientProtocol { suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse } +/** + * The response data from an HTTP request, either in-memory bytes or a downloaded file. + */ +sealed class EditorResponseData { + data class Bytes(val data: ByteArray) : EditorResponseData() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Bytes) return false + return data.contentEquals(other.data) + } + override fun hashCode(): Int = data.contentHashCode() + } + data class File(val file: java.io.File) : EditorResponseData() +} + /** * A delegate for observing HTTP requests made by the editor. * * Implement this interface to inspect or log all network requests. */ interface EditorHTTPClientDelegate { - fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) + fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) } /** @@ -141,18 +156,32 @@ class EditorHTTPClient( override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "DOWNLOAD $url") + Log.d(TAG, " Destination: ${destination.absolutePath}") + val request = Request.Builder() .url(url) .addHeader("Authorization", authHeader) .get() .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "DOWNLOAD $url – network error: ${e.message}", e) + throw e + } + val statusCode = response.code val headers = extractHeaders(response) + Log.d(TAG, "DOWNLOAD $url – $statusCode") + Log.d(TAG, " Response headers: ${redactHeaders(response.headers)}") if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error downloading $url: $statusCode") + Log.e(TAG, "DOWNLOAD $url – HTTP error: $statusCode") throw EditorHTTPClientError.DownloadFailed(statusCode) } @@ -163,8 +192,14 @@ class EditorHTTPClient( input.copyTo(output) } } - Log.d(TAG, "Downloaded file: file=${destination.absolutePath}, size=${destination.length()} bytes, url=$url") - } ?: throw EditorHTTPClientError.DownloadFailed(statusCode) + Log.d(TAG, "DOWNLOAD $url – complete (${destination.length()} bytes)") + Log.d(TAG, " Saved to: ${destination.absolutePath}") + } ?: run { + Log.e(TAG, "DOWNLOAD $url – empty response body") + throw EditorHTTPClientError.DownloadFailed(statusCode) + } + + delegate?.didPerformRequest(url, EditorHttpMethod.GET, response, EditorResponseData.File(destination)) EditorHTTPClientDownloadResponse( file = destination, @@ -175,6 +210,8 @@ class EditorHTTPClient( override suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "$method $url") + // OkHttp requires a body for POST, PUT, PATCH methods // GET, HEAD, OPTIONS, DELETE don't require a body val requiresBody = method in listOf( @@ -190,7 +227,15 @@ class EditorHTTPClient( .method(method.toString(), requestBody) .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "$method $url – network error: ${e.message}", e) + throw e + } // Note: This loads the entire response into memory. This is acceptable because // this method is only used for WordPress REST API responses (editor settings, post @@ -200,14 +245,22 @@ class EditorHTTPClient( val statusCode = response.code val headers = extractHeaders(response) - delegate?.didPerformRequest(url, method, response, data) + Log.d(TAG, "$method $url – $statusCode (${data.size} bytes)") + Log.d(TAG, " Response headers: ${redactHeaders(response.headers)}") + + delegate?.didPerformRequest(url, method, response, EditorResponseData.Bytes(data)) if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error fetching $url: $statusCode") + Log.e(TAG, "$method $url – HTTP error: $statusCode") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. + Log.e(TAG, " Response body: ${data.toString(Charsets.UTF_8)}") // Try to parse as WordPress error val wpError = tryParseWPError(data) if (wpError != null) { + Log.e(TAG, " WP error – code: ${wpError.code}, message: ${wpError.message}") throw EditorHTTPClientError.WPErrorResponse(wpError) } @@ -259,5 +312,17 @@ class EditorHTTPClient( companion object { private const val TAG = "EditorHTTPClient" private val gson = Gson() + + private val SENSITIVE_HEADERS = setOf("authorization", "cookie", "set-cookie") + + /** + * Returns a string representation of the given OkHttp headers with + * sensitive values (Authorization, Cookie) redacted. + */ + internal fun redactHeaders(headers: okhttp3.Headers): String { + return headers.joinToString(", ") { (name, value) -> + if (name.lowercase() in SENSITIVE_HEADERS) "$name: " else "$name: $value" + } + } } } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt index 667e00ea8..ebc480b32 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt @@ -263,10 +263,10 @@ class EditorHTTPClientTest { var delegateCalled = false var capturedUrl: String? = null var capturedMethod: EditorHttpMethod? = null - var capturedData: ByteArray? = null + var capturedData: EditorResponseData? = null val delegate = object : EditorHTTPClientDelegate { - override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) { + override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) { delegateCalled = true capturedUrl = url capturedMethod = method @@ -280,7 +280,8 @@ class EditorHTTPClientTest { assertTrue(delegateCalled) assertTrue(capturedUrl?.contains("test") == true) assertEquals(EditorHttpMethod.GET, capturedMethod) - assertEquals("response data", capturedData?.toString(Charsets.UTF_8)) + val bytes = (capturedData as? EditorResponseData.Bytes)?.data + assertEquals("response data", bytes?.toString(Charsets.UTF_8)) } @Test diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 431a03828..0f96d451a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -73,15 +73,34 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (data, response) = try await self.urlSession.data(for: configuredRequest) + let url = configuredRequest.url!.absoluteString + let method = configuredRequest.httpMethod ?? "GET" + Logger.http.debug("📡 \(method) \(url)") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await self.urlSession.data(for: configuredRequest) + } catch { + Logger.http.error("📡 \(method) \(url) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 \(method) \(url) – \(httpResponse.statusCode) (\(data.count) bytes)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 \(method) \(url) – HTTP error: \(httpResponse.statusCode)") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. + Logger.http.error("📡 Response body: \(String(data: data, encoding: .utf8) ?? "")") if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { + Logger.http.error("📡 WP error – code: \(wpError.code), message: \(wpError.message)") throw ClientError.wpError(wpError) } @@ -94,13 +113,27 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + let requestURL = configuredRequest.url!.absoluteString + Logger.http.debug("📡 DOWNLOAD \(requestURL)") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") + + let (url, response): (URL, URLResponse) + do { + (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + } catch { + Logger.http.error("📡 DOWNLOAD \(requestURL) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .file(url)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 DOWNLOAD \(requestURL) – \(httpResponse.statusCode)") + Logger.http.debug("📡 Downloaded to: \(url.path)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 DOWNLOAD \(requestURL) – HTTP error: \(httpResponse.statusCode)") throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) } @@ -108,6 +141,24 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { return (url, response as! HTTPURLResponse) } + private static let sensitiveHeaders: Set = ["authorization", "cookie", "set-cookie"] + + private func redactHeaders(_ headers: [String: String]?) -> String { + guard let headers else { return "[:]" } + let redacted = headers.map { key, value in + Self.sensitiveHeaders.contains(key.lowercased()) ? "\(key): " : "\(key): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + + private func redactHeaders(_ headers: [AnyHashable: Any]) -> String { + let redacted = headers.map { key, value in + let name = "\(key)" + return Self.sensitiveHeaders.contains(name.lowercased()) ? "\(name): " : "\(name): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + private func configureRequest(_ request: URLRequest) -> URLRequest { var mutableRequest = request mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") From 9ee0d20134e02a6b08a6a0328cbb3794f466bf1b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:27:40 -0600 Subject: [PATCH 2/2] docs: clarify that enableNetworkLogging controls editor-to-host logging This flag enables the JavaScript editor to surface network details to the native host app via the bridge. It does not control the native EditorHTTPClient's own debug-level logging. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/gutenberg/model/EditorConfiguration.kt | 6 ++++++ .../GutenbergKit/Sources/Model/EditorConfiguration.swift | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 68dec2bca..e9e2abec5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -27,6 +27,12 @@ data class EditorConfiguration( val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, + /** + * Enables the JavaScript editor to surface network request/response details + * to the native host app (via the bridge). This does **not** control the + * native [EditorHTTPClient][org.wordpress.gutenberg.EditorHTTPClient]'s own + * debug logging, which always runs at the platform debug level. + */ val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false ): Parcelable { diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 92d8bfa37..173197654 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -57,7 +57,10 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let editorAssetsEndpoint: URL? /// Logs emitted at or above this level will be printed to the debug console public let logLevel: EditorLogLevel - /// Enables logging of all network requests/responses to the native host + /// Enables the JavaScript editor to surface network request/response details + /// to the native host app (via the bridge). This does **not** control the + /// native `EditorHTTPClient`'s own debug logging, which always runs at the + /// platform debug level and is stripped from release builds. public let enableNetworkLogging: Bool /// Don't make HTTP requests public let isOfflineModeEnabled: Bool