From bb6bf9f2d7c34d00064d6c867d065eb2048bbad8 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:21:43 +0100 Subject: [PATCH 01/22] endpoint enum --- BeeKit/Managers/CurrentUserManager.swift | 6 +-- BeeKit/Managers/RequestManager.swift | 68 +++++++++++++++++++++++- BeeKit/Managers/VersionManager.swift | 2 +- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index da0df4487..ae19979c5 100644 --- a/BeeKit/Managers/CurrentUserManager.swift +++ b/BeeKit/Managers/CurrentUserManager.swift @@ -123,11 +123,7 @@ import SwiftyJSON } public func signInWithEmail(_ email: String, password: String) async { do { - let response = try await requestManager.post( - url: "api/private/sign_in", - parameters: ["user": ["login": email, "password": password], "beemios_secret": self.beemiosSecret] - as [String: Any] - ) + let response = try await requestManager.request(endpoint: .signIn(username: email, password: password, beemiosSecret: beemiosSecret)) try! await self.handleSuccessfulSignin(JSON(response!)) } catch { try! await self.handleFailedSignin(error, errorMessage: error.localizedDescription) } } diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 8113b6114..1965e0488 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -53,8 +53,13 @@ public class RequestManager { } let encoding: ParameterEncoding = if method == .get { URLEncoding.default } else { JSONEncoding.default } // TODO + + // TODO no longer needed once migrated to endpoint enum + let urlStr = urlWithSubstitutions.starts(with: baseURLString) ? urlWithSubstitutions : "\(baseURLString)/\(urlWithSubstitutions)" + + logger.debug("rawRequest: \(urlStr), method \(method.rawValue)") let response = await AF.request( - "\(baseURLString)/\(urlWithSubstitutions)", + urlStr, method: method, parameters: parameters, encoding: encoding, @@ -118,6 +123,14 @@ public class RequestManager { let params = ["urtext": urtext, "requestid": requestId].compactMapValues { $0 } return try await post(url: "api/v1/users/{username}/goals/\(slug)/datapoints.json", parameters: params) } + + public func request(endpoint: EndPoint) async throws -> Any? { + print("rawRequest: \(endpoint)") + return try await rawRequest(url: endpoint.url.absoluteString, + method: endpoint.method, + parameters: endpoint.parameters, + headers: authenticationHeaders()) + } } extension HTTPHeaders { @@ -128,3 +141,56 @@ extension HTTPHeaders { return HTTPHeaders(allHeaders) } } + + +public enum EndPoint { + case signIn(username: String, password: String, beemiosSecret: String) + + case appVersions + + var url: URL { + var urlComponents: URLComponents { + var urlComponents = URLComponents() + urlComponents.scheme = "https" + urlComponents.host = Config().apiHost + urlComponents.path = self.path + return urlComponents + } + + return urlComponents.url! + } + + var path: String { + return switch self { + case .signIn: + "/api/private/sign_in" + case .appVersions: + "/api/private/app_versions.json" + } + } + + var method: HTTPMethod { + return switch self { + case .signIn: + .post + case .appVersions: + .get + } + } + + var parameters: [String: Any]? { + return switch self { + case .signIn(let username, let password, let beemiosSecret): + ["user": + [ + "login": username, + "password": password + ], + "beemios_secret": beemiosSecret] + as [String: Any] + + default: + nil + } + } +} diff --git a/BeeKit/Managers/VersionManager.swift b/BeeKit/Managers/VersionManager.swift index 9a48371f8..d4b21d712 100644 --- a/BeeKit/Managers/VersionManager.swift +++ b/BeeKit/Managers/VersionManager.swift @@ -56,7 +56,7 @@ public class VersionManager { return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String } private func checkIfUpdateRequired() async throws -> Bool { - let responseJSON = try await requestManager.get(url: "api/private/app_versions.json") + let responseJSON = try await requestManager.request(endpoint: .appVersions) guard let response = JSON(responseJSON!).dictionary else { throw VersionError.invalidServerResponse } guard let minVersion = response["min_ios"]?.number?.decimalValue else { throw VersionError.noMinimumVersion } From b133e2c2710db338fca568cb7d7fa547333b7837 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:21:53 +0100 Subject: [PATCH 02/22] endpoint enum, most PUT --- BeeKit/Managers/DataPointManager.swift | 21 +-- BeeKit/Managers/GoalManager.swift | 25 ++-- BeeKit/Managers/RequestManager.swift | 137 ++++++++++++++++-- .../EditDatapointViewController.swift | 9 +- .../ConfigureHKMetricViewController.swift | 17 +-- ...itDefaultNotificationsViewController.swift | 18 +-- .../EditGoalNotificationsViewController.swift | 56 ++++--- 7 files changed, 193 insertions(+), 90 deletions(-) diff --git a/BeeKit/Managers/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 1480c4fb1..23d82e81c 100644 --- a/BeeKit/Managers/DataPointManager.swift +++ b/BeeKit/Managers/DataPointManager.swift @@ -30,11 +30,12 @@ import SwiftyJSON { let val = datapoint.value if datapointValue == val && comment == datapoint.comment { return } - let params = ["value": "\(datapointValue)", "comment": comment] - let _ = try await requestManager.put( - url: "api/v1/users/{username}/goals/\(goal.slug)/datapoints/\(datapoint.id).json", - parameters: params - ) + + let _ = try await requestManager.request(endpoint: .updateDatapoint(username: goal.owner.username, + goalname: goal.slug, + datapointID: datapoint.id, + value: datapointValue, + comment: comment)) } private func deleteDatapoint(goal: Goal, datapoint: DataPoint) async throws { @@ -48,11 +49,11 @@ import SwiftyJSON } private func fetchDatapoints(goal: Goal, sort: String, per: Int, page: Int) async throws -> [DataPoint] { - let params = ["sort": sort, "per": per, "page": page] as [String: Any] - let response = try await requestManager.get( - url: "api/v1/users/{username}/goals/\(goal.slug)/datapoints.json", - parameters: params - ) + let response = try await requestManager.request(endpoint: .getDatapoints(username: goal.owner.username, + goalname: goal.slug, + sort: sort, + page: page, + per: per)) let responseJSON = JSON(response!) return responseJSON.arrayValue.map({ DataPoint.fromJSON(context: modelContext, goal: goal, json: $0) }) diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 6d93a1fc4..6d29a5924 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -82,10 +82,8 @@ import SwiftyJSON logger.notice("Goals unknown, doing full fetch") // We must fetch the user object first, and then fetch goals afterwards, to guarantee User.updated_at is // a safe timestamp for future fetches without losing data - let userResponse = JSON(try await requestManager.get(url: "api/v1/users/{username}.json")!) - let goalResponse = JSON( - try await requestManager.get(url: "api/v1/users/{username}/goals.json", parameters: ["emaciated": "true"])! - ) + let userResponse = JSON(try await requestManager.request(endpoint: .getUser(username: user.username))!) + let goalResponse = JSON(try await requestManager.request(endpoint: .getGoals(username: user.username, emaciated: true))!) // The user may have logged out during the network operation. If so we have nothing to do modelContext.refreshAllObjects() @@ -103,10 +101,9 @@ import SwiftyJSON private func refreshGoalsIncremental(user: User) async throws { logger.notice("Doing incremental update since \(user.updatedAt, privacy: .public)") let userResponse = JSON( - try await requestManager.get( - url: "api/v1/users/{username}.json", - parameters: ["diff_since": user.updatedAt.timeIntervalSince1970 + 1, "emaciated": "true"] - )! + try await requestManager.request(endpoint: .getUser(username: user.username, + diff_since: user.updatedAt.timeIntervalSince1970 + 1, + emaciated: true))! ) let goalResponse = userResponse["goals"] let deletedGoals = userResponse["deleted_goals"] @@ -127,10 +124,10 @@ import SwiftyJSON public func refreshGoal(_ goalID: NSManagedObjectID) async throws { let goal = try modelContext.existingObject(with: goalID) as! Goal - let responseObject = try await requestManager.get( - url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)", - parameters: ["datapoints_count": "5", "emaciated": "true"] - ) + let responseObject = try await requestManager.request(endpoint: .getGoalDetails(username: goal.owner.username, + goalname: goal.slug, + datapoints_count: 5, + emaciated: true)) let goalJSON = JSON(responseObject!) // The goal may have changed during the network operation, reload latest version @@ -143,9 +140,7 @@ import SwiftyJSON } public func forceAutodataRefresh(_ goal: Goal) async throws { - let _ = try await requestManager.get( - url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)/refresh_graph.json" - ) + let _ = try await requestManager.request(endpoint: .requestAutodataFetch(username: goal.owner.username, goalname: goal.slug)) } private func updateGoalsFromJson(_ responseJSON: JSON) { diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 1965e0488..6d8d1d586 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -102,12 +102,7 @@ public class RequestManager { throw error } } - public func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - return try await rawRequest(url: url, method: .get, parameters: parameters, headers: authenticationHeaders()) - } - public func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - return try await rawRequest(url: url, method: .patch, parameters: parameters, headers: authenticationHeaders()) - } + @available(*, deprecated, message: "use Endpoint") public func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { return try await rawRequest(url: url, method: .post, parameters: parameters, headers: authenticationHeaders()) } @@ -125,7 +120,7 @@ public class RequestManager { } public func request(endpoint: EndPoint) async throws -> Any? { - print("rawRequest: \(endpoint)") + print("rawRequest(endpoint) \(endpoint)") return try await rawRequest(url: endpoint.url.absoluteString, method: endpoint.method, parameters: endpoint.parameters, @@ -144,10 +139,51 @@ extension HTTPHeaders { public enum EndPoint { + // signing in case signIn(username: String, password: String, beemiosSecret: String) + // about the app versions the server expects to see case appVersions + // Retrieves information and a list of goalnames for the user with username. + case getUser(username: String, diff_since: TimeInterval? = nil, emaciated: Bool? = nil) + + // Gets goal details for user u's goal g + case getGoalDetails(username: String, goalname: String, datapoints_count: Int? = nil, emaciated: Bool? = nil) + + // Get the list of datapoints for user u's goal g + case getDatapoints(username: String, goalname: String, sort: String? = nil, count: Int? = nil, page: Int? = nil, per: Int? = nil) + + // Get all goals for a user + case getGoals(username: String, emaciated: Bool? = nil) + + // Force a fetch of autodata and graph refresh + case requestAutodataFetch(username: String, goalname: String) + + // Update the datapoint with ID id for user u's goal g (beeminder.com/u/g). + case updateDatapoint(username: String, goalname: String, datapointID: String, timestamp: Double? = nil, value: NSNumber? = nil, comment: String? = nil, urtext: String? = nil) + + // Update a goal for a user + case updateGoal(username: String, + goalname: String, + title: String? = nil, + tmin: String? = nil, + tmax: String? = nil, + isSecret: Bool? = nil, + isDataPublic: Bool? = nil, + tags: [String]? = nil, + iiParams: [String:Any?]? = nil, + leadtime: Int? = nil, + alertstart: Int? = nil, + deadline: Int? = nil, + usesDefaultNotifications: Bool? = nil) + + // Update the user + case updateUser(username: String, + default_alertstart: Int? = nil, + default_deadline: Int? = nil, + default_leadtime: Int? = nil) + var url: URL { var urlComponents: URLComponents { var urlComponents = URLComponents() @@ -164,8 +200,31 @@ public enum EndPoint { return switch self { case .signIn: "/api/private/sign_in" + case .appVersions: "/api/private/app_versions.json" + + case .getUser(let username, _, _), .updateUser(let username, _, _, _): + "/api/v1/users/\(username).json" + + case .getGoalDetails(let username, let goalname, _, _): + "/api/v1/users/\(username)/goals/\(goalname)" + + case .getDatapoints(let username, let goalname, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" + + case .getGoals(let username, _): + "/api/v1/users/\(username)/goals.json" + + case .requestAutodataFetch(let username, let goalname): + "/api/v1/users/\(username)/goals/\(goalname)/refresh_graph.json" + + case .updateDatapoint(let username, let goalname, let datapointID, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" + + case .updateGoal(let username, let goalname, _, _, _, _, _, _, _, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname).json" + } } @@ -173,15 +232,17 @@ public enum EndPoint { return switch self { case .signIn: .post - case .appVersions: + case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: .get + case .updateDatapoint, .updateGoal, .updateUser: + .put } } var parameters: [String: Any]? { - return switch self { + switch self { case .signIn(let username, let password, let beemiosSecret): - ["user": + return ["user": [ "login": username, "password": password @@ -189,8 +250,62 @@ public enum EndPoint { "beemios_secret": beemiosSecret] as [String: Any] + case .getUser(_, let diff_since, let emaciated): + var parameters: [String: Any] = [:] + if let diff_since { parameters["diff_since"] = diff_since } + if let emaciated { parameters["emaciated"] = emaciated } + return parameters.isEmpty ? nil : parameters + + case .getGoalDetails(_, _, let datapoints_count, let emaciated): + var parameters: [String: Any] = [:] + if let datapoints_count { parameters["datapoints_count"] = datapoints_count } + if let emaciated { parameters["emaciated"] = emaciated } + return parameters.isEmpty ? nil : parameters + + case .getDatapoints(_, _, let sort, let count, let page, let per): + var parameters: [String: Any] = [:] + if let sort { parameters["sort"] = sort } + if let count { parameters["count"] = count } + if let page { parameters["page"] = page } + if let per { parameters["per"] = per } + return parameters.isEmpty ? nil : parameters + + case .getGoals(_, let emaciated): + if let emaciated { return ["emaciated": emaciated] } + return nil + + case .updateDatapoint(_, _, _, let timestamp, let value, let comment, let urtext): + var parameters: [String: Any] = [:] + if let timestamp { parameters["timestamp"] = timestamp } + if let value { parameters["value"] = value } + if let comment { parameters["comment"] = comment } + if let urtext { parameters["urtext"] = urtext } + return parameters.isEmpty ? nil : parameters + + case .updateGoal(_, _, let title, let tmin, let tmax, let isSecret, let isDataPublic, let tags, let iiParams, let leadtime, let alertstart, let deadline, let usesDefaultNotifications): + var parameters: [String: Any] = [:] + if let title { parameters["title"] = title } + if let tmin { parameters["tmin"] = tmin } + if let tmax { parameters["tmax"] = tmax } + if let isSecret { parameters["is_secret"] = isSecret } + if let isDataPublic { parameters["is_data_public"] = isDataPublic } + if let tags { parameters["tags"] = tags } + if let iiParams { parameters["ii_params"] = iiParams } + if let leadtime { parameters["leadtime"] = leadtime } + if let alertstart { parameters["alertstart"] = alertstart } + if let deadline { parameters["deadline"] = deadline } + if let usesDefaultNotifications { parameters["use_defaults"] = usesDefaultNotifications } + return parameters.isEmpty ? nil : parameters + + case .updateUser(_, let default_alertstart, let default_deadline, let default_leadtime): + var parameters: [String: Any] = [:] + if let default_alertstart { parameters["default_alertstart"] = default_alertstart } + if let default_deadline { parameters["default_deadline"] = default_deadline } + if let default_leadtime { parameters["default_leadtime"] = default_leadtime } + return parameters.isEmpty ? nil : parameters + default: - nil + return nil } } } diff --git a/BeeSwift/GoalView/EditDatapointViewController.swift b/BeeSwift/GoalView/EditDatapointViewController.swift index a9a36e3c5..34bd11c2b 100644 --- a/BeeSwift/GoalView/EditDatapointViewController.swift +++ b/BeeSwift/GoalView/EditDatapointViewController.swift @@ -199,11 +199,10 @@ class EditDatapointViewController: UIViewController, UITextFieldDelegate { hud.mode = .indeterminate do { - let params = ["urtext": self.urtext()] - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug)/datapoints/\(self.datapoint.id).json", - parameters: params - ) + let _ = try await self.requestManager.request(endpoint: .updateDatapoint(username: goal.owner.username, + goalname: goal.slug, + datapointID: datapoint.id, + urtext: urtext())) try await self.goalManager.refreshGoal(self.goal.objectID) hud.mode = .customView diff --git a/BeeSwift/Settings/ConfigureHKMetricViewController.swift b/BeeSwift/Settings/ConfigureHKMetricViewController.swift index f296dfef9..3175b7cb4 100644 --- a/BeeSwift/Settings/ConfigureHKMetricViewController.swift +++ b/BeeSwift/Settings/ConfigureHKMetricViewController.swift @@ -325,13 +325,11 @@ class ConfigureHKMetricViewController: UIViewController { let configParams = metricConfig.getConfigParameters() for (key, value) in configParams { iiParams[key] = value } } - let params: [String: Any] = ["ii_params": iiParams] do { - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params - ) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: goal.owner.username, + goalname: goal.slug, + iiParams: iiParams)) hud.mode = .customView hud.customView = UIImageView(image: UIImage(systemName: "checkmark")) hud.hide(animated: true, afterDelay: 2) @@ -363,16 +361,15 @@ class ConfigureHKMetricViewController: UIViewController { isRequestInFlight = true disconnectButton.isUserInteractionEnabled = false - let params: [String: [String: String?]] = ["ii_params": ["name": nil, "metric": ""]] + let iiParams: [String : Any?] = ["name": nil, "metric": ""] let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate Task { @MainActor in do { - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params - ) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: goal.owner.username, + goalname: goal.slug, + iiParams: iiParams)) if let goalManager = self.goalManager { try await goalManager.refreshGoal(self.goal.objectID) } diff --git a/BeeSwift/Settings/EditDefaultNotificationsViewController.swift b/BeeSwift/Settings/EditDefaultNotificationsViewController.swift index fe08370a2..c78092aa3 100644 --- a/BeeSwift/Settings/EditDefaultNotificationsViewController.swift +++ b/BeeSwift/Settings/EditDefaultNotificationsViewController.swift @@ -37,12 +37,13 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func sendLeadTimeToServer(_ timer: Timer) { // We must not use `timer` in the Task as it may change once this method returns - let userInfo = timer.userInfo! as! [String: NSNumber] + guard let userInfo = timer.userInfo as? [String: NSNumber] else { return } + Task { @MainActor in - guard let leadtime = userInfo["leadtime"] else { return } - let params = ["default_leadtime": leadtime] + guard let leadtime = userInfo["leadtime"]?.intValue else { return } do { - let _ = try await requestManager.put(url: "api/v1/users/{username}.json", parameters: params) + let _ = try await requestManager.request(endpoint: .updateUser(username: user.username, + default_leadtime: leadtime)) try await goalManager.refreshGoals() } catch { logger.error("Error setting default leadtime: \(error)") // show alert @@ -59,10 +60,10 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { hud.mode = .indeterminate switch self.timePickerEditingMode { case .alertstart: - self.updateAlertstartLabel(self.midnightOffsetFromTimePickerView()) - let params = ["default_alertstart": self.midnightOffsetFromTimePickerView()] + let alertstart = self.midnightOffsetFromTimePickerView() + self.updateAlertstartLabel(alertstart) do { - let _ = try await requestManager.put(url: "api/v1/users/{username}.json", parameters: params) + let _ = try await requestManager.request(endpoint: .updateUser(username: user.username, default_alertstart: alertstart)) try await goalManager.refreshGoals() hud.hide(animated: true, afterDelay: 0.5) } catch { @@ -72,9 +73,8 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { case .deadline: let deadline = self.deadlineFromTimePickerView self.updateDeadlineLabel(deadline) - let params = ["default_deadline": deadline] do { - let _ = try await requestManager.put(url: "api/v1/users/{username}.json", parameters: params) + let _ = try await requestManager.request(endpoint: .updateUser(username: user.username, default_deadline: deadline)) try await goalManager.refreshGoals() hud.hide(animated: true, afterDelay: 0.5) } catch { diff --git a/BeeSwift/Settings/EditGoalNotificationsViewController.swift b/BeeSwift/Settings/EditGoalNotificationsViewController.swift index b42105af4..e742116d1 100644 --- a/BeeSwift/Settings/EditGoalNotificationsViewController.swift +++ b/BeeSwift/Settings/EditGoalNotificationsViewController.swift @@ -70,15 +70,15 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { } override func sendLeadTimeToServer(_ timer: Timer) { // We must not use `timer` in the Task as it may change once this method returns - let userInfo = timer.userInfo! as! [String: NSNumber] + guard let userInfo = timer.userInfo as? [String: NSNumber] else { return } + Task { @MainActor in - let leadtime = userInfo["leadtime"] - let params = ["leadtime": leadtime, "use_defaults": false] + let leadtime = userInfo["leadtime"]?.intValue do { - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params as [String: Any] - ) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: goal.owner.username, + goalname: goal.slug, + leadtime: leadtime, + usesDefaultNotifications: false)) try await self.goalManager.refreshGoal(self.goal.objectID) @@ -92,13 +92,14 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate if self.timePickerEditingMode == .alertstart { - self.updateAlertstartLabel(self.midnightOffsetFromTimePickerView()) + do { - let params = ["alertstart": self.midnightOffsetFromTimePickerView(), "use_defaults": false] - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params - ) + let alertstart = self.midnightOffsetFromTimePickerView() + self.updateAlertstartLabel(alertstart) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: user.username, + goalname: goal.slug, + alertstart: alertstart, + usesDefaultNotifications: false)) try await self.goalManager.refreshGoal(self.goal.objectID) self.useDefaultsSwitch.isOn = false @@ -110,14 +111,13 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { } } if self.timePickerEditingMode == .deadline { - let deadline = self.deadlineFromTimePickerView - self.updateDeadlineLabel(deadline) do { - let params = ["deadline": deadline, "use_defaults": false] - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params - ) + let deadline = self.deadlineFromTimePickerView + self.updateDeadlineLabel(deadline) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: user.username, + goalname: goal.slug, + deadline: deadline, + usesDefaultNotifications: false)) try await self.goalManager.refreshGoal(self.goal.objectID) self.useDefaultsSwitch.isOn = false @@ -153,11 +153,9 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate do { - let params = ["use_defaults": true] - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params - ) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: self.user.username, + goalname: self.goal.slug, + usesDefaultNotifications: true)) try await self.goalManager.refreshGoal(self.goal.objectID) hud.hide(animated: true, afterDelay: 0.5) } catch { @@ -195,11 +193,9 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate do { - let params = ["use_defaults": false] - let _ = try await self.requestManager.put( - url: "api/v1/users/{username}/goals/\(self.goal.slug).json", - parameters: params - ) + let _ = try await self.requestManager.request(endpoint: .updateGoal(username: user.username, + goalname: goal.slug, + usesDefaultNotifications: false)) try await self.goalManager.refreshGoal(self.goal.objectID) hud.hide(animated: true, afterDelay: 0.5) } catch { From 1759098a75d312c1b1132a0b31d70d4db47ce2f9 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:21:59 +0100 Subject: [PATCH 03/22] datapoint --- BeeKit/GoalExtensions.swift | 2 +- BeeSwift/Intents/AddDataPointIntent.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BeeKit/GoalExtensions.swift b/BeeKit/GoalExtensions.swift index 485a4c7e7..a625c9218 100644 --- a/BeeKit/GoalExtensions.swift +++ b/BeeKit/GoalExtensions.swift @@ -15,7 +15,7 @@ extension Goal { public var isDataProvidedAutomatically: Bool { return !(self.autodata ?? "").isEmpty } - /// The daystamp corresponding to the day of the goal's creation, thus the first day we should add data points for. + /// The daystamp corresponding to the day of the goal's creation, thus the first day we should add datapoints for. var initDaystamp: Daystamp { let initDate = Date(timeIntervalSince1970: Double(self.initDay)) diff --git a/BeeSwift/Intents/AddDataPointIntent.swift b/BeeSwift/Intents/AddDataPointIntent.swift index 8b1e20b5d..daba12802 100644 --- a/BeeSwift/Intents/AddDataPointIntent.swift +++ b/BeeSwift/Intents/AddDataPointIntent.swift @@ -5,8 +5,8 @@ import BeeKit import Foundation struct AddDataPointIntent: AppIntent { - static var title: LocalizedStringResource = "Add Data Point" - static var description = IntentDescription("Add a data point to a Beeminder goal") + static var title: LocalizedStringResource = "Add Datapoint" + static var description = IntentDescription("Add a datapoint to a Beeminder goal") @Parameter(title: "Goal") var goal: GoalEntity @Parameter(title: "Value") var value: Double @Parameter(title: "Comment", default: "Added via iOS Shortcut") var comment: String? From 51bd070c8c08c808517d38a456df15d7afbed066 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:04 +0100 Subject: [PATCH 04/22] endpoint enum, migrated addDatapoint --- BeeKit/Managers/DataPointManager.swift | 2 +- BeeKit/Managers/RequestManager.swift | 18 ++++++++++++++++-- BeeSwift/GoalView/GoalViewController.swift | 4 +++- BeeSwift/GoalView/TimerViewController.swift | 4 +++- BeeSwift/Intents/AddData.swift | 9 +++++---- BeeSwift/Intents/AddDataError.swift | 2 ++ BeeSwift/Intents/AddDataPointIntent.swift | 12 ++++++++---- 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/BeeKit/Managers/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 23d82e81c..7d9cf36e7 100644 --- a/BeeKit/Managers/DataPointManager.swift +++ b/BeeKit/Managers/DataPointManager.swift @@ -45,7 +45,7 @@ import SwiftyJSON } private func postDatapoint(goal: Goal, urText: String, requestId: String) async throws { - let _ = try await requestManager.addDatapoint(urtext: urText, slug: goal.slug, requestId: requestId) + let _ = try await requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, goalname: goal.slug, urtext: urText, requestID: requestId)) } private func fetchDatapoints(goal: Goal, sort: String, per: Int, page: Int) async throws -> [DataPoint] { diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 6d8d1d586..995c6c46a 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -113,7 +113,7 @@ public class RequestManager { guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) } - + @available(*, deprecated, message: "use Endpoint") public func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { let params = ["urtext": urtext, "requestid": requestId].compactMapValues { $0 } return try await post(url: "api/v1/users/{username}/goals/\(slug)/datapoints.json", parameters: params) @@ -183,6 +183,8 @@ public enum EndPoint { default_alertstart: Int? = nil, default_deadline: Int? = nil, default_leadtime: Int? = nil) + // Add a new datapoint to user u's goal g — beeminder.com/u/g. + case createDatapoint(username: String, goalname: String, value: NSNumber? = nil, timestamp: Double? = nil, daystamp: String? = nil, comment: String? = nil, urtext: String? = nil, requestID: String? = nil) var url: URL { var urlComponents: URLComponents { @@ -225,12 +227,14 @@ public enum EndPoint { case .updateGoal(let username, let goalname, _, _, _, _, _, _, _, _, _, _, _): "/api/v1/users/\(username)/goals/\(goalname).json" + case .createDatapoint(let username, let goalname, _, _, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" } } var method: HTTPMethod { return switch self { - case .signIn: + case .signIn, .createDatapoint: .post case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: .get @@ -304,6 +308,16 @@ public enum EndPoint { if let default_leadtime { parameters["default_leadtime"] = default_leadtime } return parameters.isEmpty ? nil : parameters + case .createDatapoint(_, _, let value, let timestamp, let daystamp, let comment, let urtext, let requestID): + var parameters: [String: Any] = [:] + if let value { parameters["value"] = value } + if let timestamp { parameters["timestamp"] = timestamp } + if let daystamp { parameters["daystamp"] = daystamp } + if let comment { parameters["comment"] = comment } + if let urtext { parameters["urtext"] = urtext } + if let requestID { parameters["request_id"] = requestID } + return parameters.isEmpty ? nil : parameters + default: return nil } diff --git a/BeeSwift/GoalView/GoalViewController.swift b/BeeSwift/GoalView/GoalViewController.swift index b722b29b6..7c8de1aed 100644 --- a/BeeSwift/GoalView/GoalViewController.swift +++ b/BeeSwift/GoalView/GoalViewController.swift @@ -501,7 +501,9 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable self.scrollView.scrollRectToVisible(CGRect(x: 0, y: 0, width: 0, height: 0), animated: true) do { - let _ = try await self.requestManager.addDatapoint(urtext: self.urtext, slug: self.goal.slug) + let _ = try await self.requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, + goalname: goal.slug, + urtext: self.urtext)) self.commentTextField.text = "" try await updateGoalAndInterface() diff --git a/BeeSwift/GoalView/TimerViewController.swift b/BeeSwift/GoalView/TimerViewController.swift index d0718a2f7..cd0ad5984 100644 --- a/BeeSwift/GoalView/TimerViewController.swift +++ b/BeeSwift/GoalView/TimerViewController.swift @@ -147,7 +147,9 @@ class TimerViewController: UIViewController { Task { @MainActor in do { - let _ = try await requestManager.addDatapoint(urtext: self.urtext(), slug: self.goal.slug) + let _ = try await requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, + goalname: goal.slug, + urtext: self.urtext())) hud.mode = .text hud.label.text = "Added!" DispatchQueue.main.asyncAfter( diff --git a/BeeSwift/Intents/AddData.swift b/BeeSwift/Intents/AddData.swift index a79d5fad4..e871a5ed8 100644 --- a/BeeSwift/Intents/AddData.swift +++ b/BeeSwift/Intents/AddData.swift @@ -32,12 +32,13 @@ struct AddData: DeprecatedAppIntent, CustomIntentMigratedAppIntent, PredictableI func perform() async throws -> some IntentResult & ProvidesDialog { guard let goalSlug = goal else { throw AddDataError.noGoal } guard let dataValue = value else { throw AddDataError.noValue } + guard let username = await ServiceLocator.currentUserManager.username else { throw AddDataError.noUser } let dataComment = comment ?? "" do { - let _ = try await ServiceLocator.requestManager.addDatapoint( - urtext: "^ \(dataValue) \"\(dataComment)\"", - slug: goalSlug - ) + let urtext = "^ \(dataValue) \"\(dataComment)\"" + let _ = try await ServiceLocator.requestManager.request(endpoint: .createDatapoint(username: username, + goalname: goalSlug, + urtext: urtext)) return .result(dialog: .responseSuccess(goal: goalSlug, value: dataValue)) } catch ServerError.notFound { throw AddDataError.apiError("Goal '\(goalSlug)' not found") } catch { throw AddDataError.apiError(error.localizedDescription) diff --git a/BeeSwift/Intents/AddDataError.swift b/BeeSwift/Intents/AddDataError.swift index e097c56d1..a175f1942 100644 --- a/BeeSwift/Intents/AddDataError.swift +++ b/BeeSwift/Intents/AddDataError.swift @@ -9,12 +9,14 @@ typealias ServerError = BeeKit.ServerError enum AddDataError: Error, CustomLocalizedStringResourceConvertible { case noGoal case noValue + case noUser case apiError(String) var localizedStringResource: LocalizedStringResource { switch self { case .noGoal: return "No goal specified. Please provide a goal slug." case .noValue: return "No value specified. Please provide a value for the datapoint." case .apiError(let message): return "Failed to add datapoint: \(message)" + case .noUser: return "No current user (username). Please log in." } } } diff --git a/BeeSwift/Intents/AddDataPointIntent.swift b/BeeSwift/Intents/AddDataPointIntent.swift index daba12802..b202fe861 100644 --- a/BeeSwift/Intents/AddDataPointIntent.swift +++ b/BeeSwift/Intents/AddDataPointIntent.swift @@ -14,10 +14,14 @@ struct AddDataPointIntent: AppIntent { func perform() async throws -> some IntentResult & ProvidesDialog { let dataComment = comment ?? "" do { - let _ = try await ServiceLocator.requestManager.addDatapoint( - urtext: "^ \(value) \"\(dataComment)\"", - slug: goal.slug - ) + guard let username = await ServiceLocator.currentUserManager.username else { + throw AddDataError.noUser + } + let urtext = "^ \(value) \"\(dataComment)\"" + let _ = try await ServiceLocator.requestManager.request(endpoint: .createDatapoint(username: username, + goalname: goal.slug, + urtext: urtext)) + // Use displayTitle to show title with slug fallback let formatter = NumberFormatter() formatter.minimumFractionDigits = 0 From b6034b92e69e6fa1bb93e4d754143f1d09e4452b Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:11 +0100 Subject: [PATCH 05/22] endpoint enum, create/delete datapoint --- BeeKit/Managers/DataPointManager.swift | 11 ++++--- BeeKit/Managers/RequestManager.swift | 29 ++++++++++--------- .../EditDatapointViewController.swift | 6 ++-- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/BeeKit/Managers/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 7d9cf36e7..4fb322a37 100644 --- a/BeeKit/Managers/DataPointManager.swift +++ b/BeeKit/Managers/DataPointManager.swift @@ -39,13 +39,16 @@ import SwiftyJSON } private func deleteDatapoint(goal: Goal, datapoint: DataPoint) async throws { - let _ = try await requestManager.delete( - url: "api/v1/users/{username}/goals/\(goal.slug)/datapoints/\(datapoint.id)" - ) + let _ = try await requestManager.request(endpoint: .deletedDatapoint(username: goal.owner.username, + goalname: goal.slug, + datapointID: datapoint.id)) } private func postDatapoint(goal: Goal, urText: String, requestId: String) async throws { - let _ = try await requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, goalname: goal.slug, urtext: urText, requestID: requestId)) + let _ = try await requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, + goalname: goal.slug, + urtext: urText, + requestID: requestId)) } private func fetchDatapoints(goal: Goal, sort: String, per: Int, page: Int) async throws -> [DataPoint] { diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 995c6c46a..b7e9594c7 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -102,22 +102,10 @@ public class RequestManager { throw error } } - @available(*, deprecated, message: "use Endpoint") - public func post(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - return try await rawRequest(url: url, method: .post, parameters: parameters, headers: authenticationHeaders()) - } - public func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - return try await rawRequest(url: url, method: .delete, parameters: parameters, headers: authenticationHeaders()) - } func authenticationHeaders() -> HTTPHeaders { guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) } - @available(*, deprecated, message: "use Endpoint") - public func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { - let params = ["urtext": urtext, "requestid": requestId].compactMapValues { $0 } - return try await post(url: "api/v1/users/{username}/goals/\(slug)/datapoints.json", parameters: params) - } public func request(endpoint: EndPoint) async throws -> Any? { print("rawRequest(endpoint) \(endpoint)") @@ -183,8 +171,18 @@ public enum EndPoint { default_alertstart: Int? = nil, default_deadline: Int? = nil, default_leadtime: Int? = nil) + // Add a new datapoint to user u's goal g — beeminder.com/u/g. - case createDatapoint(username: String, goalname: String, value: NSNumber? = nil, timestamp: Double? = nil, daystamp: String? = nil, comment: String? = nil, urtext: String? = nil, requestID: String? = nil) + case createDatapoint(username: String, + goalname: String, + value: NSNumber? = nil, + timestamp: Double? = nil, + daystamp: String? = nil, + comment: String? = nil, + urtext: String? = nil, + requestID: String? = nil) + + case deletedDatapoint(username: String, goalname: String, datapointID: String) var url: URL { var urlComponents: URLComponents { @@ -229,6 +227,9 @@ public enum EndPoint { case .createDatapoint(let username, let goalname, _, _, _, _, _, _): "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" + + case .deletedDatapoint(let username, let goalname, let datapointID): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" } } @@ -240,6 +241,8 @@ public enum EndPoint { .get case .updateDatapoint, .updateGoal, .updateUser: .put + case .deletedDatapoint: + .delete } } diff --git a/BeeSwift/GoalView/EditDatapointViewController.swift b/BeeSwift/GoalView/EditDatapointViewController.swift index 34bd11c2b..4cc447ead 100644 --- a/BeeSwift/GoalView/EditDatapointViewController.swift +++ b/BeeSwift/GoalView/EditDatapointViewController.swift @@ -221,9 +221,9 @@ class EditDatapointViewController: UIViewController, UITextFieldDelegate { hud.mode = .indeterminate do { - let _ = try await self.requestManager.delete( - url: "api/v1/users/{username}/goals/\(self.goal.slug)/datapoints/\(self.datapoint.id).json" - ) + let _ = try await self.requestManager.request(endpoint: .deletedDatapoint(username: goal.owner.username, + goalname: goal.slug, + datapointID: datapoint.id)) try await self.goalManager.refreshGoal(self.goal.objectID) hud.mode = .customView From 32bfda04afd982a772b9a6b12985e77246ddf1f6 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:15 +0100 Subject: [PATCH 06/22] endpoint enum, register device token --- BeeKit/Managers/RequestManager.swift | 22 +++++++++++++++++++++- BeeKit/Managers/SignedRequestManager.swift | 17 ++++------------- BeeSwift/AppDelegate.swift | 13 ++++++------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index b7e9594c7..aae01d49c 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -183,6 +183,8 @@ public enum EndPoint { requestID: String? = nil) case deletedDatapoint(username: String, goalname: String, datapointID: String) + + case registerDeviceToken(token: String, environment: String? = nil) var url: URL { var urlComponents: URLComponents { @@ -204,6 +206,9 @@ public enum EndPoint { case .appVersions: "/api/private/app_versions.json" + case .registerDeviceToken: + "/api/private/device_tokens" + case .getUser(let username, _, _), .updateUser(let username, _, _, _): "/api/v1/users/\(username).json" @@ -235,7 +240,7 @@ public enum EndPoint { var method: HTTPMethod { return switch self { - case .signIn, .createDatapoint: + case .signIn, .createDatapoint, .registerDeviceToken: .post case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: .get @@ -321,8 +326,23 @@ public enum EndPoint { if let requestID { parameters["request_id"] = requestID } return parameters.isEmpty ? nil : parameters + case .registerDeviceToken(let token, let environment): + var parameters: [String: Any] = [:] + parameters["device_token"] = token + if let environment { parameters["server"] = environment } + return parameters.isEmpty ? nil : parameters + default: return nil } } + + var shouldSign: Bool { + return switch self { + case .registerDeviceToken: + true + default: + false + } + } } diff --git a/BeeKit/Managers/SignedRequestManager.swift b/BeeKit/Managers/SignedRequestManager.swift index 3cbac6ce5..5f911ed65 100644 --- a/BeeKit/Managers/SignedRequestManager.swift +++ b/BeeKit/Managers/SignedRequestManager.swift @@ -14,20 +14,11 @@ public class SignedRequestManager { init(requestManager: RequestManager) { self.requestManager = requestManager } - public func signedGET(url: String, parameters: [String: Any]?) async throws -> Any? { - let params = signedParameters(parameters) + public func request(endpoint: EndPoint) async throws -> Any? { + let params = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters return try await requestManager.rawRequest( - url: url, - method: .get, - parameters: params, - headers: requestManager.authenticationHeaders() - ) - } - public func signedPOST(url: String, parameters: [String: Any]?) async throws -> Any? { - let params = signedParameters(parameters) - return try await requestManager.rawRequest( - url: url, - method: .post, + url: endpoint.url.absoluteString, + method: endpoint.method, parameters: params, headers: requestManager.authenticationHeaders() ) diff --git a/BeeSwift/AppDelegate.swift b/BeeSwift/AppDelegate.swift index fc3f77a8e..501c3be35 100644 --- a/BeeSwift/AppDelegate.swift +++ b/BeeSwift/AppDelegate.swift @@ -84,18 +84,17 @@ import UIKit logger.notice("application:didRegisterForRemoteNotificationsWithDeviceToken") Task { @MainActor in let token = deviceToken.reduce("", { $0 + String(format: "%02X", $1) }) - - var parameters = ["device_token": token] + var environment: String? + if isDevelopmentBuild() { - parameters["server"] = "development" + environment = "development" logger.notice("Registering device token for development APNS server") } do { - let _ = try await ServiceLocator.signedRequestManager.signedPOST( - url: "/api/private/device_tokens", - parameters: parameters - ) + let _ = try await ServiceLocator.signedRequestManager.request(endpoint: .registerDeviceToken(token: token, + environment: environment)) + } catch { logger.error("Error sending device push token: \(error)") } } } From fa63fd8de1e08a28e89c6c4a503ce0781ee09224 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:19 +0100 Subject: [PATCH 07/22] just one request manager --- BeeKit/Managers/RequestManager.swift | 30 ++++++++++++++- BeeKit/Managers/SignedRequestManager.swift | 45 ---------------------- BeeKit/ServiceLocator.swift | 2 +- BeeSwift/AppDelegate.swift | 6 +-- 4 files changed, 32 insertions(+), 51 deletions(-) delete mode 100644 BeeKit/Managers/SignedRequestManager.swift diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index aae01d49c..1a693da7c 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -37,7 +37,8 @@ public enum ServerError: LocalizedError { public class RequestManager { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") - func rawRequest(url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws + + private func rawRequest(url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws -> Any? { @@ -109,9 +110,11 @@ public class RequestManager { public func request(endpoint: EndPoint) async throws -> Any? { print("rawRequest(endpoint) \(endpoint)") + + let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters return try await rawRequest(url: endpoint.url.absoluteString, method: endpoint.method, - parameters: endpoint.parameters, + parameters: parameters, headers: authenticationHeaders()) } } @@ -346,3 +349,26 @@ public enum EndPoint { } } } + + +fileprivate extension RequestManager { + func signedParameters(_ params: [String: Any]?) -> [String: Any]? { + if params == nil { return params } + var signed = params + var base = "" + var keys = Array(params!.keys) + keys.sort(by: { $0 < $1 }) + for key in keys { + let value: AnyObject? = params![key] as AnyObject? + if !(value is String) { return params! } + let allowedCharacterSet = (CharacterSet(charactersIn: "@/").inverted) + let escapedKey = key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) + let escapedValue = (params![key] as AnyObject).addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) + if base.count > 0 { base += "&" } + base += "\(escapedKey!)=\(escapedValue!)" + } + let token = base.hmac(algorithm: HMACAlgorithm.SHA1, key: Config().requestSigningKey) + signed?["beemios_token"] = token + return signed! as [String: Any] + } +} diff --git a/BeeKit/Managers/SignedRequestManager.swift b/BeeKit/Managers/SignedRequestManager.swift deleted file mode 100644 index 5f911ed65..000000000 --- a/BeeKit/Managers/SignedRequestManager.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// SignedRequestManager.swift -// BeeSwift -// -// Created by Andy Brett on 11/30/17. -// Copyright 2017 APB. All rights reserved. -// - -import Alamofire -import Foundation - -public class SignedRequestManager { - private let requestManager: RequestManager - - init(requestManager: RequestManager) { self.requestManager = requestManager } - - public func request(endpoint: EndPoint) async throws -> Any? { - let params = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters - return try await requestManager.rawRequest( - url: endpoint.url.absoluteString, - method: endpoint.method, - parameters: params, - headers: requestManager.authenticationHeaders() - ) - } - fileprivate func signedParameters(_ params: [String: Any]?) -> [String: Any]? { - if params == nil { return params } - var signed = params - var base = "" - var keys = Array(params!.keys) - keys.sort(by: { $0 < $1 }) - for key in keys { - let value: AnyObject? = params![key] as AnyObject? - if !(value is String) { return params! } - let allowedCharacterSet = (CharacterSet(charactersIn: "@/").inverted) - let escapedKey = key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) - let escapedValue = (params![key] as AnyObject).addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) - if base.count > 0 { base += "&" } - base += "\(escapedKey!)=\(escapedValue!)" - } - let token = base.hmac(algorithm: HMACAlgorithm.SHA1, key: Config().requestSigningKey) - signed?["beemios_token"] = token - return signed! as [String: Any] - } -} diff --git a/BeeKit/ServiceLocator.swift b/BeeKit/ServiceLocator.swift index c1c6dcdf3..c7c8e54ae 100644 --- a/BeeKit/ServiceLocator.swift +++ b/BeeKit/ServiceLocator.swift @@ -15,7 +15,7 @@ public class ServiceLocator { public static let persistentContainer = BeeminderPersistentContainer.create() public static let requestManager = RequestManager() - public static let signedRequestManager = SignedRequestManager(requestManager: requestManager) + public static let currentUserManager = CurrentUserManager( requestManager: requestManager, container: persistentContainer diff --git a/BeeSwift/AppDelegate.swift b/BeeSwift/AppDelegate.swift index 501c3be35..1796344c7 100644 --- a/BeeSwift/AppDelegate.swift +++ b/BeeSwift/AppDelegate.swift @@ -92,9 +92,9 @@ import UIKit } do { - let _ = try await ServiceLocator.signedRequestManager.request(endpoint: .registerDeviceToken(token: token, - environment: environment)) - + let _ = try await ServiceLocator.requestManager.request(endpoint: .registerDeviceToken(token: token, + environment: environment)) + } catch { logger.error("Error sending device push token: \(error)") } } } From bd7d4a81b0dd4e11d73027ec4cd7729243c9ce32 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:23 +0100 Subject: [PATCH 08/22] and tests back to compiling --- BeeKit/Managers/CurrentUserManager.swift | 4 +- BeeKit/Managers/DataPointManager.swift | 4 +- BeeKit/Managers/GoalManager.swift | 4 +- BeeKit/Managers/RequestManager.swift | 6 ++- BeeKitTests/DataPointManagerTests.swift | 65 ++++++++++++++---------- BeeKitTests/GoalManagerTests.swift | 28 +++++----- 6 files changed, 65 insertions(+), 46 deletions(-) diff --git a/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index ae19979c5..15c3454b0 100644 --- a/BeeKit/Managers/CurrentUserManager.swift +++ b/BeeKit/Managers/CurrentUserManager.swift @@ -36,13 +36,13 @@ import SwiftyJSON internal static let keychainPrefix = "CurrentUserManager_" - private let requestManager: RequestManager + private let requestManager: RequestManaging fileprivate static var allKeys: [String] { [accessTokenKey, usernameKey, deadbeatKey, defaultLeadtimeKey, defaultAlertstartKey, defaultDeadlineKey, beemTZKey] } - init(requestManager: RequestManager, container: BeeminderPersistentContainer) { + init(requestManager: RequestManaging, container: BeeminderPersistentContainer) { self.requestManager = requestManager self.modelContainer = container let context = container.newBackgroundContext() diff --git a/BeeKit/Managers/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 4fb322a37..7c733aa11 100644 --- a/BeeKit/Managers/DataPointManager.swift +++ b/BeeKit/Managers/DataPointManager.swift @@ -12,9 +12,9 @@ import SwiftyJSON // prevents effectively no-op updates due to float rounding private let datapointValueEpsilon = 0.00000001 - let requestManager: RequestManager + let requestManager: RequestManaging - init(requestManager: RequestManager, container: BeeminderPersistentContainer) { + init(requestManager: RequestManaging, container: BeeminderPersistentContainer) { self.requestManager = requestManager self.modelContainer = container let context = container.newBackgroundContext() diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 6d29a5924..50c2a3932 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -16,12 +16,12 @@ import SwiftyJSON @NSModelActor(disableGenerateInit: true) public actor GoalManager { private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "GoalManager") - private let requestManager: RequestManager + private let requestManager: RequestManaging private nonisolated let currentUserManager: CurrentUserManager private var queuedGoalsBackgroundTaskRunning: Bool = false - init(requestManager: RequestManager, currentUserManager: CurrentUserManager, container: BeeminderPersistentContainer) + init(requestManager: RequestManaging, currentUserManager: CurrentUserManager, container: BeeminderPersistentContainer) { modelContainer = container let context = container.newBackgroundContext() diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 1a693da7c..897078922 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -34,7 +34,11 @@ public enum ServerError: LocalizedError { } } -public class RequestManager { +public protocol RequestManaging { + func request(endpoint: EndPoint) async throws -> Any? +} + +public class RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index 06bf76a88..dc18da639 100644 --- a/BeeKitTests/DataPointManagerTests.swift +++ b/BeeKitTests/DataPointManagerTests.swift @@ -17,12 +17,12 @@ class MockHealthKitDataPoint: BeeDataPoint { } } -class MockRequestManagerForDataPoint: RequestManager { +class MockRequestManagerForDataPoint: RequestManaging { private let queue = DispatchQueue(label: "com.beeminder.MockRequestManagerForDataPoint") private var _responses: [String: Any] = [:] private var _putCalls: [(url: String, parameters: [String: Any])] = [] private var _deleteCalls: [String] = [] - private var _addDatapointCalls: [(urtext: String, slug: String, requestId: String)] = [] + private var _addDatapointCalls: [(url: String, parameters: [String: Any])] = [] var responses: [String: Any] { get { queue.sync { _responses } } @@ -30,30 +30,41 @@ class MockRequestManagerForDataPoint: RequestManager { } var putCalls: [(url: String, parameters: [String: Any])] { queue.sync { _putCalls } } var deleteCalls: [String] { queue.sync { _deleteCalls } } - var addDatapointCalls: [(urtext: String, slug: String, requestId: String)] { queue.sync { _addDatapointCalls } } + var addDatapointCalls: [(url: String, parameters: [String: Any])] { queue.sync { _addDatapointCalls } } - override func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - let response = queue.sync { () -> Any? in - if let response = _responses[url] { - _responses.removeValue(forKey: url) - return response + func request(endpoint: EndPoint) async throws -> Any? { + let urlString = endpoint.url.absoluteString + + // Determine HTTP method from endpoint path patterns + if urlString.contains("/datapoints/") && urlString.contains("datapoints.json") { + // POST - create new datapoint + queue.sync { _addDatapointCalls.append((url: urlString, parameters: endpoint.parameters ?? [:])) } + return [:] + } else if urlString.contains("/datapoints/") && !urlString.contains("datapoints.json") { + // Has specific datapoint ID - could be PUT or DELETE + // Check if it's a delete (usually has the datapoint ID in the path) + if endpoint.parameters == nil || endpoint.parameters?.isEmpty == true { + // Likely a delete + queue.sync { _deleteCalls.append(urlString) } + return [:] + } else { + // PUT - update datapoint + queue.sync { _putCalls.append((url: urlString, parameters: endpoint.parameters ?? [:])) } + return [:] } - return nil + } else { + // GET - fetch datapoints + let response = queue.sync { () -> Any? in + if let response = _responses[urlString] { + _responses.removeValue(forKey: urlString) + return response + } + return nil + } + return response ?? [] } - return response ?? [] - } - override func put(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - queue.sync { _putCalls.append((url: url, parameters: parameters ?? [:])) } - return [:] - } - override func delete(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - queue.sync { _deleteCalls.append(url) } - return [:] - } - override func addDatapoint(urtext: String, slug: String, requestId: String? = nil) async throws -> Any? { - queue.sync { _addDatapointCalls.append((urtext: urtext, slug: slug, requestId: requestId ?? "")) } - return [:] } + } class DataPointManagerTests: XCTestCase { @@ -134,9 +145,9 @@ class DataPointManagerTests: XCTestCase { XCTAssertTrue(mockRequestManager.deleteCalls[0].contains("obsolete1")) // Should create a new datapoint XCTAssertEqual(mockRequestManager.addDatapointCalls.count, 1) - XCTAssertEqual(mockRequestManager.addDatapointCalls[0].urtext, "1 25 \"New workout\"") - XCTAssertEqual(mockRequestManager.addDatapointCalls[0].slug, "test-goal") - XCTAssertEqual(mockRequestManager.addDatapointCalls[0].requestId, "hk_workout_2") + XCTAssertEqual(mockRequestManager.addDatapointCalls[0].parameters["urtext"] as? String, "1 25 \"New workout\"") + XCTAssertTrue(mockRequestManager.addDatapointCalls[0].url.contains("/goals/test-goal")) + XCTAssertEqual(mockRequestManager.addDatapointCalls[0].parameters["requestId"] as? String, "hk_workout_2") } func testDeletesRemovedWorkouts() async throws { let apiResponse = [ @@ -211,8 +222,8 @@ class DataPointManagerTests: XCTestCase { XCTAssertTrue(mockRequestManager.deleteCalls[0].contains("day2_workout1")) // Should create 2 new workouts (day1 yoga, day2 bike) XCTAssertEqual(mockRequestManager.addDatapointCalls.count, 2) - XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.requestId == "uuid_3" }) - XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.requestId == "uuid_4" }) + XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.parameters["requestId"] as? String == "uuid_3" }) + XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.parameters["requestId"] as? String == "uuid_4" }) } private func createTestGoalJSON() -> JSON { return JSON( diff --git a/BeeKitTests/GoalManagerTests.swift b/BeeKitTests/GoalManagerTests.swift index 361d19951..730673664 100644 --- a/BeeKitTests/GoalManagerTests.swift +++ b/BeeKitTests/GoalManagerTests.swift @@ -5,13 +5,17 @@ import XCTest @testable import BeeKit -class MockRequestManager: RequestManager { - var responses: [String: Any] = [:] - override func get(url: String, parameters: [String: Any]? = nil) async throws -> Any? { - if let response = responses[url] { return response } - XCTFail("Unexpected URL requested: \(url)") - return nil +class MockRequestManager: RequestManaging { + func request(endpoint: BeeKit.EndPoint) async throws -> Any? { + let keyUrlStr = endpoint.url.absoluteString + guard let response = responses[keyUrlStr] else { + XCTFail("Unexpected URL requested: \(keyUrlStr)") + return nil + } + + return response } + var responses: [String: Any] = [:] } class GoalManagerTests: XCTestCase { @@ -85,11 +89,11 @@ class GoalManagerTests: XCTestCase { ] """ mockRequestManager.responses = [ - "api/v1/users/{username}.json": try JSONSerialization.jsonObject( + "https://www.beeminder.com/api/v1/users/test_user.json": try JSONSerialization.jsonObject( with: userResponse.data(using: .utf8)!, options: [] ), - "api/v1/users/{username}/goals.json": try JSONSerialization.jsonObject( + "https://www.beeminder.com/api/v1/users/test_user/goals.json": try JSONSerialization.jsonObject( with: goalsResponse.data(using: .utf8)!, options: [] ), @@ -123,7 +127,7 @@ class GoalManagerTests: XCTestCase { } """ mockRequestManager.responses = [ - "api/v1/users/{username}.json": try JSONSerialization.jsonObject( + "https://www.beeminder.com/api/v1/users/test_user.json": try JSONSerialization.jsonObject( with: deletionResponse.data(using: .utf8)!, options: [] ) @@ -194,11 +198,11 @@ class GoalManagerTests: XCTestCase { ] """ mockRequestManager.responses = [ - "api/v1/users/{username}.json": try JSONSerialization.jsonObject( + "https://www.beeminder.com/api/v1/users/test_user.json": try JSONSerialization.jsonObject( with: userResponse.data(using: .utf8)!, options: [] ), - "api/v1/users/{username}/goals.json": try JSONSerialization.jsonObject( + "https://www.beeminder.com/api/v1/users/test_user/goals.json": try JSONSerialization.jsonObject( with: initialGoalsResponse.data(using: .utf8)!, options: [] ), @@ -253,7 +257,7 @@ class GoalManagerTests: XCTestCase { } """ mockRequestManager.responses = [ - "api/v1/users/{username}.json": try JSONSerialization.jsonObject( + "https://www.beeminder.com/api/v1/users/test_user.json": try JSONSerialization.jsonObject( with: incrementalResponse.data(using: .utf8)!, options: [] ) From 661e3cbb762143cd0596580253c395291352d98f Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:27 +0100 Subject: [PATCH 09/22] using url now that we have it --- BeeKit/Managers/RequestManager.swift | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 897078922..683891762 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -42,29 +42,15 @@ public class RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") - private func rawRequest(url: String, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws + private func rawRequest(url: URL, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws -> Any? { - - var urlWithSubstitutions = url - if url.contains("{username}") { - guard let username = await ServiceLocator.currentUserManager.username else { - throw ServerError.custom( - "Attempted to make request to username-based URL \(url) while logged out", - requestError: nil - ) - } - urlWithSubstitutions = urlWithSubstitutions.replacingOccurrences(of: "{username}", with: username) - } - - let encoding: ParameterEncoding = if method == .get { URLEncoding.default } else { JSONEncoding.default } // TODO + let encoding: ParameterEncoding = method == .get ? URLEncoding.default : JSONEncoding.default - // TODO no longer needed once migrated to endpoint enum - let urlStr = urlWithSubstitutions.starts(with: baseURLString) ? urlWithSubstitutions : "\(baseURLString)/\(urlWithSubstitutions)" + logger.debug("rawRequest: \(url.absoluteString), method \(method.rawValue)") - logger.debug("rawRequest: \(urlStr), method \(method.rawValue)") let response = await AF.request( - urlStr, + url, method: method, parameters: parameters, encoding: encoding, @@ -77,7 +63,7 @@ public class RequestManager: RequestManaging { return asJSON case .failure(let error): - logger.error("Error issuing request \(url): \(error, privacy: .public)") + logger.error("Error issuing request \(url.absoluteString): \(error, privacy: .public)") // Log out the user on an unauthorized response if case .responseValidationFailed(let reason) = error { @@ -116,7 +102,7 @@ public class RequestManager: RequestManaging { print("rawRequest(endpoint) \(endpoint)") let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters - return try await rawRequest(url: endpoint.url.absoluteString, + return try await rawRequest(url: endpoint.url, method: endpoint.method, parameters: parameters, headers: authenticationHeaders()) From 72f71301162c3a958de29d8392a75f29fca97551 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:32 +0100 Subject: [PATCH 10/22] clean code --- BeeKit/Managers/RequestManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 683891762..22acca894 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -93,7 +93,8 @@ public class RequestManager: RequestManaging { throw error } } - func authenticationHeaders() -> HTTPHeaders { + + private func authenticationHeaders() -> HTTPHeaders { guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) } From a6945b385a8000e21c70478089c4cbdbf8ac9d1f Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:46 +0100 Subject: [PATCH 11/22] file for Endpoint --- BeeKit/Managers/EndPoint.swift | 228 ++++++++++++++++++++++ BeeKit/Managers/RequestManager.swift | 278 +++------------------------ 2 files changed, 257 insertions(+), 249 deletions(-) create mode 100644 BeeKit/Managers/EndPoint.swift diff --git a/BeeKit/Managers/EndPoint.swift b/BeeKit/Managers/EndPoint.swift new file mode 100644 index 000000000..e97640a83 --- /dev/null +++ b/BeeKit/Managers/EndPoint.swift @@ -0,0 +1,228 @@ +// Part of BeeSwift. Copyright Beeminder + + +import Alamofire +import Foundation +import OSLog +import SwiftyJSON + +public enum EndPoint { + // signing in + case signIn(username: String, password: String, beemiosSecret: String) + + // about the app versions the server expects to see + case appVersions + + // Retrieves information and a list of goalnames for the user with username. + case getUser(username: String, diff_since: TimeInterval? = nil, emaciated: Bool? = nil) + + // Gets goal details for user u's goal g + case getGoalDetails(username: String, goalname: String, datapoints_count: Int? = nil, emaciated: Bool? = nil) + + // Get the list of datapoints for user u's goal g + case getDatapoints(username: String, goalname: String, sort: String? = nil, count: Int? = nil, page: Int? = nil, per: Int? = nil) + + // Get all goals for a user + case getGoals(username: String, emaciated: Bool? = nil) + + // Force a fetch of autodata and graph refresh + case requestAutodataFetch(username: String, goalname: String) + + // Update the datapoint with ID id for user u's goal g (beeminder.com/u/g). + case updateDatapoint(username: String, goalname: String, datapointID: String, timestamp: Double? = nil, value: NSNumber? = nil, comment: String? = nil, urtext: String? = nil) + + // Update a goal for a user + case updateGoal(username: String, + goalname: String, + title: String? = nil, + tmin: String? = nil, + tmax: String? = nil, + isSecret: Bool? = nil, + isDataPublic: Bool? = nil, + tags: [String]? = nil, + iiParams: [String:Any?]? = nil, + leadtime: Int? = nil, + alertstart: Int? = nil, + deadline: Int? = nil, + usesDefaultNotifications: Bool? = nil) + + // Update the user + case updateUser(username: String, + default_alertstart: Int? = nil, + default_deadline: Int? = nil, + default_leadtime: Int? = nil) + + // Add a new datapoint to user u's goal g — beeminder.com/u/g. + case createDatapoint(username: String, + goalname: String, + value: NSNumber? = nil, + timestamp: Double? = nil, + daystamp: String? = nil, + comment: String? = nil, + urtext: String? = nil, + requestID: String? = nil) + + case deletedDatapoint(username: String, goalname: String, datapointID: String) + + case registerDeviceToken(token: String, environment: String? = nil) + + var url: URL { + var urlComponents: URLComponents { + var urlComponents = URLComponents() + urlComponents.scheme = "https" + urlComponents.host = Config().apiHost + urlComponents.path = self.path + return urlComponents + } + + return urlComponents.url! + } + + var path: String { + return switch self { + case .signIn: + "/api/private/sign_in" + + case .appVersions: + "/api/private/app_versions.json" + + case .registerDeviceToken: + "/api/private/device_tokens" + + case .getUser(let username, _, _), .updateUser(let username, _, _, _): + "/api/v1/users/\(username).json" + + case .getGoalDetails(let username, let goalname, _, _): + "/api/v1/users/\(username)/goals/\(goalname)" + + case .getDatapoints(let username, let goalname, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" + + case .getGoals(let username, _): + "/api/v1/users/\(username)/goals.json" + + case .requestAutodataFetch(let username, let goalname): + "/api/v1/users/\(username)/goals/\(goalname)/refresh_graph.json" + + case .updateDatapoint(let username, let goalname, let datapointID, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" + + case .updateGoal(let username, let goalname, _, _, _, _, _, _, _, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname).json" + + case .createDatapoint(let username, let goalname, _, _, _, _, _, _): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" + + case .deletedDatapoint(let username, let goalname, let datapointID): + "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" + } + } + + var method: HTTPMethod { + return switch self { + case .signIn, .createDatapoint, .registerDeviceToken: + .post + case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: + .get + case .updateDatapoint, .updateGoal, .updateUser: + .put + case .deletedDatapoint: + .delete + } + } + + var parameters: [String: Any]? { + switch self { + case .signIn(let username, let password, let beemiosSecret): + return ["user": + [ + "login": username, + "password": password + ], + "beemios_secret": beemiosSecret] + as [String: Any] + + case .getUser(_, let diff_since, let emaciated): + var parameters: [String: Any] = [:] + if let diff_since { parameters["diff_since"] = diff_since } + if let emaciated { parameters["emaciated"] = emaciated } + return parameters.isEmpty ? nil : parameters + + case .getGoalDetails(_, _, let datapoints_count, let emaciated): + var parameters: [String: Any] = [:] + if let datapoints_count { parameters["datapoints_count"] = datapoints_count } + if let emaciated { parameters["emaciated"] = emaciated } + return parameters.isEmpty ? nil : parameters + + case .getDatapoints(_, _, let sort, let count, let page, let per): + var parameters: [String: Any] = [:] + if let sort { parameters["sort"] = sort } + if let count { parameters["count"] = count } + if let page { parameters["page"] = page } + if let per { parameters["per"] = per } + return parameters.isEmpty ? nil : parameters + + case .getGoals(_, let emaciated): + if let emaciated { return ["emaciated": emaciated] } + return nil + + case .updateDatapoint(_, _, _, let timestamp, let value, let comment, let urtext): + var parameters: [String: Any] = [:] + if let timestamp { parameters["timestamp"] = timestamp } + if let value { parameters["value"] = value } + if let comment { parameters["comment"] = comment } + if let urtext { parameters["urtext"] = urtext } + return parameters.isEmpty ? nil : parameters + + case .updateGoal(_, _, let title, let tmin, let tmax, let isSecret, let isDataPublic, let tags, let iiParams, let leadtime, let alertstart, let deadline, let usesDefaultNotifications): + var parameters: [String: Any] = [:] + if let title { parameters["title"] = title } + if let tmin { parameters["tmin"] = tmin } + if let tmax { parameters["tmax"] = tmax } + if let isSecret { parameters["is_secret"] = isSecret } + if let isDataPublic { parameters["is_data_public"] = isDataPublic } + if let tags { parameters["tags"] = tags } + if let iiParams { parameters["ii_params"] = iiParams } + if let leadtime { parameters["leadtime"] = leadtime } + if let alertstart { parameters["alertstart"] = alertstart } + if let deadline { parameters["deadline"] = deadline } + if let usesDefaultNotifications { parameters["use_defaults"] = usesDefaultNotifications } + return parameters.isEmpty ? nil : parameters + + case .updateUser(_, let default_alertstart, let default_deadline, let default_leadtime): + var parameters: [String: Any] = [:] + if let default_alertstart { parameters["default_alertstart"] = default_alertstart } + if let default_deadline { parameters["default_deadline"] = default_deadline } + if let default_leadtime { parameters["default_leadtime"] = default_leadtime } + return parameters.isEmpty ? nil : parameters + + case .createDatapoint(_, _, let value, let timestamp, let daystamp, let comment, let urtext, let requestID): + var parameters: [String: Any] = [:] + if let value { parameters["value"] = value } + if let timestamp { parameters["timestamp"] = timestamp } + if let daystamp { parameters["daystamp"] = daystamp } + if let comment { parameters["comment"] = comment } + if let urtext { parameters["urtext"] = urtext } + if let requestID { parameters["request_id"] = requestID } + return parameters.isEmpty ? nil : parameters + + case .registerDeviceToken(let token, let environment): + var parameters: [String: Any] = [:] + parameters["device_token"] = token + if let environment { parameters["server"] = environment } + return parameters.isEmpty ? nil : parameters + + default: + return nil + } + } + + var shouldSign: Bool { + return switch self { + case .registerDeviceToken: + true + default: + false + } + } +} \ No newline at end of file diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 22acca894..0ebfa4c7e 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -42,10 +42,23 @@ public class RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") - private func rawRequest(url: URL, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws - -> Any? + public func request(endpoint: EndPoint) async throws -> Any? { + print("rawRequest(endpoint) \(endpoint)") + + let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters + return try await rawRequest(url: endpoint.url, + method: endpoint.method, + parameters: parameters, + headers: authenticationHeaders()) + } +} + +fileprivate extension RequestManager { + func rawRequest(url: URL, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws + -> Any? { let encoding: ParameterEncoding = method == .get ? URLEncoding.default : JSONEncoding.default + let headers = HTTPHeaders.default + headers logger.debug("rawRequest: \(url.absoluteString), method \(method.rawValue)") @@ -54,9 +67,9 @@ public class RequestManager: RequestManaging { method: method, parameters: parameters, encoding: encoding, - headers: HTTPHeaders.default + headers + headers: headers ).validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response - + switch response.result { case .success(let data): let asJSON = try? JSONSerialization.jsonObject(with: data) @@ -89,259 +102,17 @@ public class RequestManager: RequestManaging { } } } - + throw error } } - - private func authenticationHeaders() -> HTTPHeaders { + + func authenticationHeaders() -> HTTPHeaders { guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) } - - public func request(endpoint: EndPoint) async throws -> Any? { - print("rawRequest(endpoint) \(endpoint)") - - let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters - return try await rawRequest(url: endpoint.url, - method: endpoint.method, - parameters: parameters, - headers: authenticationHeaders()) - } -} - -extension HTTPHeaders { - static func + (lhs: HTTPHeaders, rhs: HTTPHeaders) -> HTTPHeaders { - var allHeaders = [HTTPHeader]() - allHeaders.append(contentsOf: lhs) - allHeaders.append(contentsOf: rhs) - return HTTPHeaders(allHeaders) - } -} - - -public enum EndPoint { - // signing in - case signIn(username: String, password: String, beemiosSecret: String) - - // about the app versions the server expects to see - case appVersions - - // Retrieves information and a list of goalnames for the user with username. - case getUser(username: String, diff_since: TimeInterval? = nil, emaciated: Bool? = nil) - - // Gets goal details for user u's goal g - case getGoalDetails(username: String, goalname: String, datapoints_count: Int? = nil, emaciated: Bool? = nil) - - // Get the list of datapoints for user u's goal g - case getDatapoints(username: String, goalname: String, sort: String? = nil, count: Int? = nil, page: Int? = nil, per: Int? = nil) - - // Get all goals for a user - case getGoals(username: String, emaciated: Bool? = nil) - - // Force a fetch of autodata and graph refresh - case requestAutodataFetch(username: String, goalname: String) - - // Update the datapoint with ID id for user u's goal g (beeminder.com/u/g). - case updateDatapoint(username: String, goalname: String, datapointID: String, timestamp: Double? = nil, value: NSNumber? = nil, comment: String? = nil, urtext: String? = nil) - - // Update a goal for a user - case updateGoal(username: String, - goalname: String, - title: String? = nil, - tmin: String? = nil, - tmax: String? = nil, - isSecret: Bool? = nil, - isDataPublic: Bool? = nil, - tags: [String]? = nil, - iiParams: [String:Any?]? = nil, - leadtime: Int? = nil, - alertstart: Int? = nil, - deadline: Int? = nil, - usesDefaultNotifications: Bool? = nil) - - // Update the user - case updateUser(username: String, - default_alertstart: Int? = nil, - default_deadline: Int? = nil, - default_leadtime: Int? = nil) - - // Add a new datapoint to user u's goal g — beeminder.com/u/g. - case createDatapoint(username: String, - goalname: String, - value: NSNumber? = nil, - timestamp: Double? = nil, - daystamp: String? = nil, - comment: String? = nil, - urtext: String? = nil, - requestID: String? = nil) - - case deletedDatapoint(username: String, goalname: String, datapointID: String) - - case registerDeviceToken(token: String, environment: String? = nil) - - var url: URL { - var urlComponents: URLComponents { - var urlComponents = URLComponents() - urlComponents.scheme = "https" - urlComponents.host = Config().apiHost - urlComponents.path = self.path - return urlComponents - } - - return urlComponents.url! - } - - var path: String { - return switch self { - case .signIn: - "/api/private/sign_in" - - case .appVersions: - "/api/private/app_versions.json" - - case .registerDeviceToken: - "/api/private/device_tokens" - - case .getUser(let username, _, _), .updateUser(let username, _, _, _): - "/api/v1/users/\(username).json" - - case .getGoalDetails(let username, let goalname, _, _): - "/api/v1/users/\(username)/goals/\(goalname)" - - case .getDatapoints(let username, let goalname, _, _, _, _): - "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" - - case .getGoals(let username, _): - "/api/v1/users/\(username)/goals.json" - - case .requestAutodataFetch(let username, let goalname): - "/api/v1/users/\(username)/goals/\(goalname)/refresh_graph.json" - - case .updateDatapoint(let username, let goalname, let datapointID, _, _, _, _): - "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" - - case .updateGoal(let username, let goalname, _, _, _, _, _, _, _, _, _, _, _): - "/api/v1/users/\(username)/goals/\(goalname).json" - - case .createDatapoint(let username, let goalname, _, _, _, _, _, _): - "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" - - case .deletedDatapoint(let username, let goalname, let datapointID): - "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" - } - } - - var method: HTTPMethod { - return switch self { - case .signIn, .createDatapoint, .registerDeviceToken: - .post - case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: - .get - case .updateDatapoint, .updateGoal, .updateUser: - .put - case .deletedDatapoint: - .delete - } - } - - var parameters: [String: Any]? { - switch self { - case .signIn(let username, let password, let beemiosSecret): - return ["user": - [ - "login": username, - "password": password - ], - "beemios_secret": beemiosSecret] - as [String: Any] - - case .getUser(_, let diff_since, let emaciated): - var parameters: [String: Any] = [:] - if let diff_since { parameters["diff_since"] = diff_since } - if let emaciated { parameters["emaciated"] = emaciated } - return parameters.isEmpty ? nil : parameters - - case .getGoalDetails(_, _, let datapoints_count, let emaciated): - var parameters: [String: Any] = [:] - if let datapoints_count { parameters["datapoints_count"] = datapoints_count } - if let emaciated { parameters["emaciated"] = emaciated } - return parameters.isEmpty ? nil : parameters - - case .getDatapoints(_, _, let sort, let count, let page, let per): - var parameters: [String: Any] = [:] - if let sort { parameters["sort"] = sort } - if let count { parameters["count"] = count } - if let page { parameters["page"] = page } - if let per { parameters["per"] = per } - return parameters.isEmpty ? nil : parameters - - case .getGoals(_, let emaciated): - if let emaciated { return ["emaciated": emaciated] } - return nil - - case .updateDatapoint(_, _, _, let timestamp, let value, let comment, let urtext): - var parameters: [String: Any] = [:] - if let timestamp { parameters["timestamp"] = timestamp } - if let value { parameters["value"] = value } - if let comment { parameters["comment"] = comment } - if let urtext { parameters["urtext"] = urtext } - return parameters.isEmpty ? nil : parameters - - case .updateGoal(_, _, let title, let tmin, let tmax, let isSecret, let isDataPublic, let tags, let iiParams, let leadtime, let alertstart, let deadline, let usesDefaultNotifications): - var parameters: [String: Any] = [:] - if let title { parameters["title"] = title } - if let tmin { parameters["tmin"] = tmin } - if let tmax { parameters["tmax"] = tmax } - if let isSecret { parameters["is_secret"] = isSecret } - if let isDataPublic { parameters["is_data_public"] = isDataPublic } - if let tags { parameters["tags"] = tags } - if let iiParams { parameters["ii_params"] = iiParams } - if let leadtime { parameters["leadtime"] = leadtime } - if let alertstart { parameters["alertstart"] = alertstart } - if let deadline { parameters["deadline"] = deadline } - if let usesDefaultNotifications { parameters["use_defaults"] = usesDefaultNotifications } - return parameters.isEmpty ? nil : parameters - - case .updateUser(_, let default_alertstart, let default_deadline, let default_leadtime): - var parameters: [String: Any] = [:] - if let default_alertstart { parameters["default_alertstart"] = default_alertstart } - if let default_deadline { parameters["default_deadline"] = default_deadline } - if let default_leadtime { parameters["default_leadtime"] = default_leadtime } - return parameters.isEmpty ? nil : parameters - - case .createDatapoint(_, _, let value, let timestamp, let daystamp, let comment, let urtext, let requestID): - var parameters: [String: Any] = [:] - if let value { parameters["value"] = value } - if let timestamp { parameters["timestamp"] = timestamp } - if let daystamp { parameters["daystamp"] = daystamp } - if let comment { parameters["comment"] = comment } - if let urtext { parameters["urtext"] = urtext } - if let requestID { parameters["request_id"] = requestID } - return parameters.isEmpty ? nil : parameters - - case .registerDeviceToken(let token, let environment): - var parameters: [String: Any] = [:] - parameters["device_token"] = token - if let environment { parameters["server"] = environment } - return parameters.isEmpty ? nil : parameters - - default: - return nil - } - } - - var shouldSign: Bool { - return switch self { - case .registerDeviceToken: - true - default: - false - } - } } - fileprivate extension RequestManager { func signedParameters(_ params: [String: Any]?) -> [String: Any]? { if params == nil { return params } @@ -363,3 +134,12 @@ fileprivate extension RequestManager { return signed! as [String: Any] } } + +fileprivate extension HTTPHeaders { + static func + (lhs: HTTPHeaders, rhs: HTTPHeaders) -> HTTPHeaders { + var allHeaders = [HTTPHeader]() + allHeaders.append(contentsOf: lhs) + allHeaders.append(contentsOf: rhs) + return HTTPHeaders(allHeaders) + } +} From f507a2d71474cb5d2482aea627e683a6b5ec5a4d Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:51 +0100 Subject: [PATCH 12/22] file for ServerError --- BeeKit/Managers/RequestManager.swift | 23 +-------------------- BeeKit/Managers/ServerError.swift | 30 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 22 deletions(-) create mode 100644 BeeKit/Managers/ServerError.swift diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 0ebfa4c7e..fa3e73302 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -11,28 +11,7 @@ import Foundation import OSLog import SwiftyJSON -public enum ServerError: LocalizedError { - case notFound - case unauthorized - case forbidden - case serverError(Int) - case custom(String, requestError: Error?) - public var errorDescription: String? { - switch self { - case .notFound: return "Not found" - case .unauthorized: return "Unauthorized" - case .forbidden: return "Permission denied" - case .serverError(let code): return "Server error (\(code)). Please try again later" - case .custom(let message, _): return message - } - } - var requestError: Error? { - switch self { - case .custom(_, let error): return error - default: return nil - } - } -} + public protocol RequestManaging { func request(endpoint: EndPoint) async throws -> Any? diff --git a/BeeKit/Managers/ServerError.swift b/BeeKit/Managers/ServerError.swift new file mode 100644 index 000000000..d87e7ced1 --- /dev/null +++ b/BeeKit/Managers/ServerError.swift @@ -0,0 +1,30 @@ +// Part of BeeSwift. Copyright Beeminder + + +import Alamofire +import Foundation +import OSLog +import SwiftyJSON + +public enum ServerError: LocalizedError { + case notFound + case unauthorized + case forbidden + case serverError(Int) + case custom(String, requestError: Error?) + public var errorDescription: String? { + switch self { + case .notFound: return "Not found" + case .unauthorized: return "Unauthorized" + case .forbidden: return "Permission denied" + case .serverError(let code): return "Server error (\(code)). Please try again later" + case .custom(let message, _): return message + } + } + var requestError: Error? { + switch self { + case .custom(_, let error): return error + default: return nil + } + } +} \ No newline at end of file From 8559095ac496026687b6d14b6e1ee641ea0e22ec Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:22:56 +0100 Subject: [PATCH 13/22] sample config was out of date --- BeeKit/Config.swift.sample | 2 ++ BeeSwift.xcodeproj/project.pbxproj | 1 - BeeSwift/Config.sample.swift | 23 ----------------------- 3 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 BeeSwift/Config.sample.swift diff --git a/BeeKit/Config.swift.sample b/BeeKit/Config.swift.sample index fd6d85e8b..fcfe8dd90 100644 --- a/BeeKit/Config.swift.sample +++ b/BeeKit/Config.swift.sample @@ -14,10 +14,12 @@ public struct Config { public var sentryClientDSN = "" public var requestSigningKey = "" public var baseURLString = "https://www.beeminder.com" + public var apiHost: String = "www.beeminder.com" public init() { self.baseURLString = "https://www.beeminder.com" self.requestSigningKey = "" self.sentryClientDSN = "" + self.apiHost = "www.beeminder.com" } } diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 6078e1ad8..e7d6a8892 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -121,7 +121,6 @@ FSSYNCEXCEPT00000000001 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Config.sample.swift, Info.plist, ); target = A196CB131AE4142E00B90A3E /* BeeSwift */; diff --git a/BeeSwift/Config.sample.swift b/BeeSwift/Config.sample.swift deleted file mode 100644 index 511a88152..000000000 --- a/BeeSwift/Config.sample.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Config.sample.swift -// BeeSwift -// -// Created by Andy Brett on 11/29/17. -// Copyright 2017 APB. All rights reserved. -// - -import Foundation - -// Uncomment this struct and rename the file to "Config.swift" to use blank values for all config variables - -//struct Config { -// static let sentryClientDSN = "" -// static let twitterConsumerKey = "" -// static let twitterConsumerSecret = "" -// static let twitterUrlScheme = "" -// static let facebookUrlScheme = "" -// static let googleClientId = "" -// static let googleReversedClientId = "" -// static let requestSigningKey = "" -// static let baseURLString = "https://www.beeminder.com" -//} From 8c70b4038d5adebd6bbb69908751c00447a201f6 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:28:22 +0100 Subject: [PATCH 14/22] bundle exec fastlane ios format --- BeeKit/Managers/CurrentUserManager.swift | 4 +- BeeKit/Managers/DataPointManager.swift | 40 ++-- BeeKit/Managers/EndPoint.swift | 175 ++++++++---------- BeeKit/Managers/GoalManager.swift | 30 ++- BeeKit/Managers/RequestManager.swift | 48 ++--- BeeKit/Managers/ServerError.swift | 5 +- BeeKit/ServiceLocator.swift | 1 - BeeKitTests/DataPointManagerTests.swift | 1 - BeeSwift/AppDelegate.swift | 7 +- .../EditDatapointViewController.swift | 18 +- BeeSwift/GoalView/GoalViewController.swift | 6 +- BeeSwift/GoalView/TimerViewController.swift | 6 +- BeeSwift/Intents/AddData.swift | 6 +- BeeSwift/Intents/AddDataPointIntent.swift | 11 +- .../ConfigureHKMetricViewController.swift | 14 +- ...itDefaultNotificationsViewController.swift | 14 +- .../EditGoalNotificationsViewController.swift | 54 ++++-- 17 files changed, 221 insertions(+), 219 deletions(-) diff --git a/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index 15c3454b0..d7b0495bc 100644 --- a/BeeKit/Managers/CurrentUserManager.swift +++ b/BeeKit/Managers/CurrentUserManager.swift @@ -123,7 +123,9 @@ import SwiftyJSON } public func signInWithEmail(_ email: String, password: String) async { do { - let response = try await requestManager.request(endpoint: .signIn(username: email, password: password, beemiosSecret: beemiosSecret)) + let response = try await requestManager.request( + endpoint: .signIn(username: email, password: password, beemiosSecret: beemiosSecret) + ) try! await self.handleSuccessfulSignin(JSON(response!)) } catch { try! await self.handleFailedSignin(error, errorMessage: error.localizedDescription) } } diff --git a/BeeKit/Managers/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 7c733aa11..79966263a 100644 --- a/BeeKit/Managers/DataPointManager.swift +++ b/BeeKit/Managers/DataPointManager.swift @@ -31,32 +31,38 @@ import SwiftyJSON let val = datapoint.value if datapointValue == val && comment == datapoint.comment { return } - let _ = try await requestManager.request(endpoint: .updateDatapoint(username: goal.owner.username, - goalname: goal.slug, - datapointID: datapoint.id, - value: datapointValue, - comment: comment)) + let _ = try await requestManager.request( + endpoint: .updateDatapoint( + username: goal.owner.username, + goalname: goal.slug, + datapointID: datapoint.id, + value: datapointValue, + comment: comment + ) + ) } private func deleteDatapoint(goal: Goal, datapoint: DataPoint) async throws { - let _ = try await requestManager.request(endpoint: .deletedDatapoint(username: goal.owner.username, - goalname: goal.slug, - datapointID: datapoint.id)) + let _ = try await requestManager.request( + endpoint: .deletedDatapoint(username: goal.owner.username, goalname: goal.slug, datapointID: datapoint.id) + ) } private func postDatapoint(goal: Goal, urText: String, requestId: String) async throws { - let _ = try await requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, - goalname: goal.slug, - urtext: urText, - requestID: requestId)) + let _ = try await requestManager.request( + endpoint: .createDatapoint( + username: goal.owner.username, + goalname: goal.slug, + urtext: urText, + requestID: requestId + ) + ) } private func fetchDatapoints(goal: Goal, sort: String, per: Int, page: Int) async throws -> [DataPoint] { - let response = try await requestManager.request(endpoint: .getDatapoints(username: goal.owner.username, - goalname: goal.slug, - sort: sort, - page: page, - per: per)) + let response = try await requestManager.request( + endpoint: .getDatapoints(username: goal.owner.username, goalname: goal.slug, sort: sort, page: page, per: per) + ) let responseJSON = JSON(response!) return responseJSON.arrayValue.map({ DataPoint.fromJSON(context: modelContext, goal: goal, json: $0) }) diff --git a/BeeKit/Managers/EndPoint.swift b/BeeKit/Managers/EndPoint.swift index e97640a83..d8d5211d8 100644 --- a/BeeKit/Managers/EndPoint.swift +++ b/BeeKit/Managers/EndPoint.swift @@ -1,6 +1,5 @@ // Part of BeeSwift. Copyright Beeminder - import Alamofire import Foundation import OSLog @@ -9,61 +8,71 @@ import SwiftyJSON public enum EndPoint { // signing in case signIn(username: String, password: String, beemiosSecret: String) - // about the app versions the server expects to see case appVersions - // Retrieves information and a list of goalnames for the user with username. case getUser(username: String, diff_since: TimeInterval? = nil, emaciated: Bool? = nil) - // Gets goal details for user u's goal g case getGoalDetails(username: String, goalname: String, datapoints_count: Int? = nil, emaciated: Bool? = nil) - // Get the list of datapoints for user u's goal g - case getDatapoints(username: String, goalname: String, sort: String? = nil, count: Int? = nil, page: Int? = nil, per: Int? = nil) + case getDatapoints( + username: String, + goalname: String, + sort: String? = nil, + count: Int? = nil, + page: Int? = nil, + per: Int? = nil + ) // Get all goals for a user case getGoals(username: String, emaciated: Bool? = nil) - // Force a fetch of autodata and graph refresh case requestAutodataFetch(username: String, goalname: String) - // Update the datapoint with ID id for user u's goal g (beeminder.com/u/g). - case updateDatapoint(username: String, goalname: String, datapointID: String, timestamp: Double? = nil, value: NSNumber? = nil, comment: String? = nil, urtext: String? = nil) - + case updateDatapoint( + username: String, + goalname: String, + datapointID: String, + timestamp: Double? = nil, + value: NSNumber? = nil, + comment: String? = nil, + urtext: String? = nil + ) // Update a goal for a user - case updateGoal(username: String, - goalname: String, - title: String? = nil, - tmin: String? = nil, - tmax: String? = nil, - isSecret: Bool? = nil, - isDataPublic: Bool? = nil, - tags: [String]? = nil, - iiParams: [String:Any?]? = nil, - leadtime: Int? = nil, - alertstart: Int? = nil, - deadline: Int? = nil, - usesDefaultNotifications: Bool? = nil) - + case updateGoal( + username: String, + goalname: String, + title: String? = nil, + tmin: String? = nil, + tmax: String? = nil, + isSecret: Bool? = nil, + isDataPublic: Bool? = nil, + tags: [String]? = nil, + iiParams: [String: Any?]? = nil, + leadtime: Int? = nil, + alertstart: Int? = nil, + deadline: Int? = nil, + usesDefaultNotifications: Bool? = nil + ) // Update the user - case updateUser(username: String, - default_alertstart: Int? = nil, - default_deadline: Int? = nil, - default_leadtime: Int? = nil) - + case updateUser( + username: String, + default_alertstart: Int? = nil, + default_deadline: Int? = nil, + default_leadtime: Int? = nil + ) // Add a new datapoint to user u's goal g — beeminder.com/u/g. - case createDatapoint(username: String, - goalname: String, - value: NSNumber? = nil, - timestamp: Double? = nil, - daystamp: String? = nil, - comment: String? = nil, - urtext: String? = nil, - requestID: String? = nil) - + case createDatapoint( + username: String, + goalname: String, + value: NSNumber? = nil, + timestamp: Double? = nil, + daystamp: String? = nil, + comment: String? = nil, + urtext: String? = nil, + requestID: String? = nil + ) case deletedDatapoint(username: String, goalname: String, datapointID: String) - case registerDeviceToken(token: String, environment: String? = nil) var url: URL { @@ -74,86 +83,55 @@ public enum EndPoint { urlComponents.path = self.path return urlComponents } - return urlComponents.url! } - var path: String { return switch self { - case .signIn: - "/api/private/sign_in" + case .signIn: "/api/private/sign_in" - case .appVersions: - "/api/private/app_versions.json" - - case .registerDeviceToken: - "/api/private/device_tokens" + case .appVersions: "/api/private/app_versions.json" + case .registerDeviceToken: "/api/private/device_tokens" - case .getUser(let username, _, _), .updateUser(let username, _, _, _): - "/api/v1/users/\(username).json" + case .getUser(let username, _, _), .updateUser(let username, _, _, _): "/api/v1/users/\(username).json" - case .getGoalDetails(let username, let goalname, _, _): - "/api/v1/users/\(username)/goals/\(goalname)" - + case .getGoalDetails(let username, let goalname, _, _): "/api/v1/users/\(username)/goals/\(goalname)" case .getDatapoints(let username, let goalname, _, _, _, _): "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" - - case .getGoals(let username, _): - "/api/v1/users/\(username)/goals.json" - + case .getGoals(let username, _): "/api/v1/users/\(username)/goals.json" case .requestAutodataFetch(let username, let goalname): "/api/v1/users/\(username)/goals/\(goalname)/refresh_graph.json" - case .updateDatapoint(let username, let goalname, let datapointID, _, _, _, _): "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" - case .updateGoal(let username, let goalname, _, _, _, _, _, _, _, _, _, _, _): "/api/v1/users/\(username)/goals/\(goalname).json" - case .createDatapoint(let username, let goalname, _, _, _, _, _, _): "/api/v1/users/\(username)/goals/\(goalname)/datapoints.json" - case .deletedDatapoint(let username, let goalname, let datapointID): "/api/v1/users/\(username)/goals/\(goalname)/datapoints/\(datapointID).json" } } - var method: HTTPMethod { return switch self { - case .signIn, .createDatapoint, .registerDeviceToken: - .post - case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: - .get - case .updateDatapoint, .updateGoal, .updateUser: - .put - case .deletedDatapoint: - .delete + case .signIn, .createDatapoint, .registerDeviceToken: .post + case .appVersions, .getUser, .getGoalDetails, .getDatapoints, .getGoals, .requestAutodataFetch: .get + case .updateDatapoint, .updateGoal, .updateUser: .put + case .deletedDatapoint: .delete } } - var parameters: [String: Any]? { switch self { case .signIn(let username, let password, let beemiosSecret): - return ["user": - [ - "login": username, - "password": password - ], - "beemios_secret": beemiosSecret] - as [String: Any] - + return ["user": ["login": username, "password": password], "beemios_secret": beemiosSecret] as [String: Any] case .getUser(_, let diff_since, let emaciated): var parameters: [String: Any] = [:] if let diff_since { parameters["diff_since"] = diff_since } if let emaciated { parameters["emaciated"] = emaciated } return parameters.isEmpty ? nil : parameters - case .getGoalDetails(_, _, let datapoints_count, let emaciated): var parameters: [String: Any] = [:] if let datapoints_count { parameters["datapoints_count"] = datapoints_count } if let emaciated { parameters["emaciated"] = emaciated } return parameters.isEmpty ? nil : parameters - case .getDatapoints(_, _, let sort, let count, let page, let per): var parameters: [String: Any] = [:] if let sort { parameters["sort"] = sort } @@ -161,11 +139,9 @@ public enum EndPoint { if let page { parameters["page"] = page } if let per { parameters["per"] = per } return parameters.isEmpty ? nil : parameters - case .getGoals(_, let emaciated): if let emaciated { return ["emaciated": emaciated] } return nil - case .updateDatapoint(_, _, _, let timestamp, let value, let comment, let urtext): var parameters: [String: Any] = [:] if let timestamp { parameters["timestamp"] = timestamp } @@ -173,8 +149,21 @@ public enum EndPoint { if let comment { parameters["comment"] = comment } if let urtext { parameters["urtext"] = urtext } return parameters.isEmpty ? nil : parameters - - case .updateGoal(_, _, let title, let tmin, let tmax, let isSecret, let isDataPublic, let tags, let iiParams, let leadtime, let alertstart, let deadline, let usesDefaultNotifications): + case .updateGoal( + _, + _, + let title, + let tmin, + let tmax, + let isSecret, + let isDataPublic, + let tags, + let iiParams, + let leadtime, + let alertstart, + let deadline, + let usesDefaultNotifications + ): var parameters: [String: Any] = [:] if let title { parameters["title"] = title } if let tmin { parameters["tmin"] = tmin } @@ -188,14 +177,12 @@ public enum EndPoint { if let deadline { parameters["deadline"] = deadline } if let usesDefaultNotifications { parameters["use_defaults"] = usesDefaultNotifications } return parameters.isEmpty ? nil : parameters - case .updateUser(_, let default_alertstart, let default_deadline, let default_leadtime): var parameters: [String: Any] = [:] if let default_alertstart { parameters["default_alertstart"] = default_alertstart } if let default_deadline { parameters["default_deadline"] = default_deadline } if let default_leadtime { parameters["default_leadtime"] = default_leadtime } return parameters.isEmpty ? nil : parameters - case .createDatapoint(_, _, let value, let timestamp, let daystamp, let comment, let urtext, let requestID): var parameters: [String: Any] = [:] if let value { parameters["value"] = value } @@ -205,24 +192,18 @@ public enum EndPoint { if let urtext { parameters["urtext"] = urtext } if let requestID { parameters["request_id"] = requestID } return parameters.isEmpty ? nil : parameters - case .registerDeviceToken(let token, let environment): var parameters: [String: Any] = [:] parameters["device_token"] = token if let environment { parameters["server"] = environment } return parameters.isEmpty ? nil : parameters - - default: - return nil + default: return nil } } - var shouldSign: Bool { return switch self { - case .registerDeviceToken: - true - default: - false + case .registerDeviceToken: true + default: false } } -} \ No newline at end of file +} diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 50c2a3932..93f9fa14b 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -83,7 +83,9 @@ import SwiftyJSON // We must fetch the user object first, and then fetch goals afterwards, to guarantee User.updated_at is // a safe timestamp for future fetches without losing data let userResponse = JSON(try await requestManager.request(endpoint: .getUser(username: user.username))!) - let goalResponse = JSON(try await requestManager.request(endpoint: .getGoals(username: user.username, emaciated: true))!) + let goalResponse = JSON( + try await requestManager.request(endpoint: .getGoals(username: user.username, emaciated: true))! + ) // The user may have logged out during the network operation. If so we have nothing to do modelContext.refreshAllObjects() @@ -101,9 +103,13 @@ import SwiftyJSON private func refreshGoalsIncremental(user: User) async throws { logger.notice("Doing incremental update since \(user.updatedAt, privacy: .public)") let userResponse = JSON( - try await requestManager.request(endpoint: .getUser(username: user.username, - diff_since: user.updatedAt.timeIntervalSince1970 + 1, - emaciated: true))! + try await requestManager.request( + endpoint: .getUser( + username: user.username, + diff_since: user.updatedAt.timeIntervalSince1970 + 1, + emaciated: true + ) + )! ) let goalResponse = userResponse["goals"] let deletedGoals = userResponse["deleted_goals"] @@ -124,10 +130,14 @@ import SwiftyJSON public func refreshGoal(_ goalID: NSManagedObjectID) async throws { let goal = try modelContext.existingObject(with: goalID) as! Goal - let responseObject = try await requestManager.request(endpoint: .getGoalDetails(username: goal.owner.username, - goalname: goal.slug, - datapoints_count: 5, - emaciated: true)) + let responseObject = try await requestManager.request( + endpoint: .getGoalDetails( + username: goal.owner.username, + goalname: goal.slug, + datapoints_count: 5, + emaciated: true + ) + ) let goalJSON = JSON(responseObject!) // The goal may have changed during the network operation, reload latest version @@ -140,7 +150,9 @@ import SwiftyJSON } public func forceAutodataRefresh(_ goal: Goal) async throws { - let _ = try await requestManager.request(endpoint: .requestAutodataFetch(username: goal.owner.username, goalname: goal.slug)) + let _ = try await requestManager.request( + endpoint: .requestAutodataFetch(username: goal.owner.username, goalname: goal.slug) + ) } private func updateGoalsFromJson(_ responseJSON: JSON) { diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index fa3e73302..2f4a9d998 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -11,44 +11,32 @@ import Foundation import OSLog import SwiftyJSON - - -public protocol RequestManaging { - func request(endpoint: EndPoint) async throws -> Any? -} +public protocol RequestManaging { func request(endpoint: EndPoint) async throws -> Any? } public class RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") - public func request(endpoint: EndPoint) async throws -> Any? { print("rawRequest(endpoint) \(endpoint)") - let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters - return try await rawRequest(url: endpoint.url, - method: endpoint.method, - parameters: parameters, - headers: authenticationHeaders()) + return try await rawRequest( + url: endpoint.url, + method: endpoint.method, + parameters: parameters, + headers: authenticationHeaders() + ) } } -fileprivate extension RequestManager { - func rawRequest(url: URL, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) async throws - -> Any? +extension RequestManager { + fileprivate func rawRequest(url: URL, method: HTTPMethod, parameters: [String: Any]? = nil, headers: HTTPHeaders) + async throws -> Any? { let encoding: ParameterEncoding = method == .get ? URLEncoding.default : JSONEncoding.default let headers = HTTPHeaders.default + headers - logger.debug("rawRequest: \(url.absoluteString), method \(method.rawValue)") - - let response = await AF.request( - url, - method: method, - parameters: parameters, - encoding: encoding, - headers: headers - ).validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response - + let response = await AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: headers) + .validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response switch response.result { case .success(let data): let asJSON = try? JSONSerialization.jsonObject(with: data) @@ -81,19 +69,17 @@ fileprivate extension RequestManager { } } } - throw error } } - - func authenticationHeaders() -> HTTPHeaders { + fileprivate func authenticationHeaders() -> HTTPHeaders { guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) } } -fileprivate extension RequestManager { - func signedParameters(_ params: [String: Any]?) -> [String: Any]? { +extension RequestManager { + fileprivate func signedParameters(_ params: [String: Any]?) -> [String: Any]? { if params == nil { return params } var signed = params var base = "" @@ -114,8 +100,8 @@ fileprivate extension RequestManager { } } -fileprivate extension HTTPHeaders { - static func + (lhs: HTTPHeaders, rhs: HTTPHeaders) -> HTTPHeaders { +extension HTTPHeaders { + fileprivate static func + (lhs: HTTPHeaders, rhs: HTTPHeaders) -> HTTPHeaders { var allHeaders = [HTTPHeader]() allHeaders.append(contentsOf: lhs) allHeaders.append(contentsOf: rhs) diff --git a/BeeKit/Managers/ServerError.swift b/BeeKit/Managers/ServerError.swift index d87e7ced1..900c1a65c 100644 --- a/BeeKit/Managers/ServerError.swift +++ b/BeeKit/Managers/ServerError.swift @@ -1,12 +1,11 @@ // Part of BeeSwift. Copyright Beeminder - import Alamofire import Foundation import OSLog import SwiftyJSON -public enum ServerError: LocalizedError { +public enum ServerError: LocalizedError { case notFound case unauthorized case forbidden @@ -27,4 +26,4 @@ public enum ServerError: LocalizedError { default: return nil } } -} \ No newline at end of file +} diff --git a/BeeKit/ServiceLocator.swift b/BeeKit/ServiceLocator.swift index c7c8e54ae..34b7d4d96 100644 --- a/BeeKit/ServiceLocator.swift +++ b/BeeKit/ServiceLocator.swift @@ -15,7 +15,6 @@ public class ServiceLocator { public static let persistentContainer = BeeminderPersistentContainer.create() public static let requestManager = RequestManager() - public static let currentUserManager = CurrentUserManager( requestManager: requestManager, container: persistentContainer diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index dc18da639..783e6db2c 100644 --- a/BeeKitTests/DataPointManagerTests.swift +++ b/BeeKitTests/DataPointManagerTests.swift @@ -34,7 +34,6 @@ class MockRequestManagerForDataPoint: RequestManaging { func request(endpoint: EndPoint) async throws -> Any? { let urlString = endpoint.url.absoluteString - // Determine HTTP method from endpoint path patterns if urlString.contains("/datapoints/") && urlString.contains("datapoints.json") { // POST - create new datapoint diff --git a/BeeSwift/AppDelegate.swift b/BeeSwift/AppDelegate.swift index 1796344c7..cd9da821d 100644 --- a/BeeSwift/AppDelegate.swift +++ b/BeeSwift/AppDelegate.swift @@ -85,16 +85,15 @@ import UIKit Task { @MainActor in let token = deviceToken.reduce("", { $0 + String(format: "%02X", $1) }) var environment: String? - if isDevelopmentBuild() { environment = "development" logger.notice("Registering device token for development APNS server") } do { - let _ = try await ServiceLocator.requestManager.request(endpoint: .registerDeviceToken(token: token, - environment: environment)) - + let _ = try await ServiceLocator.requestManager.request( + endpoint: .registerDeviceToken(token: token, environment: environment) + ) } catch { logger.error("Error sending device push token: \(error)") } } } diff --git a/BeeSwift/GoalView/EditDatapointViewController.swift b/BeeSwift/GoalView/EditDatapointViewController.swift index 4cc447ead..9a1d640d5 100644 --- a/BeeSwift/GoalView/EditDatapointViewController.swift +++ b/BeeSwift/GoalView/EditDatapointViewController.swift @@ -199,10 +199,14 @@ class EditDatapointViewController: UIViewController, UITextFieldDelegate { hud.mode = .indeterminate do { - let _ = try await self.requestManager.request(endpoint: .updateDatapoint(username: goal.owner.username, - goalname: goal.slug, - datapointID: datapoint.id, - urtext: urtext())) + let _ = try await self.requestManager.request( + endpoint: .updateDatapoint( + username: goal.owner.username, + goalname: goal.slug, + datapointID: datapoint.id, + urtext: urtext() + ) + ) try await self.goalManager.refreshGoal(self.goal.objectID) hud.mode = .customView @@ -221,9 +225,9 @@ class EditDatapointViewController: UIViewController, UITextFieldDelegate { hud.mode = .indeterminate do { - let _ = try await self.requestManager.request(endpoint: .deletedDatapoint(username: goal.owner.username, - goalname: goal.slug, - datapointID: datapoint.id)) + let _ = try await self.requestManager.request( + endpoint: .deletedDatapoint(username: goal.owner.username, goalname: goal.slug, datapointID: datapoint.id) + ) try await self.goalManager.refreshGoal(self.goal.objectID) hud.mode = .customView diff --git a/BeeSwift/GoalView/GoalViewController.swift b/BeeSwift/GoalView/GoalViewController.swift index 7c8de1aed..36e8aa2bf 100644 --- a/BeeSwift/GoalView/GoalViewController.swift +++ b/BeeSwift/GoalView/GoalViewController.swift @@ -501,9 +501,9 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable self.scrollView.scrollRectToVisible(CGRect(x: 0, y: 0, width: 0, height: 0), animated: true) do { - let _ = try await self.requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, - goalname: goal.slug, - urtext: self.urtext)) + let _ = try await self.requestManager.request( + endpoint: .createDatapoint(username: goal.owner.username, goalname: goal.slug, urtext: self.urtext) + ) self.commentTextField.text = "" try await updateGoalAndInterface() diff --git a/BeeSwift/GoalView/TimerViewController.swift b/BeeSwift/GoalView/TimerViewController.swift index cd0ad5984..8e830a38d 100644 --- a/BeeSwift/GoalView/TimerViewController.swift +++ b/BeeSwift/GoalView/TimerViewController.swift @@ -147,9 +147,9 @@ class TimerViewController: UIViewController { Task { @MainActor in do { - let _ = try await requestManager.request(endpoint: .createDatapoint(username: goal.owner.username, - goalname: goal.slug, - urtext: self.urtext())) + let _ = try await requestManager.request( + endpoint: .createDatapoint(username: goal.owner.username, goalname: goal.slug, urtext: self.urtext()) + ) hud.mode = .text hud.label.text = "Added!" DispatchQueue.main.asyncAfter( diff --git a/BeeSwift/Intents/AddData.swift b/BeeSwift/Intents/AddData.swift index e871a5ed8..4a4a83246 100644 --- a/BeeSwift/Intents/AddData.swift +++ b/BeeSwift/Intents/AddData.swift @@ -36,9 +36,9 @@ struct AddData: DeprecatedAppIntent, CustomIntentMigratedAppIntent, PredictableI let dataComment = comment ?? "" do { let urtext = "^ \(dataValue) \"\(dataComment)\"" - let _ = try await ServiceLocator.requestManager.request(endpoint: .createDatapoint(username: username, - goalname: goalSlug, - urtext: urtext)) + let _ = try await ServiceLocator.requestManager.request( + endpoint: .createDatapoint(username: username, goalname: goalSlug, urtext: urtext) + ) return .result(dialog: .responseSuccess(goal: goalSlug, value: dataValue)) } catch ServerError.notFound { throw AddDataError.apiError("Goal '\(goalSlug)' not found") } catch { throw AddDataError.apiError(error.localizedDescription) diff --git a/BeeSwift/Intents/AddDataPointIntent.swift b/BeeSwift/Intents/AddDataPointIntent.swift index b202fe861..c8ddc430e 100644 --- a/BeeSwift/Intents/AddDataPointIntent.swift +++ b/BeeSwift/Intents/AddDataPointIntent.swift @@ -14,14 +14,11 @@ struct AddDataPointIntent: AppIntent { func perform() async throws -> some IntentResult & ProvidesDialog { let dataComment = comment ?? "" do { - guard let username = await ServiceLocator.currentUserManager.username else { - throw AddDataError.noUser - } + guard let username = await ServiceLocator.currentUserManager.username else { throw AddDataError.noUser } let urtext = "^ \(value) \"\(dataComment)\"" - let _ = try await ServiceLocator.requestManager.request(endpoint: .createDatapoint(username: username, - goalname: goal.slug, - urtext: urtext)) - + let _ = try await ServiceLocator.requestManager.request( + endpoint: .createDatapoint(username: username, goalname: goal.slug, urtext: urtext) + ) // Use displayTitle to show title with slug fallback let formatter = NumberFormatter() formatter.minimumFractionDigits = 0 diff --git a/BeeSwift/Settings/ConfigureHKMetricViewController.swift b/BeeSwift/Settings/ConfigureHKMetricViewController.swift index 3175b7cb4..a1b72074f 100644 --- a/BeeSwift/Settings/ConfigureHKMetricViewController.swift +++ b/BeeSwift/Settings/ConfigureHKMetricViewController.swift @@ -327,9 +327,9 @@ class ConfigureHKMetricViewController: UIViewController { } do { - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: goal.owner.username, - goalname: goal.slug, - iiParams: iiParams)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal(username: goal.owner.username, goalname: goal.slug, iiParams: iiParams) + ) hud.mode = .customView hud.customView = UIImageView(image: UIImage(systemName: "checkmark")) hud.hide(animated: true, afterDelay: 2) @@ -361,15 +361,15 @@ class ConfigureHKMetricViewController: UIViewController { isRequestInFlight = true disconnectButton.isUserInteractionEnabled = false - let iiParams: [String : Any?] = ["name": nil, "metric": ""] + let iiParams: [String: Any?] = ["name": nil, "metric": ""] let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate Task { @MainActor in do { - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: goal.owner.username, - goalname: goal.slug, - iiParams: iiParams)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal(username: goal.owner.username, goalname: goal.slug, iiParams: iiParams) + ) if let goalManager = self.goalManager { try await goalManager.refreshGoal(self.goal.objectID) } diff --git a/BeeSwift/Settings/EditDefaultNotificationsViewController.swift b/BeeSwift/Settings/EditDefaultNotificationsViewController.swift index c78092aa3..c10b793e0 100644 --- a/BeeSwift/Settings/EditDefaultNotificationsViewController.swift +++ b/BeeSwift/Settings/EditDefaultNotificationsViewController.swift @@ -38,12 +38,12 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { override func sendLeadTimeToServer(_ timer: Timer) { // We must not use `timer` in the Task as it may change once this method returns guard let userInfo = timer.userInfo as? [String: NSNumber] else { return } - Task { @MainActor in guard let leadtime = userInfo["leadtime"]?.intValue else { return } do { - let _ = try await requestManager.request(endpoint: .updateUser(username: user.username, - default_leadtime: leadtime)) + let _ = try await requestManager.request( + endpoint: .updateUser(username: user.username, default_leadtime: leadtime) + ) try await goalManager.refreshGoals() } catch { logger.error("Error setting default leadtime: \(error)") // show alert @@ -63,7 +63,9 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { let alertstart = self.midnightOffsetFromTimePickerView() self.updateAlertstartLabel(alertstart) do { - let _ = try await requestManager.request(endpoint: .updateUser(username: user.username, default_alertstart: alertstart)) + let _ = try await requestManager.request( + endpoint: .updateUser(username: user.username, default_alertstart: alertstart) + ) try await goalManager.refreshGoals() hud.hide(animated: true, afterDelay: 0.5) } catch { @@ -74,7 +76,9 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController { let deadline = self.deadlineFromTimePickerView self.updateDeadlineLabel(deadline) do { - let _ = try await requestManager.request(endpoint: .updateUser(username: user.username, default_deadline: deadline)) + let _ = try await requestManager.request( + endpoint: .updateUser(username: user.username, default_deadline: deadline) + ) try await goalManager.refreshGoals() hud.hide(animated: true, afterDelay: 0.5) } catch { diff --git a/BeeSwift/Settings/EditGoalNotificationsViewController.swift b/BeeSwift/Settings/EditGoalNotificationsViewController.swift index e742116d1..8cd2fc663 100644 --- a/BeeSwift/Settings/EditGoalNotificationsViewController.swift +++ b/BeeSwift/Settings/EditGoalNotificationsViewController.swift @@ -71,14 +71,17 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { override func sendLeadTimeToServer(_ timer: Timer) { // We must not use `timer` in the Task as it may change once this method returns guard let userInfo = timer.userInfo as? [String: NSNumber] else { return } - Task { @MainActor in let leadtime = userInfo["leadtime"]?.intValue do { - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: goal.owner.username, - goalname: goal.slug, - leadtime: leadtime, - usesDefaultNotifications: false)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal( + username: goal.owner.username, + goalname: goal.slug, + leadtime: leadtime, + usesDefaultNotifications: false + ) + ) try await self.goalManager.refreshGoal(self.goal.objectID) @@ -92,14 +95,17 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate if self.timePickerEditingMode == .alertstart { - do { let alertstart = self.midnightOffsetFromTimePickerView() self.updateAlertstartLabel(alertstart) - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: user.username, - goalname: goal.slug, - alertstart: alertstart, - usesDefaultNotifications: false)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal( + username: user.username, + goalname: goal.slug, + alertstart: alertstart, + usesDefaultNotifications: false + ) + ) try await self.goalManager.refreshGoal(self.goal.objectID) self.useDefaultsSwitch.isOn = false @@ -114,10 +120,14 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { do { let deadline = self.deadlineFromTimePickerView self.updateDeadlineLabel(deadline) - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: user.username, - goalname: goal.slug, - deadline: deadline, - usesDefaultNotifications: false)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal( + username: user.username, + goalname: goal.slug, + deadline: deadline, + usesDefaultNotifications: false + ) + ) try await self.goalManager.refreshGoal(self.goal.objectID) self.useDefaultsSwitch.isOn = false @@ -153,9 +163,13 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate do { - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: self.user.username, - goalname: self.goal.slug, - usesDefaultNotifications: true)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal( + username: self.user.username, + goalname: self.goal.slug, + usesDefaultNotifications: true + ) + ) try await self.goalManager.refreshGoal(self.goal.objectID) hud.hide(animated: true, afterDelay: 0.5) } catch { @@ -193,9 +207,9 @@ class EditGoalNotificationsViewController: EditNotificationsViewController { let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .indeterminate do { - let _ = try await self.requestManager.request(endpoint: .updateGoal(username: user.username, - goalname: goal.slug, - usesDefaultNotifications: false)) + let _ = try await self.requestManager.request( + endpoint: .updateGoal(username: user.username, goalname: goal.slug, usesDefaultNotifications: false) + ) try await self.goalManager.refreshGoal(self.goal.objectID) hud.hide(animated: true, afterDelay: 0.5) } catch { From 20350f228d686e89aec3c873b9cefa93f598ea58 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:42:41 +0100 Subject: [PATCH 15/22] Endpoint --- BeeKit/Managers/{EndPoint.swift => Endpoint.swift} | 2 +- BeeKit/Managers/RequestManager.swift | 4 ++-- BeeKitTests/DataPointManagerTests.swift | 2 +- BeeKitTests/GoalManagerTests.swift | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) rename BeeKit/Managers/{EndPoint.swift => Endpoint.swift} (99%) diff --git a/BeeKit/Managers/EndPoint.swift b/BeeKit/Managers/Endpoint.swift similarity index 99% rename from BeeKit/Managers/EndPoint.swift rename to BeeKit/Managers/Endpoint.swift index d8d5211d8..de0c0ada4 100644 --- a/BeeKit/Managers/EndPoint.swift +++ b/BeeKit/Managers/Endpoint.swift @@ -5,7 +5,7 @@ import Foundation import OSLog import SwiftyJSON -public enum EndPoint { +public enum Endpoint { // signing in case signIn(username: String, password: String, beemiosSecret: String) // about the app versions the server expects to see diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 2f4a9d998..55ce26764 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -11,12 +11,12 @@ import Foundation import OSLog import SwiftyJSON -public protocol RequestManaging { func request(endpoint: EndPoint) async throws -> Any? } +public protocol RequestManaging { func request(endpoint: Endpoint) async throws -> Any? } public class RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") - public func request(endpoint: EndPoint) async throws -> Any? { + public func request(endpoint: Endpoint) async throws -> Any? { print("rawRequest(endpoint) \(endpoint)") let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters return try await rawRequest( diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index 783e6db2c..1b6666c9c 100644 --- a/BeeKitTests/DataPointManagerTests.swift +++ b/BeeKitTests/DataPointManagerTests.swift @@ -32,7 +32,7 @@ class MockRequestManagerForDataPoint: RequestManaging { var deleteCalls: [String] { queue.sync { _deleteCalls } } var addDatapointCalls: [(url: String, parameters: [String: Any])] { queue.sync { _addDatapointCalls } } - func request(endpoint: EndPoint) async throws -> Any? { + func request(endpoint: Endpoint) async throws -> Any? { let urlString = endpoint.url.absoluteString // Determine HTTP method from endpoint path patterns if urlString.contains("/datapoints/") && urlString.contains("datapoints.json") { diff --git a/BeeKitTests/GoalManagerTests.swift b/BeeKitTests/GoalManagerTests.swift index 730673664..ad4741b3e 100644 --- a/BeeKitTests/GoalManagerTests.swift +++ b/BeeKitTests/GoalManagerTests.swift @@ -6,12 +6,12 @@ import XCTest @testable import BeeKit class MockRequestManager: RequestManaging { - func request(endpoint: BeeKit.EndPoint) async throws -> Any? { + func request(endpoint: BeeKit.Endpoint) async throws -> Any? { let keyUrlStr = endpoint.url.absoluteString guard let response = responses[keyUrlStr] else { XCTFail("Unexpected URL requested: \(keyUrlStr)") - return nil - } + return nil + } return response } From 44101adcae44d7323897edc4b88326ed1d7b0b4f Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:50:03 +0100 Subject: [PATCH 16/22] debug logging --- BeeKit/Managers/RequestManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 55ce26764..d855745e7 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -17,7 +17,6 @@ public class RequestManager: RequestManaging { public let baseURLString = Config().baseURLString private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "RequestManager") public func request(endpoint: Endpoint) async throws -> Any? { - print("rawRequest(endpoint) \(endpoint)") let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters return try await rawRequest( url: endpoint.url, From 5b530c2113f797a9721422fc0c883d6705816750 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:17:55 +0100 Subject: [PATCH 17/22] and now the tests work with the enum endpoint --- BeeKitTests/DataPointManagerTests.swift | 50 +++++++++++-------------- BeeKitTests/GoalManagerTests.swift | 5 +-- 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index 1b6666c9c..e0eac91d0 100644 --- a/BeeKitTests/DataPointManagerTests.swift +++ b/BeeKitTests/DataPointManagerTests.swift @@ -33,37 +33,29 @@ class MockRequestManagerForDataPoint: RequestManaging { var addDatapointCalls: [(url: String, parameters: [String: Any])] { queue.sync { _addDatapointCalls } } func request(endpoint: Endpoint) async throws -> Any? { - let urlString = endpoint.url.absoluteString - // Determine HTTP method from endpoint path patterns - if urlString.contains("/datapoints/") && urlString.contains("datapoints.json") { - // POST - create new datapoint - queue.sync { _addDatapointCalls.append((url: urlString, parameters: endpoint.parameters ?? [:])) } + switch endpoint { + case .createDatapoint: + queue.sync { _addDatapointCalls.append((url: endpoint.path, parameters: endpoint.parameters ?? [:])) } return [:] - } else if urlString.contains("/datapoints/") && !urlString.contains("datapoints.json") { - // Has specific datapoint ID - could be PUT or DELETE - // Check if it's a delete (usually has the datapoint ID in the path) - if endpoint.parameters == nil || endpoint.parameters?.isEmpty == true { - // Likely a delete - queue.sync { _deleteCalls.append(urlString) } - return [:] - } else { - // PUT - update datapoint - queue.sync { _putCalls.append((url: urlString, parameters: endpoint.parameters ?? [:])) } - return [:] - } - } else { - // GET - fetch datapoints + case .updateDatapoint: + queue.sync { _putCalls.append((url: endpoint.path, parameters: endpoint.parameters ?? [:])) } + return [:] + case .deletedDatapoint: + queue.sync { _deleteCalls.append(endpoint.path) } + return [:] + case .getDatapoints: let response = queue.sync { () -> Any? in - if let response = _responses[urlString] { - _responses.removeValue(forKey: urlString) + // Attempt an exact match on path first + if let response = _responses[endpoint.path] { + _responses.removeValue(forKey: endpoint.path) return response } return nil } return response ?? [] + default: return [] } } - } class DataPointManagerTests: XCTestCase { @@ -116,7 +108,7 @@ class DataPointManagerTests: XCTestCase { "is_dummy": true, "is_initial": false, ], ] - mockRequestManager.responses["api/v1/users/{username}/goals/test-goal/datapoints.json"] = apiResponse + mockRequestManager.responses["/api/v1/users/test_user/goals/test-goal/datapoints.json"] = apiResponse let updatedHealthKitDatapoint = MockHealthKitDataPoint( daystamp: try Daystamp(fromString: "20221201"), value: NSNumber(value: 15), @@ -137,7 +129,7 @@ class DataPointManagerTests: XCTestCase { // Should update the existing datapoint by requestId XCTAssertEqual(mockRequestManager.putCalls.count, 1) XCTAssertTrue(mockRequestManager.putCalls[0].url.contains("existing1")) - XCTAssertEqual(mockRequestManager.putCalls[0].parameters["value"] as? String, "15") + XCTAssertEqual(mockRequestManager.putCalls[0].parameters["value"] as? NSNumber, 15) XCTAssertEqual(mockRequestManager.putCalls[0].parameters["comment"] as? String, "Updated workout comment") // Should delete the obsolete datapoint XCTAssertEqual(mockRequestManager.deleteCalls.count, 1) @@ -146,7 +138,7 @@ class DataPointManagerTests: XCTestCase { XCTAssertEqual(mockRequestManager.addDatapointCalls.count, 1) XCTAssertEqual(mockRequestManager.addDatapointCalls[0].parameters["urtext"] as? String, "1 25 \"New workout\"") XCTAssertTrue(mockRequestManager.addDatapointCalls[0].url.contains("/goals/test-goal")) - XCTAssertEqual(mockRequestManager.addDatapointCalls[0].parameters["requestId"] as? String, "hk_workout_2") + XCTAssertEqual(mockRequestManager.addDatapointCalls[0].parameters["request_id"] as? String, "hk_workout_2") } func testDeletesRemovedWorkouts() async throws { let apiResponse = [ @@ -159,7 +151,7 @@ class DataPointManagerTests: XCTestCase { "is_dummy": false, "is_initial": false, "requestid": "hk_workout_uuid_2", ], ] - mockRequestManager.responses["api/v1/users/{username}/goals/test-goal/datapoints.json"] = apiResponse + mockRequestManager.responses["/api/v1/users/test_user/goals/test-goal/datapoints.json"] = apiResponse // Only one workout remains in HealthKit let remainingWorkout = MockHealthKitDataPoint( daystamp: try Daystamp(fromString: "20221201"), @@ -188,7 +180,7 @@ class DataPointManagerTests: XCTestCase { "is_dummy": false, "is_initial": false, "requestid": "uuid_2", ], ] - mockRequestManager.responses["api/v1/users/{username}/goals/test-goal/datapoints.json"] = apiResponse + mockRequestManager.responses["/api/v1/users/test_user/goals/test-goal/datapoints.json"] = apiResponse let day1Workouts = [ MockHealthKitDataPoint( daystamp: try Daystamp(fromString: "20221201"), @@ -221,8 +213,8 @@ class DataPointManagerTests: XCTestCase { XCTAssertTrue(mockRequestManager.deleteCalls[0].contains("day2_workout1")) // Should create 2 new workouts (day1 yoga, day2 bike) XCTAssertEqual(mockRequestManager.addDatapointCalls.count, 2) - XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.parameters["requestId"] as? String == "uuid_3" }) - XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.parameters["requestId"] as? String == "uuid_4" }) + XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.parameters["request_id"] as? String == "uuid_3" }) + XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.parameters["request_id"] as? String == "uuid_4" }) } private func createTestGoalJSON() -> JSON { return JSON( diff --git a/BeeKitTests/GoalManagerTests.swift b/BeeKitTests/GoalManagerTests.swift index ad4741b3e..e6e32d7af 100644 --- a/BeeKitTests/GoalManagerTests.swift +++ b/BeeKitTests/GoalManagerTests.swift @@ -10,9 +10,8 @@ class MockRequestManager: RequestManaging { let keyUrlStr = endpoint.url.absoluteString guard let response = responses[keyUrlStr] else { XCTFail("Unexpected URL requested: \(keyUrlStr)") - return nil - } - + return nil + } return response } var responses: [String: Any] = [:] From d819fa3836c94abb243ac5dd08ace9fccfd43329 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:00:25 +0100 Subject: [PATCH 18/22] deeplinkgen clean code --- BeeSwift/DeeplinkGenerator.swift | 50 ++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/BeeSwift/DeeplinkGenerator.swift b/BeeSwift/DeeplinkGenerator.swift index 932213d54..7f149f6e3 100644 --- a/BeeSwift/DeeplinkGenerator.swift +++ b/BeeSwift/DeeplinkGenerator.swift @@ -5,31 +5,57 @@ // Created by krugerk on 2024-11-29. // +import Foundation + +public enum GoalTab: String { + case commitment + case stop + case data + case statistics + case settings +} + +public enum WebEndpoint { + case goal(username: String, goalName: String, tab: GoalTab) + case apiRedirect(username: String, accessToken: String, redirectTo: URL) + public var url: URL { + var components = URLComponents() + components.scheme = "https" + components.host = "www.beeminder.com" + switch self { + case .goal(let username, let goalName, let tab): + components.path = "/\(username)/\(goalName)" + components.fragment = tab.rawValue + case .apiRedirect(let username, let accessToken, let redirectTo): + components.path = "/api/v1/users/\(username).json" + components.queryItems = [ + URLQueryItem(name: "access_token", value: accessToken), + URLQueryItem(name: "redirect_to_url", value: redirectTo.absoluteString), + ] + } + return components.url! + } +} + struct DeeplinkGenerator { public static func generateDeepLinkToGoalCommitment(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#commitment")! + WebEndpoint.goal(username: username, goalName: goalName, tab: .commitment).url } public static func generateDeepLinkToGoalStop(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#stop")! + WebEndpoint.goal(username: username, goalName: goalName, tab: .stop).url } public static func generateDeepLinkToGoalData(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#data")! + WebEndpoint.goal(username: username, goalName: goalName, tab: .data).url } public static func generateDeepLinkToGoalStatistics(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#statistics")! + WebEndpoint.goal(username: username, goalName: goalName, tab: .statistics).url } public static func generateDeepLinkToGoalSettings(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#settings")! + WebEndpoint.goal(username: username, goalName: goalName, tab: .settings).url } public static func generateDeepLinkToUrl(accessToken: String, username: String, url: URL) -> URL { - let baseUrlString = "https://www.beeminder.com/api/v1/users/\(username).json" - var components = URLComponents(string: baseUrlString)! - components.queryItems = [ - URLQueryItem(name: "access_token", value: accessToken), - URLQueryItem(name: "redirect_to_url", value: url.absoluteString), - ] - return components.url! + WebEndpoint.apiRedirect(username: username, accessToken: accessToken, redirectTo: url).url } } From da0a09d050315173f08c19585571cf37d63b224a Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:04:34 +0100 Subject: [PATCH 19/22] clean code --- BeeSwift/DeeplinkGenerator.swift | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/BeeSwift/DeeplinkGenerator.swift b/BeeSwift/DeeplinkGenerator.swift index 7f149f6e3..728eb4a78 100644 --- a/BeeSwift/DeeplinkGenerator.swift +++ b/BeeSwift/DeeplinkGenerator.swift @@ -38,22 +38,10 @@ public enum WebEndpoint { } struct DeeplinkGenerator { - public static func generateDeepLinkToGoalCommitment(username: String, goalName: String) -> URL { - WebEndpoint.goal(username: username, goalName: goalName, tab: .commitment).url + public static func webURLToGoal(username: String, goalName: String, tab: GoalTab) -> URL { + WebEndpoint.goal(username: username, goalName: goalName, tab: tab).url } - public static func generateDeepLinkToGoalStop(username: String, goalName: String) -> URL { - WebEndpoint.goal(username: username, goalName: goalName, tab: .stop).url } - - public static func generateDeepLinkToGoalData(username: String, goalName: String) -> URL { - WebEndpoint.goal(username: username, goalName: goalName, tab: .data).url - } - - public static func generateDeepLinkToGoalStatistics(username: String, goalName: String) -> URL { - WebEndpoint.goal(username: username, goalName: goalName, tab: .statistics).url - } - public static func generateDeepLinkToGoalSettings(username: String, goalName: String) -> URL { - WebEndpoint.goal(username: username, goalName: goalName, tab: .settings).url } public static func generateDeepLinkToUrl(accessToken: String, username: String, url: URL) -> URL { WebEndpoint.apiRedirect(username: username, accessToken: accessToken, redirectTo: url).url From 2b66593a63ec9133b4b05bb9676da5ab1d9851b7 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:06:07 +0100 Subject: [PATCH 20/22] DeeplinkGenerator clean code --- BeeSwift/DeeplinkGenerator.swift | 13 +++++++++--- BeeSwift/GoalView/GoalViewController.swift | 24 +++++++++++----------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/BeeSwift/DeeplinkGenerator.swift b/BeeSwift/DeeplinkGenerator.swift index 728eb4a78..65e0e8783 100644 --- a/BeeSwift/DeeplinkGenerator.swift +++ b/BeeSwift/DeeplinkGenerator.swift @@ -41,9 +41,16 @@ struct DeeplinkGenerator { public static func webURLToGoal(username: String, goalName: String, tab: GoalTab) -> URL { WebEndpoint.goal(username: username, goalName: goalName, tab: tab).url } + static func generateAuthenticatedDeepLink(accessToken: String, username: String, destinationURL: URL) -> URL { + WebEndpoint.apiRedirect(username: username, accessToken: accessToken, redirectTo: destinationURL).url } - } - public static func generateDeepLinkToUrl(accessToken: String, username: String, url: URL) -> URL { - WebEndpoint.apiRedirect(username: username, accessToken: accessToken, redirectTo: url).url + public static func generateAuthenticatedDeepLinkToGoal( + tab: GoalTab, + username: String, + goalName: String, + accessToken: String + ) -> URL { + let destinationURL = webURLToGoal(username: username, goalName: goalName, tab: tab) + return generateAuthenticatedDeepLink(accessToken: accessToken, username: username, destinationURL: destinationURL) } } diff --git a/BeeSwift/GoalView/GoalViewController.swift b/BeeSwift/GoalView/GoalViewController.swift index 36e8aa2bf..2cdae9b29 100644 --- a/BeeSwift/GoalView/GoalViewController.swift +++ b/BeeSwift/GoalView/GoalViewController.swift @@ -613,21 +613,21 @@ extension GoalViewController { case goalSettings func makeLink(username: String, goalName: String, currentUserManager: CurrentUserManager) -> URL? { guard let accessToken = currentUserManager.accessToken else { return nil } - let destinationUrl: URL + let tab: GoalTab switch self { case .inAppSettings: return nil - case .goalCommitment: - destinationUrl = DeeplinkGenerator.generateDeepLinkToGoalCommitment(username: username, goalName: goalName) - case .goalStop: - destinationUrl = DeeplinkGenerator.generateDeepLinkToGoalStop(username: username, goalName: goalName) - case .goalData: - destinationUrl = DeeplinkGenerator.generateDeepLinkToGoalData(username: username, goalName: goalName) - case .goalStatistics: - destinationUrl = DeeplinkGenerator.generateDeepLinkToGoalStatistics(username: username, goalName: goalName) - case .goalSettings: - destinationUrl = DeeplinkGenerator.generateDeepLinkToGoalSettings(username: username, goalName: goalName) + case .goalCommitment: tab = .commitment + case .goalStop: tab = .stop + case .goalData: tab = .data + case .goalStatistics: tab = .statistics + case .goalSettings: tab = .settings } - return DeeplinkGenerator.generateDeepLinkToUrl(accessToken: accessToken, username: username, url: destinationUrl) + return DeeplinkGenerator.generateAuthenticatedDeepLinkToGoal( + tab: tab, + username: username, + goalName: goalName, + accessToken: accessToken + ) } } fileprivate struct MenuOption { From e037c8e9a1af0d4ae548311d3c85e93b2cfb9a84 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:23:20 +0200 Subject: [PATCH 21/22] refactoring away from force unwrap --- BeeKit/Managers/RequestManager.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index d855745e7..9e9c8dd15 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -79,23 +79,25 @@ extension RequestManager { extension RequestManager { fileprivate func signedParameters(_ params: [String: Any]?) -> [String: Any]? { - if params == nil { return params } + guard let params else { return nil } + var signed = params var base = "" - var keys = Array(params!.keys) + var keys = Array(params.keys) keys.sort(by: { $0 < $1 }) for key in keys { - let value: AnyObject? = params![key] as AnyObject? - if !(value is String) { return params! } + let value: AnyObject? = params[key] as AnyObject? + if !(value is String) { return params } let allowedCharacterSet = (CharacterSet(charactersIn: "@/").inverted) let escapedKey = key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) - let escapedValue = (params![key] as AnyObject).addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) + let escapedValue = (params[key] as AnyObject).addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) if base.count > 0 { base += "&" } base += "\(escapedKey!)=\(escapedValue!)" } let token = base.hmac(algorithm: HMACAlgorithm.SHA1, key: Config().requestSigningKey) - signed?["beemios_token"] = token - return signed! as [String: Any] + signed["beemios_token"] = token + + return signed as [String: Any] } } From fa3d178db4be096b13927cd82ff268a1735f6680 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:59:31 +0200 Subject: [PATCH 22/22] swift-format --recursive --in-place . --- BeeKit/Managers/RequestManager.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 9e9c8dd15..124a3e3eb 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -80,7 +80,6 @@ extension RequestManager { extension RequestManager { fileprivate func signedParameters(_ params: [String: Any]?) -> [String: Any]? { guard let params else { return nil } - var signed = params var base = "" var keys = Array(params.keys) @@ -96,7 +95,6 @@ extension RequestManager { } let token = base.hmac(algorithm: HMACAlgorithm.SHA1, key: Config().requestSigningKey) signed["beemios_token"] = token - return signed as [String: Any] } }