NightscoutUtils.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. // LoopFollow
  2. // NightscoutUtils.swift
  3. import Foundation
  4. class NightscoutUtils {
  5. enum NightscoutError: Error, LocalizedError {
  6. case emptyAddress
  7. case invalidURL
  8. case networkError
  9. case siteNotFound
  10. case invalidToken
  11. case tokenRequired
  12. case unknown
  13. var errorDescription: String? {
  14. switch self {
  15. case .emptyAddress:
  16. return "The address is empty."
  17. case .invalidURL:
  18. return "The URL is invalid."
  19. case .networkError:
  20. return "A network error occurred."
  21. case .siteNotFound:
  22. return "The site was not found."
  23. case .invalidToken:
  24. return "The token is invalid."
  25. case .tokenRequired:
  26. return "A token is required."
  27. case .unknown:
  28. return "An unknown error occurred."
  29. }
  30. }
  31. }
  32. enum EventType: String {
  33. case cage = "Site Change"
  34. case carbsToday = "Carb Correction"
  35. case sage = "Sensor Start"
  36. case sgv
  37. case profile
  38. case treatments
  39. case deviceStatus
  40. case iage = "Insulin Change"
  41. case temporaryOverride = "Temporary Override"
  42. case temporaryOverrideCancel = "Temporary Override Cancel"
  43. case dbSize
  44. var endpoint: String {
  45. switch self {
  46. case .cage, .carbsToday, .sage, .treatments, .iage:
  47. return "/api/v1/treatments.json"
  48. case .sgv:
  49. return "/api/v1/entries.json"
  50. case .profile:
  51. return "/api/v1/profiles.json"
  52. case .deviceStatus:
  53. return "/api/v1/devicestatus.json"
  54. case .temporaryOverride, .temporaryOverrideCancel:
  55. return "/api/v2/notifications/loop"
  56. case .dbSize:
  57. return "/api/v2/properties/dbsize"
  58. }
  59. }
  60. }
  61. static func executeRequest<T: Decodable>(eventType: EventType, parameters: [String: String], completion: @escaping (Result<T, Error>) -> Void) {
  62. let baseURL = Storage.shared.url.value
  63. let token = Storage.shared.token.value
  64. guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
  65. completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
  66. return
  67. }
  68. var request = URLRequest(url: url)
  69. request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
  70. let task = URLSession.shared.dataTask(with: request) { data, _, error in
  71. guard let data = data, error == nil else {
  72. completion(.failure(error!))
  73. return
  74. }
  75. let decoder = JSONDecoder()
  76. do {
  77. let decodedObject = try decoder.decode(T.self, from: data)
  78. DispatchQueue.main.async {
  79. completion(.success(decodedObject))
  80. }
  81. } catch let decodingError as DecodingError {
  82. let typeName = String(describing: T.self)
  83. switch decodingError {
  84. case let .typeMismatch(type, context):
  85. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
  86. case let .valueNotFound(type, context):
  87. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
  88. case let .keyNotFound(key, context):
  89. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
  90. case .dataCorrupted:
  91. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
  92. @unknown default:
  93. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
  94. }
  95. completion(.failure(decodingError))
  96. } catch {
  97. LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
  98. completion(.failure(error))
  99. }
  100. }
  101. task.resume()
  102. }
  103. static func executeDynamicRequest(eventType: EventType, parameters: [String: String], completion: @escaping (Result<Any, Error>) -> Void) {
  104. let baseURL = Storage.shared.url.value
  105. let token = Storage.shared.token.value
  106. guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
  107. completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
  108. return
  109. }
  110. var request = URLRequest(url: url)
  111. request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
  112. let task = URLSession.shared.dataTask(with: request) { data, _, error in
  113. guard let data = data, error == nil else {
  114. completion(.failure(error!))
  115. return
  116. }
  117. do {
  118. if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
  119. DispatchQueue.main.async {
  120. completion(.success(jsonObject))
  121. }
  122. } else if let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] {
  123. DispatchQueue.main.async {
  124. completion(.success(jsonArray))
  125. }
  126. } else {
  127. completion(.failure(NSError(domain: "NightscoutUtils", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON Structure"])))
  128. }
  129. } catch {
  130. completion(.failure(error))
  131. }
  132. }
  133. task.resume()
  134. }
  135. static func createURLRequest(url: String, token: String?, path: String) -> URLRequest? {
  136. var requestURLString = "\(url)\(path)"
  137. if let token = token {
  138. let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token
  139. requestURLString += "?token=\(encodedToken)"
  140. }
  141. guard let requestURL = URL(string: requestURLString) else {
  142. return nil
  143. }
  144. var request = URLRequest(url: requestURL)
  145. request.httpMethod = "GET"
  146. return request
  147. }
  148. // Strip whitespace/newlines/control characters that can sneak in via paste.
  149. // Neither a URL nor a token may legally contain them, and a stray one breaks
  150. // WebSocket connect (invalid percent-encoded query traps on iOS 26) or makes
  151. // URL parsing fall back to a lossy cleanup that mangles the address.
  152. static func sanitizeConnectionInput(_ input: String) -> String {
  153. input.unicodeScalars
  154. .filter { !CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.controlCharacters.contains($0) }
  155. .map(String.init)
  156. .joined()
  157. }
  158. static func constructURL(baseURL: String, token: String?, endpoint: String, parameters: [String: String]) -> URL? {
  159. var components = URLComponents(string: baseURL)
  160. components?.path = endpoint
  161. var queryItems = [URLQueryItem]()
  162. if let token = token, !token.isEmpty {
  163. queryItems.append(URLQueryItem(name: "token", value: token))
  164. }
  165. for (key, value) in parameters {
  166. queryItems.append(URLQueryItem(name: key, value: value))
  167. }
  168. components?.queryItems = queryItems
  169. return components?.url
  170. }
  171. static func verifyURLAndToken(completion: @escaping (NightscoutError?, String?, Bool, Bool) -> Void) {
  172. let urlUser = Storage.shared.url.value
  173. let token = Storage.shared.token.value
  174. if urlUser.isEmpty {
  175. completion(.emptyAddress, nil, false, false)
  176. return
  177. }
  178. guard let _ = URL(string: urlUser), urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://") else {
  179. completion(.invalidURL, nil, false, false)
  180. return
  181. }
  182. guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json") else {
  183. completion(.invalidURL, nil, false, false)
  184. return
  185. }
  186. let task = URLSession.shared.dataTask(with: request) { data, response, error in
  187. var nsWriteAuth = false
  188. var nsAdminAuth = false
  189. if let httpResponse = response as? HTTPURLResponse {
  190. switch httpResponse.statusCode {
  191. case 200:
  192. if let data = data {
  193. do {
  194. if let jsonResponse = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  195. let authorized = jsonResponse["authorized"] as? [String: Any],
  196. let token = authorized["token"] as? String,
  197. let permissionGroups = authorized["permissionGroups"] as? [[String]]
  198. {
  199. if permissionGroups.contains(where: { $0.contains("*") }) {
  200. nsWriteAuth = true
  201. nsAdminAuth = true
  202. } else if permissionGroups.contains(where: { $0.contains("api:treatments:create") }) {
  203. nsWriteAuth = true
  204. }
  205. completion(nil, token, nsWriteAuth, nsAdminAuth)
  206. } else {
  207. completion(nil, nil, false, false)
  208. }
  209. } catch {
  210. completion(nil, nil, false, false)
  211. }
  212. } else {
  213. completion(nil, nil, false, false)
  214. }
  215. case 401:
  216. if token.isEmpty {
  217. completion(.tokenRequired, nil, false, false)
  218. } else {
  219. completion(.invalidToken, nil, false, false)
  220. }
  221. default:
  222. completion(.unknown, nil, false, false)
  223. }
  224. } else {
  225. if let _ = error {
  226. completion(.siteNotFound, nil, false, false)
  227. } else {
  228. completion(.networkError, nil, false, false)
  229. }
  230. }
  231. }
  232. task.resume()
  233. }
  234. static func parseDate(_ rawString: String) -> Date? {
  235. var mutableDate = rawString
  236. if mutableDate.hasSuffix("Z") {
  237. mutableDate = String(mutableDate.dropLast())
  238. } else if let offsetRange = mutableDate.range(of: "[\\+\\-]\\d{2}:\\d{2}$",
  239. options: .regularExpression)
  240. {
  241. mutableDate.removeSubrange(offsetRange)
  242. }
  243. mutableDate = mutableDate.replacingOccurrences(
  244. of: "\\.\\d+",
  245. with: "",
  246. options: .regularExpression
  247. )
  248. let dateFormatter = DateFormatter()
  249. dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
  250. dateFormatter.locale = Locale(identifier: "en_US")
  251. dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
  252. let result = dateFormatter.date(from: mutableDate)
  253. if result == nil {
  254. print("Unable to parse string: '\(mutableDate)'")
  255. }
  256. return result
  257. }
  258. static func retrieveJWTToken() async throws -> String {
  259. let urlUser = Storage.shared.url.value
  260. let token = Storage.shared.token.value
  261. if urlUser.isEmpty {
  262. throw NightscoutError.emptyAddress
  263. }
  264. guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json"),
  265. urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://")
  266. else {
  267. throw NightscoutError.invalidURL
  268. }
  269. let sessionConfig = URLSessionConfiguration.default
  270. sessionConfig.waitsForConnectivity = true
  271. sessionConfig.networkServiceType = .responsiveData
  272. let session = URLSession(configuration: sessionConfig)
  273. let (data, response) = try await session.data(for: request)
  274. guard let httpResponse = response as? HTTPURLResponse else {
  275. throw NightscoutError.networkError
  276. }
  277. switch httpResponse.statusCode {
  278. case 200:
  279. if let jsonResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  280. let authorized = jsonResponse["authorized"] as? [String: Any],
  281. let jwtToken = authorized["token"] as? String
  282. {
  283. return jwtToken
  284. } else {
  285. throw NightscoutError.invalidToken
  286. }
  287. case 401:
  288. throw token.isEmpty ? NightscoutError.tokenRequired : NightscoutError.invalidToken
  289. default:
  290. throw NightscoutError.unknown
  291. }
  292. }
  293. static func executePostRequest<T: Decodable>(eventType: EventType, body: [String: Any]) async throws -> T {
  294. let jwtToken = try await retrieveJWTToken()
  295. let baseURL = Storage.shared.url.value
  296. guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
  297. throw NightscoutError.invalidURL
  298. }
  299. var request = URLRequest(url: url)
  300. request.httpMethod = "POST"
  301. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  302. request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
  303. request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
  304. let sessionConfig = URLSessionConfiguration.default
  305. sessionConfig.waitsForConnectivity = true
  306. sessionConfig.networkServiceType = .responsiveData
  307. let session = URLSession(configuration: sessionConfig)
  308. let (data, response) = try await session.data(for: request)
  309. guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
  310. throw NightscoutError.networkError
  311. }
  312. let decoder = JSONDecoder()
  313. return try decoder.decode(T.self, from: data)
  314. }
  315. static func executePostRequest(eventType: EventType, body: [String: Any]) async throws -> String {
  316. let jwtToken = try await retrieveJWTToken()
  317. let baseURL = Storage.shared.url.value
  318. guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
  319. throw NightscoutError.invalidURL
  320. }
  321. var request = URLRequest(url: url)
  322. request.httpMethod = "POST"
  323. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  324. request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
  325. request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
  326. let sessionConfig = URLSessionConfiguration.default
  327. sessionConfig.waitsForConnectivity = true
  328. sessionConfig.networkServiceType = .responsiveData
  329. let session = URLSession(configuration: sessionConfig)
  330. let (data, response) = try await session.data(for: request)
  331. var responseString: String
  332. responseString = String(data: data, encoding: .utf8) ?? ""
  333. guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
  334. if responseString != "" {
  335. return responseString
  336. } else {
  337. throw NightscoutError.networkError
  338. }
  339. }
  340. return responseString
  341. }
  342. static func extractErrorReason(from responseString: String) -> String {
  343. // 1) Try to parse the entire string as JSON and return the "message"
  344. if let data = responseString.data(using: .utf8) {
  345. if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  346. let message = json["message"] as? String
  347. {
  348. return message
  349. }
  350. }
  351. // 2) If not valid JSON (or no "message"), try to parse it as HTML <title>
  352. if let startRange = responseString.range(of: "<title>"),
  353. let endRange = responseString.range(of: "</title>")
  354. {
  355. let titleRange = startRange.upperBound ..< endRange.lowerBound
  356. let titleContent = responseString[titleRange].trimmingCharacters(in: .whitespacesAndNewlines)
  357. if !titleContent.isEmpty {
  358. return titleContent
  359. }
  360. }
  361. // 3) Fallback: just return the entire raw string
  362. return responseString
  363. }
  364. }