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/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/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index da0df4487..d7b0495bc 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() @@ -123,10 +123,8 @@ 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/DataPointManager.swift b/BeeKit/Managers/DataPointManager.swift index 1480c4fb1..79966263a 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() @@ -30,28 +30,38 @@ 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 { - 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.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] { - 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!) diff --git a/BeeKit/Managers/Endpoint.swift b/BeeKit/Managers/Endpoint.swift new file mode 100644 index 000000000..de0c0ada4 --- /dev/null +++ b/BeeKit/Managers/Endpoint.swift @@ -0,0 +1,209 @@ +// 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 + } + } +} diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 6d93a1fc4..93f9fa14b 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() @@ -82,9 +82,9 @@ 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 userResponse = JSON(try await requestManager.request(endpoint: .getUser(username: user.username))!) let goalResponse = JSON( - try await requestManager.get(url: "api/v1/users/{username}/goals.json", parameters: ["emaciated": "true"])! + 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 @@ -103,9 +103,12 @@ 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"] @@ -127,9 +130,13 @@ 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!) @@ -143,8 +150,8 @@ 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) ) } diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index 8113b6114..124a3e3eb 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -11,63 +11,38 @@ 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? } -public class RequestManager { +public class RequestManager: RequestManaging { 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 - -> 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 response = await AF.request( - "\(baseURLString)/\(urlWithSubstitutions)", - method: method, + public func request(endpoint: Endpoint) async throws -> Any? { + let parameters = endpoint.shouldSign ? signedParameters(endpoint.parameters) : endpoint.parameters + return try await rawRequest( + url: endpoint.url, + method: endpoint.method, parameters: parameters, - encoding: encoding, - headers: HTTPHeaders.default + headers - ).validate().serializingData(emptyRequestMethods: [HTTPMethod.post]).response + headers: authenticationHeaders() + ) + } +} +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 switch response.result { case .success(let data): let asJSON = try? JSONSerialization.jsonObject(with: data) 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 { @@ -93,35 +68,39 @@ 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()) - } - 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 { + fileprivate func authenticationHeaders() -> HTTPHeaders { guard let accessToken = ServiceLocator.currentUserManager.accessToken else { return HTTPHeaders() } return HTTPHeaders([HTTPHeader(name: "Authorization", value: "Bearer " + accessToken)]) } +} - 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) +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) + 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] } } extension HTTPHeaders { - static func + (lhs: HTTPHeaders, rhs: HTTPHeaders) -> 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 new file mode 100644 index 000000000..900c1a65c --- /dev/null +++ b/BeeKit/Managers/ServerError.swift @@ -0,0 +1,29 @@ +// 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 + } + } +} diff --git a/BeeKit/Managers/SignedRequestManager.swift b/BeeKit/Managers/SignedRequestManager.swift deleted file mode 100644 index 3cbac6ce5..000000000 --- a/BeeKit/Managers/SignedRequestManager.swift +++ /dev/null @@ -1,54 +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 signedGET(url: String, parameters: [String: Any]?) async throws -> Any? { - let params = signedParameters(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, - 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/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 } diff --git a/BeeKit/ServiceLocator.swift b/BeeKit/ServiceLocator.swift index c1c6dcdf3..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 signedRequestManager = SignedRequestManager(requestManager: requestManager) public static let currentUserManager = CurrentUserManager( requestManager: requestManager, container: persistentContainer diff --git a/BeeKitTests/DataPointManagerTests.swift b/BeeKitTests/DataPointManagerTests.swift index 06bf76a88..e0eac91d0 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,29 +30,31 @@ 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? { + switch endpoint { + case .createDatapoint: + queue.sync { _addDatapointCalls.append((url: endpoint.path, parameters: endpoint.parameters ?? [:])) } + return [:] + 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 + // Attempt an exact match on path first + if let response = _responses[endpoint.path] { + _responses.removeValue(forKey: endpoint.path) + return response + } + return nil } - return nil + return response ?? [] + default: return [] } - 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 [:] } } @@ -106,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), @@ -127,16 +129,16 @@ 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) 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["request_id"] as? String, "hk_workout_2") } func testDeletesRemovedWorkouts() async throws { let apiResponse = [ @@ -149,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"), @@ -178,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"), @@ -211,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.requestId == "uuid_3" }) - XCTAssertTrue(mockRequestManager.addDatapointCalls.contains { $0.requestId == "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 361d19951..e6e32d7af 100644 --- a/BeeKitTests/GoalManagerTests.swift +++ b/BeeKitTests/GoalManagerTests.swift @@ -5,13 +5,16 @@ 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 +88,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 +126,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 +197,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 +256,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: [] ) 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/AppDelegate.swift b/BeeSwift/AppDelegate.swift index fc3f77a8e..cd9da821d 100644 --- a/BeeSwift/AppDelegate.swift +++ b/BeeSwift/AppDelegate.swift @@ -84,17 +84,15 @@ 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.requestManager.request( + endpoint: .registerDeviceToken(token: token, environment: environment) ) } catch { logger.error("Error sending device push token: \(error)") } } 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" -//} diff --git a/BeeSwift/DeeplinkGenerator.swift b/BeeSwift/DeeplinkGenerator.swift index 932213d54..65e0e8783 100644 --- a/BeeSwift/DeeplinkGenerator.swift +++ b/BeeSwift/DeeplinkGenerator.swift @@ -5,31 +5,52 @@ // Created by krugerk on 2024-11-29. // -struct DeeplinkGenerator { - public static func generateDeepLinkToGoalCommitment(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#commitment")! - } - public static func generateDeepLinkToGoalStop(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#stop")! - } +import Foundation + +public enum GoalTab: String { + case commitment + case stop + case data + case statistics + case settings +} - public static func generateDeepLinkToGoalData(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#data")! +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! } +} - public static func generateDeepLinkToGoalStatistics(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#statistics")! +struct DeeplinkGenerator { + public static func webURLToGoal(username: String, goalName: String, tab: GoalTab) -> URL { + WebEndpoint.goal(username: username, goalName: goalName, tab: tab).url } - public static func generateDeepLinkToGoalSettings(username: String, goalName: String) -> URL { - URL(string: "https://www.beeminder.com/\(username)/\(goalName)#settings")! + 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 { - 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! + 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/EditDatapointViewController.swift b/BeeSwift/GoalView/EditDatapointViewController.swift index a9a36e3c5..9a1d640d5 100644 --- a/BeeSwift/GoalView/EditDatapointViewController.swift +++ b/BeeSwift/GoalView/EditDatapointViewController.swift @@ -199,10 +199,13 @@ 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) @@ -222,8 +225,8 @@ 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) diff --git a/BeeSwift/GoalView/GoalViewController.swift b/BeeSwift/GoalView/GoalViewController.swift index b722b29b6..2cdae9b29 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() @@ -611,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 { diff --git a/BeeSwift/GoalView/TimerViewController.swift b/BeeSwift/GoalView/TimerViewController.swift index d0718a2f7..8e830a38d 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..4a4a83246 100644 --- a/BeeSwift/Intents/AddData.swift +++ b/BeeSwift/Intents/AddData.swift @@ -32,11 +32,12 @@ 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 { 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 8b1e20b5d..c8ddc430e 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? @@ -14,9 +14,10 @@ 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() diff --git a/BeeSwift/Settings/ConfigureHKMetricViewController.swift b/BeeSwift/Settings/ConfigureHKMetricViewController.swift index f296dfef9..a1b72074f 100644 --- a/BeeSwift/Settings/ConfigureHKMetricViewController.swift +++ b/BeeSwift/Settings/ConfigureHKMetricViewController.swift @@ -325,12 +325,10 @@ 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")) @@ -363,15 +361,14 @@ 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..c10b793e0 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,12 @@ 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 +75,10 @@ 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..8cd2fc663 100644 --- a/BeeSwift/Settings/EditGoalNotificationsViewController.swift +++ b/BeeSwift/Settings/EditGoalNotificationsViewController.swift @@ -70,14 +70,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 - 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,12 +95,16 @@ 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) @@ -110,13 +117,16 @@ 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) @@ -153,10 +163,12 @@ 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) @@ -195,10 +207,8 @@ 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)