NightscoutUtils.swift 15 KB

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