From 8388cc789d8d75139577a35d6080ac69285490e8 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 16 Jun 2026 21:20:17 -0400 Subject: [PATCH] Split WebHookManager into delivery client and route factory (#66) Extract HTTP/signing/retry logic into WebhookDeliveryClient and route wiring into WebhookRouteFactory; WebHookManager becomes a thin coordinator. sendTest() in the management UI now uses the production delivery path. Co-Authored-By: Claude Sonnet 4.6 --- Jared.xcodeproj/project.pbxproj | 12 ++ Jared/WebHookManager.swift | 179 ++--------------- Jared/WebhookDeliveryClient.swift | 187 ++++++++++++++++++ Jared/WebhookRouteFactory.swift | 24 +++ JaredTests/WebhookDeliveryTests.swift | 12 +- .../WebhookManagementWindowController.swift | 39 +--- 6 files changed, 250 insertions(+), 203 deletions(-) create mode 100644 Jared/WebhookDeliveryClient.swift create mode 100644 Jared/WebhookRouteFactory.swift diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index ac1b05e..c5cc550 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ B33278811CB2CB1300BBDD3A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B332787F1CB2CB1300BBDD3A /* Main.storyboard */; }; B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC32206607B009DD882 /* ContactHelper.swift */; }; B3327DC622066427009DD882 /* WebHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC522066427009DD882 /* WebHookManager.swift */; }; + EC66DC00000000000000A002 /* WebhookDeliveryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */; }; + EC66DC00000000000000A005 /* WebhookRouteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A004 /* WebhookRouteFactory.swift */; }; EC000001000000000000AA40 /* ScheduleModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA43 /* ScheduleModule.swift */; }; EC000001000000000000AA41 /* ScheduleModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA43 /* ScheduleModule.swift */; }; EC000001000000000000AA44 /* ScheduleModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA45 /* ScheduleModuleTests.swift */; }; @@ -113,6 +115,8 @@ B3BF17CA244FDA3F00CC44C5 /* MessageDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C5244EC47700CC44C5 /* MessageDelegate.swift */; }; B3C59284224DD17900116ECB /* JaredWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C59283224DD17900116ECB /* JaredWebServer.swift */; }; B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC522066427009DD882 /* WebHookManager.swift */; }; + EC66DC00000000000000A003 /* WebhookDeliveryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */; }; + EC66DC00000000000000A006 /* WebhookRouteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A004 /* WebhookRouteFactory.swift */; }; B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DA3220687DD00953DE7 /* URLProtocolMock.swift */; }; B3EF1DA62206DFEA00953DE7 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DA52206DFEA00953DE7 /* Route.swift */; }; B3EF1DAC2206E17900953DE7 /* Entities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DAB2206E17900953DE7 /* Entities.swift */; }; @@ -199,6 +203,8 @@ B33278821CB2CB1300BBDD3A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B3327DC32206607B009DD882 /* ContactHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactHelper.swift; sourceTree = ""; }; B3327DC522066427009DD882 /* WebHookManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebHookManager.swift; sourceTree = ""; }; + EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookDeliveryClient.swift; sourceTree = ""; }; + EC66DC00000000000000A004 /* WebhookRouteFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookRouteFactory.swift; sourceTree = ""; }; EC000001000000000000AA43 /* ScheduleModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModule.swift; sourceTree = ""; }; EC000001000000000000AA45 /* ScheduleModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModuleTests.swift; sourceTree = ""; }; EC000001000000000000AA53 /* MessageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSource.swift; sourceTree = ""; }; @@ -459,6 +465,8 @@ B3BF17C1244EBE9A00CC44C5 /* Router.swift */, B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */, B3327DC522066427009DD882 /* WebHookManager.swift */, + EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */, + EC66DC00000000000000A004 /* WebhookRouteFactory.swift */, EC000001000000000000AA43 /* ScheduleModule.swift */, EC000001000000000000AA53 /* MessageSource.swift */, EC000001000000000000AA57 /* InMemoryMessageSource.swift */, @@ -777,6 +785,8 @@ B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */, B332787C1CB2CB1300BBDD3A /* ViewController.swift in Sources */, B3327DC622066427009DD882 /* WebHookManager.swift in Sources */, + EC66DC00000000000000A002 /* WebhookDeliveryClient.swift in Sources */, + EC66DC00000000000000A005 /* WebhookRouteFactory.swift in Sources */, EC000001000000000000AA40 /* ScheduleModule.swift in Sources */, EC000001000000000000AA50 /* MessageSource.swift in Sources */, EC000001000000000000AA54 /* InMemoryMessageSource.swift in Sources */, @@ -856,6 +866,8 @@ B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */, B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */, B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */, + EC66DC00000000000000A003 /* WebhookDeliveryClient.swift in Sources */, + EC66DC00000000000000A006 /* WebhookRouteFactory.swift in Sources */, B347A4AF24DD1CD000E93657 /* GlobalTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Jared/WebHookManager.swift b/Jared/WebHookManager.swift index 9776e57..1a32634 100644 --- a/Jared/WebHookManager.swift +++ b/Jared/WebHookManager.swift @@ -9,48 +9,34 @@ import Foundation import JaredFramework import os -import CryptoKit private let logger = Logger(subsystem: "com.zekesnider.jared", category: "webhooks") class WebHookManager: MessageDelegate, RoutingModule { - var urlSession: URLSession var webhooks = [RichWebhook]() var routes = [Route]() var sender: MessageSender - var keychain: KeychainAccessor var description = "Routes provided by webhooks" - /// Base nanosecond unit for retry backoff (2^attempt × base). Override in tests for speed. - var retryDelayBase: UInt64 = 1_000_000_000 - /// On-disk delivery log (newest first, capped). Survives app restarts so the - /// management UI can show history across launches. let deliveryStore: WebhookDeliveryStore - - /// Snapshot of persisted deliveries, newest first. Re-read from disk on access - /// so callers see records written by deliveries that happened in this session. var deliveryLog: [WebhookDeliveryRecord] { deliveryStore.load() } - private func record(_ webhookURL: String, deliveryId: String, attempt: Int, - statusCode: Int? = nil, error: String? = nil) { - let rec = WebhookDeliveryRecord(deliveryId: deliveryId, webhookURL: webhookURL, - date: Date(), statusCode: statusCode, - errorDescription: error, attempt: attempt) - deliveryStore.append(rec) - DispatchQueue.main.async { - NotificationCenter.default.post(name: .webhookDelivered, object: self, - userInfo: ["url": webhookURL]) - } + var client: WebhookDeliveryClient + private var routeFactory: WebhookRouteFactory + + var retryDelayBase: UInt64 { + get { client.retryDelayBase } + set { client.retryDelayBase = newValue } } public init(webhooks: [RichWebhook]?, session: URLSessionConfiguration = .ephemeral, sender: MessageSender, keychain: KeychainAccessor = KeychainStore(), deliveryStore: WebhookDeliveryStore = WebHookManager.defaultDeliveryStore()) { - session.timeoutIntervalForResource = 10.0 self.sender = sender - self.keychain = keychain self.deliveryStore = deliveryStore - urlSession = URLSession(configuration: session) + self.client = WebhookDeliveryClient(session: session, keychain: keychain, + sender: sender, deliveryStore: deliveryStore) + self.routeFactory = WebhookRouteFactory(client: self.client) updateHooks(to: webhooks) } @@ -78,80 +64,13 @@ class WebHookManager: MessageDelegate, RoutingModule { inFlight -= 1 } let w = webhook - group.addTask { await self.deliverWebhook(w, message: message) } + group.addTask { await self.client.deliver(w, message: message) } inFlight += 1 } } } } - // MARK: - Delivery - - func deliverWebhook(_ webhook: RichWebhook, message: Message) async { - guard let parsedUrl = URL(string: webhook.url) else { - logger.error("Webhook \(webhook.url, privacy: .public): invalid URL — skipping delivery") - return - } - - guard let webhookBody = WebHookManager.createWebhookBody(message) else { - logger.error("Webhook \(webhook.url, privacy: .public): failed to encode message body") - return - } - - let deliveryId = UUID().uuidString - let maxRetries = webhook.effectiveMaxRetries - - for attempt in 0...maxRetries { - var request = WebHookManager.makeDeliveryRequest( - url: parsedUrl, - webhook: webhook, - body: webhookBody, - deliveryId: deliveryId, - keychain: keychain - ) - - do { - let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return } - - if (400...499).contains(httpResponse.statusCode) { - logger.warning("Webhook \(webhook.url, privacy: .public): 4xx \(httpResponse.statusCode, privacy: .public) — not retrying") - record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, statusCode: httpResponse.statusCode) - return - } - - if (200...299).contains(httpResponse.statusCode) { - logger.notice("Webhook \(webhook.url, privacy: .public): delivered (attempt \(attempt + 1, privacy: .public), status \(httpResponse.statusCode, privacy: .public))") - record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, statusCode: httpResponse.statusCode) - if webhook.mode == .command { - guard let decoded = try? JSONDecoder().decode(WebhookResponse.self, from: data) else { - logger.warning("Webhook \(webhook.url, privacy: .public): unable to parse command response") - return - } - if decoded.success, let replyText = decoded.body?.message { - sender.send(replyText, to: message.RespondTo()) - } else if let decodedError = decoded.error { - logger.warning("Webhook \(webhook.url, privacy: .public): error response: \(decodedError, privacy: .public)") - } - } - return - } - - logger.warning("Webhook \(webhook.url, privacy: .public): status \(httpResponse.statusCode, privacy: .public) on attempt \(attempt + 1, privacy: .public)") - } catch { - logger.error("Webhook \(webhook.url, privacy: .public): request failed on attempt \(attempt + 1, privacy: .public): \(error.localizedDescription, privacy: .public)") - record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, error: error.localizedDescription) - } - - if attempt < maxRetries { - let backoffNs = UInt64(pow(2.0, Double(attempt))) * retryDelayBase - try? await Task.sleep(nanoseconds: backoffNs) - } - } - - logger.error("Webhook \(webhook.url, privacy: .public): all \(maxRetries + 1, privacy: .public) attempt(s) exhausted") - } - // MARK: - Configuration public func updateHooks(to hooks: [RichWebhook]?) { @@ -159,89 +78,17 @@ class WebHookManager: MessageDelegate, RoutingModule { var newHook = hook // Persist inline auth.secret to Keychain on first load if let inlineSecret = newHook.auth?.secret { - keychain.save(secret: inlineSecret, for: newHook.url) + client.keychain.save(secret: inlineSecret, for: newHook.url) } // Signing is keyed by Keychain; keep auth enabled when a secret exists - if newHook.auth == nil, keychain.secret(for: newHook.url) != nil { + if newHook.auth == nil, client.keychain.secret(for: newHook.url) != nil { newHook.auth = WebhookAuth(secret: nil) } - newHook.routes = (newHook.routes ?? []).map { route in - var newRoute = route - newRoute.call = { [weak self] msg in - Task { await self?.deliverWebhook(newHook, message: msg) } - } - return newRoute - } + newHook.routes = routeFactory.routes(from: newHook) return newHook } self.routes = self.webhooks.flatMap { $0.routes ?? [] } UserDefaults.standard.set(self.webhooks.count, forKey: JaredConstants.webhookCount) logger.notice("Webhooks updated: \(self.webhooks.map { $0.url }.joined(separator: ", "), privacy: .public)") } - - static func createWebhookBody(_ message: Message) -> Data? { - try? JSONEncoder().encode(message) - } - - /// Test payload uses the same Message JSON shape as production, plus `_jared_test: true`. - static func createTestWebhookBody() -> Data? { - let message = Message( - body: TextBody("/test"), - date: Date(), - sender: Person(givenName: "Test", handle: "test@example.com", isMe: false), - recipient: Person(givenName: "Test", handle: "test@example.com", isMe: true) - ) - guard let base = createWebhookBody(message), - var json = String(data: base, encoding: .utf8), - json.hasSuffix("}") else { - return nil - } - // Append without JSONSerialization round-trip so key order matches JSONEncoder output. - json.removeLast() - json += ",\"_jared_test\":true}" - return json.data(using: .utf8) - } - - static func richWebhook(from dictionary: [String: Any]) -> RichWebhook? { - guard let data = try? JSONSerialization.data(withJSONObject: dictionary) else { return nil } - return try? JSONDecoder().decode(RichWebhook.self, from: data) - } - - /// Resolves signing from config `auth` or an existing Keychain secret. - static func richWebhookForDelivery(from dictionary: [String: Any], keychain: KeychainAccessor) -> RichWebhook? { - guard var webhook = richWebhook(from: dictionary) else { return nil } - if webhook.auth == nil, keychain.secret(for: webhook.url) != nil { - webhook.auth = WebhookAuth(secret: nil) - } - return webhook - } - - static func makeDeliveryRequest( - url: URL, - webhook: RichWebhook, - body: Data, - deliveryId: String, - keychain: KeychainAccessor - ) -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = body - request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.addValue(deliveryId, forHTTPHeaderField: "X-Jared-Delivery-Id") - request.addValue(webhook.url, forHTTPHeaderField: "X-Jared-Webhook-Id") - request.timeoutInterval = webhook.effectiveTimeout - - if webhook.auth != nil { - if let secret = keychain.secret(for: webhook.url) { - let key = SymmetricKey(data: Data(secret.utf8)) - let sig = HMAC.authenticationCode(for: body, using: key) - let hexSig = sig.map { String(format: "%02x", $0) }.joined() - request.addValue("sha256=\(hexSig)", forHTTPHeaderField: "X-Jared-Signature") - } else { - logger.warning("Webhook \(webhook.url, privacy: .public): auth configured but no Keychain secret found — delivering unsigned") - } - } - - return request - } } diff --git a/Jared/WebhookDeliveryClient.swift b/Jared/WebhookDeliveryClient.swift new file mode 100644 index 0000000..2d90e74 --- /dev/null +++ b/Jared/WebhookDeliveryClient.swift @@ -0,0 +1,187 @@ +// +// WebhookDeliveryClient.swift +// Jared +// + +import Foundation +import JaredFramework +import os +import CryptoKit + +private let logger = Logger(subsystem: "com.zekesnider.jared", category: "webhooks") + +class WebhookDeliveryClient { + var urlSession: URLSession + let keychain: KeychainAccessor + /// Nil in contexts that don't handle iMessage replies (e.g. management UI test sends). + let sender: MessageSender? + let deliveryStore: WebhookDeliveryStore + /// Base nanosecond unit for retry backoff (2^attempt × base). Override in tests for speed. + var retryDelayBase: UInt64 = 1_000_000_000 + + init(session: URLSessionConfiguration = .ephemeral, + keychain: KeychainAccessor, + sender: MessageSender? = nil, + deliveryStore: WebhookDeliveryStore) { + session.timeoutIntervalForResource = 10.0 + self.keychain = keychain + self.sender = sender + self.deliveryStore = deliveryStore + urlSession = URLSession(configuration: session) + } + + // MARK: - Delivery + + func deliver(_ webhook: RichWebhook, message: Message) async { + guard let body = Self.createWebhookBody(message) else { + logger.error("Webhook \(webhook.url, privacy: .public): failed to encode message body") + return + } + await deliverCore(body: body, webhook: webhook, replyTo: message.RespondTo()) + } + + /// Single-shot delivery for test payloads: same signing/retry/recording path, no iMessage reply. + func deliverTestBody(_ body: Data, to webhook: RichWebhook) async { + await deliverCore(body: body, webhook: webhook, replyTo: nil as RecipientEntity?) + } + + private func deliverCore(body: Data, webhook: RichWebhook, replyTo: RecipientEntity?) async { + guard let parsedUrl = URL(string: webhook.url) else { + logger.error("Webhook \(webhook.url, privacy: .public): invalid URL — skipping delivery") + return + } + + let deliveryId = UUID().uuidString + let maxRetries = webhook.effectiveMaxRetries + + for attempt in 0...maxRetries { + let request = Self.makeDeliveryRequest( + url: parsedUrl, + webhook: webhook, + body: body, + deliveryId: deliveryId, + keychain: keychain + ) + + do { + let (data, response) = try await urlSession.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return } + + if (400...499).contains(httpResponse.statusCode) { + logger.warning("Webhook \(webhook.url, privacy: .public): 4xx \(httpResponse.statusCode, privacy: .public) — not retrying") + record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, statusCode: httpResponse.statusCode) + return + } + + if (200...299).contains(httpResponse.statusCode) { + logger.notice("Webhook \(webhook.url, privacy: .public): delivered (attempt \(attempt + 1, privacy: .public), status \(httpResponse.statusCode, privacy: .public))") + record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, statusCode: httpResponse.statusCode) + if webhook.mode == .command, let replyTo, let sender { + guard let decoded = try? JSONDecoder().decode(WebhookResponse.self, from: data) else { + logger.warning("Webhook \(webhook.url, privacy: .public): unable to parse command response") + return + } + if decoded.success, let replyText = decoded.body?.message { + sender.send(replyText, to: replyTo) + } else if let decodedError = decoded.error { + logger.warning("Webhook \(webhook.url, privacy: .public): error response: \(decodedError, privacy: .public)") + } + } + return + } + + logger.warning("Webhook \(webhook.url, privacy: .public): status \(httpResponse.statusCode, privacy: .public) on attempt \(attempt + 1, privacy: .public)") + } catch { + logger.error("Webhook \(webhook.url, privacy: .public): request failed on attempt \(attempt + 1, privacy: .public): \(error.localizedDescription, privacy: .public)") + record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, error: error.localizedDescription) + } + + if attempt < maxRetries { + let backoffNs = UInt64(pow(2.0, Double(attempt))) * retryDelayBase + try? await Task.sleep(nanoseconds: backoffNs) + } + } + + logger.error("Webhook \(webhook.url, privacy: .public): all \(maxRetries + 1, privacy: .public) attempt(s) exhausted") + } + + private func record(_ webhookURL: String, deliveryId: String, attempt: Int, + statusCode: Int? = nil, error: String? = nil) { + let rec = WebhookDeliveryRecord(deliveryId: deliveryId, webhookURL: webhookURL, + date: Date(), statusCode: statusCode, + errorDescription: error, attempt: attempt) + deliveryStore.append(rec) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .webhookDelivered, object: self, + userInfo: ["url": webhookURL]) + } + } + + // MARK: - Static helpers + + static func createWebhookBody(_ message: Message) -> Data? { + try? JSONEncoder().encode(message) + } + + /// Test payload uses the same Message JSON shape as production, plus `_jared_test: true`. + static func createTestWebhookBody() -> Data? { + let message = Message( + body: TextBody("/test"), + date: Date(), + sender: Person(givenName: "Test", handle: "test@example.com", isMe: false), + recipient: Person(givenName: "Test", handle: "test@example.com", isMe: true) + ) + guard let base = createWebhookBody(message), + var json = String(data: base, encoding: .utf8), + json.hasSuffix("}") else { + return nil + } + // Append without JSONSerialization round-trip so key order matches JSONEncoder output. + json.removeLast() + json += ",\"_jared_test\":true}" + return json.data(using: .utf8) + } + + static func makeDeliveryRequest( + url: URL, + webhook: RichWebhook, + body: Data, + deliveryId: String, + keychain: KeychainAccessor + ) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.addValue(deliveryId, forHTTPHeaderField: "X-Jared-Delivery-Id") + request.addValue(webhook.url, forHTTPHeaderField: "X-Jared-Webhook-Id") + request.timeoutInterval = webhook.effectiveTimeout + + if webhook.auth != nil { + if let secret = keychain.secret(for: webhook.url) { + let key = SymmetricKey(data: Data(secret.utf8)) + let sig = HMAC.authenticationCode(for: body, using: key) + let hexSig = sig.map { String(format: "%02x", $0) }.joined() + request.addValue("sha256=\(hexSig)", forHTTPHeaderField: "X-Jared-Signature") + } else { + logger.warning("Webhook \(webhook.url, privacy: .public): auth configured but no Keychain secret found — delivering unsigned") + } + } + + return request + } + + static func richWebhook(from dictionary: [String: Any]) -> RichWebhook? { + guard let data = try? JSONSerialization.data(withJSONObject: dictionary) else { return nil } + return try? JSONDecoder().decode(RichWebhook.self, from: data) + } + + /// Resolves signing from config `auth` or an existing Keychain secret. + static func richWebhookForDelivery(from dictionary: [String: Any], keychain: KeychainAccessor) -> RichWebhook? { + guard var webhook = richWebhook(from: dictionary) else { return nil } + if webhook.auth == nil, keychain.secret(for: webhook.url) != nil { + webhook.auth = WebhookAuth(secret: nil) + } + return webhook + } +} diff --git a/Jared/WebhookRouteFactory.swift b/Jared/WebhookRouteFactory.swift new file mode 100644 index 0000000..1a9cabf --- /dev/null +++ b/Jared/WebhookRouteFactory.swift @@ -0,0 +1,24 @@ +// +// WebhookRouteFactory.swift +// Jared +// + +import Foundation +import JaredFramework + +/// Maps a `RichWebhook` into `[Route]` with live delivery handlers, eliminating the +/// "patch empty closure at runtime" pattern that previously lived in `updateHooks`. +struct WebhookRouteFactory { + let client: WebhookDeliveryClient + + func routes(from webhook: RichWebhook) -> [Route] { + (webhook.routes ?? []).map { route in + var r = route + let hook = webhook + r.call = { [client] msg in + Task { await client.deliver(hook, message: msg) } + } + return r + } + } +} diff --git a/JaredTests/WebhookDeliveryTests.swift b/JaredTests/WebhookDeliveryTests.swift index 50467e3..155b2f1 100644 --- a/JaredTests/WebhookDeliveryTests.swift +++ b/JaredTests/WebhookDeliveryTests.swift @@ -108,7 +108,7 @@ class WebhookDeliveryTests: XCTestCase { } func testTestWebhookBodyUsesProductionShape() { - guard let body = WebHookManager.createTestWebhookBody(), + guard let body = WebhookDeliveryClient.createTestWebhookBody(), let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { XCTFail("Failed to build test webhook body"); return } @@ -121,7 +121,7 @@ class WebhookDeliveryTests: XCTestCase { } func testTestWebhookBodyPreservesEncoderKeyOrder() { - guard let body = WebHookManager.createTestWebhookBody(), + guard let body = WebhookDeliveryClient.createTestWebhookBody(), let bodyString = String(data: body, encoding: .utf8) else { XCTFail("Failed to build test webhook body"); return } @@ -134,12 +134,12 @@ class WebhookDeliveryTests: XCTestCase { func testMakeDeliveryRequestMatchesProductionHeaders() { keychain.save(secret: "test-secret", for: WEBHOOK_URL) let webhook = RichWebhook(url: WEBHOOK_URL, auth: WebhookAuth(secret: "test-secret")) - guard let body = WebHookManager.createTestWebhookBody(), + guard let body = WebhookDeliveryClient.createTestWebhookBody(), let url = URL(string: WEBHOOK_URL) else { XCTFail("Failed to build test request inputs"); return } - let request = WebHookManager.makeDeliveryRequest( + let request = WebhookDeliveryClient.makeDeliveryRequest( url: url, webhook: webhook, body: body, @@ -166,7 +166,7 @@ class WebhookDeliveryTests: XCTestCase { keychain.save(secret: "keychain-only", for: WEBHOOK_URL) let dict: [String: Any] = ["url": WEBHOOK_URL, "enabled": true] - let webhook = WebHookManager.richWebhookForDelivery(from: dict, keychain: keychain) + let webhook = WebhookDeliveryClient.richWebhookForDelivery(from: dict, keychain: keychain) XCTAssertNotNil(webhook?.auth, "Delivery helper should enable signing from Keychain") } @@ -501,7 +501,7 @@ class WebhookDeliveryTests: XCTestCase { sender: sender, keychain: keychain, deliveryStore: WebhookDeliveryStore(fileURL: fileURL)) - let exp = expectation(forNotification: .webhookDelivered, object: wm) + let exp = expectation(forNotification: .webhookDelivered, object: wm.client) wm.didProcess(message: SAMPLE_MESSAGE) wait(for: [exp], timeout: 5) diff --git a/JaredUI/WebhookManagementWindowController.swift b/JaredUI/WebhookManagementWindowController.swift index d407a18..44dd0d9 100644 --- a/JaredUI/WebhookManagementWindowController.swift +++ b/JaredUI/WebhookManagementWindowController.swift @@ -799,43 +799,20 @@ class WebhookManagementWindowController: NSWindowController, @objc private func sendTest() { guard selectedRow >= 0, selectedRow < webhooks.count, - let hook = WebHookManager.richWebhookForDelivery(from: webhooks[selectedRow], keychain: keychain), - let url = URL(string: hook.url), - let body = WebHookManager.createTestWebhookBody() else { return } - - deliveryStatus.stringValue = "Sending test payload…" - deliveryStatus.textColor = .secondaryLabelColor + let hook = WebhookDeliveryClient.richWebhookForDelivery(from: webhooks[selectedRow], keychain: keychain), + let body = WebhookDeliveryClient.createTestWebhookBody() else { return } if let inlineSecret = hook.auth?.secret { keychain.save(secret: inlineSecret, for: hook.url) } - let request = WebHookManager.makeDeliveryRequest( - url: url, - webhook: hook, - body: body, - deliveryId: UUID().uuidString, - keychain: keychain - ) + deliveryStatus.stringValue = "Sending test payload…" + deliveryStatus.textColor = .secondaryLabelColor - URLSession.shared.dataTask(with: request) { [weak self] _, response, error in - DispatchQueue.main.async { - guard let self else { return } - if let error { - self.deliveryStatus.stringValue = "Error: \(error.localizedDescription)" - self.deliveryStatus.textColor = .systemRed - return - } - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - if (200...299).contains(code) { - self.deliveryStatus.stringValue = "Test delivered ✓ \(code)" - self.deliveryStatus.textColor = .systemGreen - } else { - self.deliveryStatus.stringValue = "Test failed ✗ HTTP \(code)" - self.deliveryStatus.textColor = .systemOrange - } - } - }.resume() + let client = WebhookDeliveryClient(keychain: keychain, deliveryStore: deliveryStore) + Task { + await client.deliverTestBody(body, to: hook) + } } @objc private func rotateHMACSecret() {