From 36fc498038ed48e670240b99dc681e7c3bf2882f Mon Sep 17 00:00:00 2001 From: Jason Shiflet <387269+jshiflet@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:46:42 -0500 Subject: [PATCH 1/2] Added JSONDecoder to fix server crash when creating a to-do item in Home Assistant integration When creating a to-do item in Home Assistant, the ha-reminders-cli integration will error with one of two errors - Error during service call to todo.add_item: Failed to create reminder: Server disconnected - Error during service call to todo.add_item: Failed to create reminder: 502, message='Bad Gateway', url='https://[reverseProxyURL:port]/api/lists/[listName]/reminders' This also causes the reminders-api process to crash on the macOS device with the following backtrace: - NullDecoder.decode(...) CodableProtocols.swift:48 preconditionFailure("HBApplication.decoder has not been set") Adding middleware JSON decoder resolves the base crash problem and allows the to-do item to be created Co-authored-by: codex --- Sources/reminders-api/main.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/reminders-api/main.swift b/Sources/reminders-api/main.swift index 0c2e136..afca5e2 100644 --- a/Sources/reminders-api/main.swift +++ b/Sources/reminders-api/main.swift @@ -158,6 +158,11 @@ func startServer( app.encoder = jsonEncoder Logger.shared.debug("JSON encoder configured") + // Middleware for JSON response decoding + let jsonDecoder = JSONDecoder() + app.decoder = jsonDecoder + Logger.shared.debug("JSON decoder configured") + // Add CORS middleware app.middleware.add( HBCORSMiddleware( From 0c6c642a4e0886a964240ba0733b7f3a57338ee6 Mon Sep 17 00:00:00 2001 From: Jason Shiflet <387269+jshiflet@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:35:48 -0500 Subject: [PATCH 2/2] Fix list-scoped reminder endpoints: UUID resolution and route matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Home Assistant integration references lists and reminders by their exposed UUIDs and updates items via PATCH /lists/:listName/reminders/:id, but every list-scoped call failed. Debugging the integration end to end surfaced three independent defects in the REST server: 1. List lookups were name-only and crashed on a miss. The list-scoped helpers resolved the calendar with calendar(withName:), so a UUID in the path never matched a list, and a missing list aborted the process instead of returning an error — surfacing to clients as "Server disconnected" / 502. Added resolveListCalendar(), which tries the calendar identifier (UUID) first, then a case-insensitive title match, and throws 404 on a miss. Lists can now be addressed by name or UUID, and a bad value can no longer take the server down. 2. Per-reminder lookups matched on the wrong identifier. delete / complete / uncomplete / update compared the path id against calendarItemExternalIdentifier, but the API exposes (and clients round- trip) uuid/externalId, which equals calendarItemIdentifier — a different value — so every list-scoped mutation returned 404 "Reminder not found." Added a shared resolveReminder() that looks the reminder up via getReminderByUUID() first (matching the canonical /reminders/:uuid routes) and falls back to the legacy external-identifier scan. Routed delete, setReminderComplete and updateReminder through it, and taught the update path to honor isCompleted. 3. A parameter-name collision made the nested routes unreachable. The list segment was registered as :name on GET/POST and :listName on DELETE/PATCH. Hummingbird keys the routing trie by parameter name, so it built two separate nodes at lists/; requests resolved into the :name node (which only carried GET and POST) while the entire reminders/:id subtree lived on the orphaned :listName node and was never reached — producing empty-body 404s before any handler ran. Standardized the segment to :listName on every route and updated the GET/POST handlers to read it, so one node carries the whole subtree. With all three fixed, create/read/update/delete and complete/uncomplete resolve by name or UUID and the integration's updates succeed. Co-authored-by: Claude --- Sources/reminders-api/main.swift | 257 ++++++++++++------------------- 1 file changed, 102 insertions(+), 155 deletions(-) diff --git a/Sources/reminders-api/main.swift b/Sources/reminders-api/main.swift index afca5e2..76871a7 100644 --- a/Sources/reminders-api/main.swift +++ b/Sources/reminders-api/main.swift @@ -292,8 +292,8 @@ func startServer( } // GET /lists/:name - Get reminders from a specific list - app.router.get("lists/:name") { request -> HBResponse in - guard let listName = request.parameters.get("name") else { + app.router.get("lists/:listName") { request -> HBResponse in + guard let listName = request.parameters.get("listName") else { Logger.shared.warn("Missing list name parameter in request") throw HBHTTPError(.badRequest, message: "Missing list name") } @@ -329,9 +329,9 @@ func startServer( } // POST /lists/:name/reminders - Add a new reminder to a list - app.router.post("lists/:name/reminders") { request -> HBResponse in + app.router.post("lists/:listName/reminders") { request -> HBResponse in - guard let listName = request.parameters.get("name") else { + guard let listName = request.parameters.get("listName") else { Logger.shared.warn("Missing list name parameter in POST request") throw HBHTTPError(.badRequest, message: "Missing list name") } @@ -441,6 +441,7 @@ func startServer( let notes: String? let dueDate: String? let priority: String? + let isCompleted: Bool? } let updateRequest = try request.decode(as: ReminderUpdateRequest.self) @@ -452,6 +453,7 @@ func startServer( notes: updateRequest.notes, dueDateString: updateRequest.dueDate, priority: updateRequest.priority, + isCompleted: updateRequest.isCompleted, remindersService: remindersService ) @@ -992,7 +994,7 @@ private func mapLogLevelToSwiftLogger(_ level: RemindersLibrary.LogLevel) -> Log func fetchReminders(from listName: String, displayOptions: DisplayOptions, remindersService: Reminders) async throws -> [EKReminder] { return try await withCheckedThrowingContinuation { continuation in do { - let calendar = try remindersService.calendar(withName: listName) + let calendar = try resolveListCalendar(identifier: listName, remindersService: remindersService) remindersService.reminders(on: [calendar], displayOptions: displayOptions) { reminders in continuation.resume(returning: reminders) @@ -1019,7 +1021,7 @@ func addReminder(title: String, notes: String?, listName: String, dueDateCompone priority: Priority, remindersService: Reminders) async throws -> EKReminder { return try await withCheckedThrowingContinuation { continuation in do { - let calendar = try remindersService.calendar(withName: listName) + let calendar = try resolveListCalendar(identifier: listName, remindersService: remindersService) let reminder = try remindersService.createReminder( title: title, notes: notes, @@ -1035,173 +1037,93 @@ func addReminder(title: String, notes: String?, listName: String, dueDateCompone } } -// Helper function to delete a reminder -func deleteReminder(id: String, listName: String, remindersService: Reminders) async throws { - return try await withCheckedThrowingContinuation { continuation in - do { - let calendar = try remindersService.calendar(withName: listName) - - // Handle both formats (with and without the protocol prefix) - let prefix = "x-apple-reminder://" - let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" +// Resolve a reminder by the identifier the API exposes as `uuid` / `externalId` +// (getReminderByUUID), falling back to the legacy calendarItemExternalIdentifier +// scan within the named list. Throws a 404 if nothing matches. Shared by the +// list-scoped delete / complete / update handlers so they all accept the same +// id that GET returns -- the previous external-identifier-only match never lined +// up with the exposed uuid, which is why those endpoints 404'd. +func resolveReminder(id: String, listName: String, remindersService: Reminders) async throws -> EKReminder { + // 1. Canonical UUID lookup (matches the /reminders/:uuid routes). + if let reminder = remindersService.getReminderByUUID(id) { + return reminder + } - remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in - // Try with the fully qualified ID first - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) { - do { - try remindersService.deleteReminder(reminder) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } + // 2. Legacy fallback: scan the named list by external identifier. + let calendar = try resolveListCalendar(identifier: listName, remindersService: remindersService) + let prefix = "x-apple-reminder://" + let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" + let match = await withCheckedContinuation { (continuation: CheckedContinuation) in + remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in + let found = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) + ?? reminders.first(where: { $0.calendarItemExternalIdentifier == id }) + continuation.resume(returning: found) + } + } - // For backward compatibility, try with the original ID string - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == id }) { - do { - try remindersService.deleteReminder(reminder) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } + guard let reminder = match else { + throw HBHTTPError(.notFound, message: "Reminder not found") + } + return reminder +} - continuation.resume(throwing: HBHTTPError(.notFound, message: "Reminder not found")) - } - } catch { - continuation.resume(throwing: error) - } +// Helper function to delete a reminder +func deleteReminder(id: String, listName: String, remindersService: Reminders) async throws { + let reminder = try await resolveReminder(id: id, listName: listName, remindersService: remindersService) + do { + try remindersService.deleteReminder(reminder) + } catch { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) } } // Helper function to mark a reminder as complete or incomplete func setReminderComplete(id: String, listName: String, complete: Bool, remindersService: Reminders) async throws { - return try await withCheckedThrowingContinuation { continuation in - do { - let calendar = try remindersService.calendar(withName: listName) - - // Handle both formats (with and without the protocol prefix) - let prefix = "x-apple-reminder://" - let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" - - remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in - // Try with the fully qualified ID first - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) { - do { - try remindersService.setReminderComplete(reminder, complete: complete) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } - - // For backward compatibility, try with the original ID string - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == id }) { - do { - try remindersService.setReminderComplete(reminder, complete: complete) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } - - continuation.resume(throwing: HBHTTPError(.notFound, message: "Reminder not found")) - } - } catch { - continuation.resume(throwing: error) - } + let reminder = try await resolveReminder(id: id, listName: listName, remindersService: remindersService) + do { + try remindersService.setReminderComplete(reminder, complete: complete) + } catch { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) } } // Helper function to update a reminder func updateReminder(id: String, listName: String, title: String?, notes: String?, - dueDateString: String?, priority: String?, remindersService: Reminders) async throws -> EKReminder { - return try await withCheckedThrowingContinuation { continuation in - do { - let calendar = try remindersService.calendar(withName: listName) - - // Handle both formats (with and without the protocol prefix) - let prefix = "x-apple-reminder://" - let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" + dueDateString: String?, priority: String?, isCompleted: Bool?, + remindersService: Reminders) async throws -> EKReminder { + let reminder = try await resolveReminder(id: id, listName: listName, remindersService: remindersService) - remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in - // Try with the fully qualified ID first - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) { - // Update fields if provided - if let title = title { - reminder.title = title - } - - if let notes = notes { - reminder.notes = notes - } - - if let dueDateString = dueDateString { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: dueDateString) { - reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) - } - } - - if let priorityString = priority { - if let priority = Priority(rawValue: priorityString) { - reminder.priority = Int(priority.value.rawValue) - } - } - - do { - try remindersService.updateReminder(reminder) - continuation.resume(returning: reminder) - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } - - // For backward compatibility, try with the original ID string - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == id }) { - // Update fields if provided - if let title = title { - reminder.title = title - } + // Apply the provided fields. + if let title = title { + reminder.title = title + } - if let notes = notes { - reminder.notes = notes - } + if let notes = notes { + reminder.notes = notes + } - if let dueDateString = dueDateString { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: dueDateString) { - reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) - } - } + if let dueDateString = dueDateString { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: dueDateString) { + reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) + } + } - if let priorityString = priority { - if let priority = Priority(rawValue: priorityString) { - reminder.priority = Int(priority.value.rawValue) - } - } + if let priorityString = priority, let priority = Priority(rawValue: priorityString) { + reminder.priority = Int(priority.value.rawValue) + } - do { - try remindersService.updateReminder(reminder) - continuation.resume(returning: reminder) - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } + if let isCompleted = isCompleted { + reminder.isCompleted = isCompleted + } - continuation.resume(throwing: HBHTTPError(.notFound, message: "Reminder not found")) - } - } catch { - continuation.resume(throwing: error) - } + // Persist the change. + do { + try remindersService.updateReminder(reminder) + return reminder + } catch { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) } } @@ -1314,6 +1236,31 @@ func parseSearchParameters(_ request: HBRequest) -> SearchParameters { ) } +// Resolve a reminder list (calendar) by either its UUID or its name. +// +// Order matters: we try the EventKit calendar identifier (UUID) first, then +// fall back to a case-insensitive title match. The name match is done against +// getCalendars() directly rather than via `calendar(withName:)`, because that +// shared-library call aborts the whole process (exit/precondition) when no list +// matches. Here a miss simply throws a 404, so an unknown or mistyped +// identifier can never take the server down. +func resolveListCalendar(identifier: String, remindersService: Reminders) throws -> EKCalendar { + // 1. Try as a UUID (EventKit calendarIdentifier). + if let calendar = remindersService.calendar(withUUID: identifier) { + return calendar + } + + // 2. Fall back to a case-insensitive list-name match. + if let calendar = remindersService.getCalendars().first(where: { + $0.title.caseInsensitiveCompare(identifier) == .orderedSame + }) { + return calendar + } + + // 3. Neither a known UUID nor a known name. + throw HBHTTPError(.notFound, message: "List '\(identifier)' not found") +} + // Helper function to resolve calendar by name or UUID func resolveCalendar(identifier: String, remindersService: Reminders) throws -> EKCalendar? { // First try as UUID @@ -1576,4 +1523,4 @@ func priorityFromRawValue(_ value: Int) -> Priority? { } // Run the Configuration command defined above -Configuration.main() +Configuration.main() \ No newline at end of file