| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 |
- // LoopFollow
- // NightscoutUtils.swift
- import Foundation
- class NightscoutUtils {
- enum NightscoutError: Error, LocalizedError {
- case emptyAddress
- case invalidURL
- case networkError
- case siteNotFound
- case invalidToken
- case tokenRequired
- case unknown
- var errorDescription: String? {
- switch self {
- case .emptyAddress:
- return "The address is empty."
- case .invalidURL:
- return "The URL is invalid."
- case .networkError:
- return "A network error occurred."
- case .siteNotFound:
- return "The site was not found."
- case .invalidToken:
- return "The token is invalid."
- case .tokenRequired:
- return "A token is required."
- case .unknown:
- return "An unknown error occurred."
- }
- }
- }
- enum EventType: String {
- case cage = "Site Change"
- case carbsToday = "Carb Correction"
- case sage = "Sensor Start"
- case sgv
- case profile
- case treatments
- case deviceStatus
- case iage = "Insulin Change"
- case temporaryOverride = "Temporary Override"
- case temporaryOverrideCancel = "Temporary Override Cancel"
- case dbSize
- var endpoint: String {
- switch self {
- case .cage, .carbsToday, .sage, .treatments, .iage:
- return "/api/v1/treatments.json"
- case .sgv:
- return "/api/v1/entries.json"
- case .profile:
- return "/api/v1/profiles.json"
- case .deviceStatus:
- return "/api/v1/devicestatus.json"
- case .temporaryOverride, .temporaryOverrideCancel:
- return "/api/v2/notifications/loop"
- case .dbSize:
- return "/api/v2/properties/dbsize"
- }
- }
- }
- static func executeRequest<T: Decodable>(eventType: EventType, parameters: [String: String], completion: @escaping (Result<T, Error>) -> Void) {
- let baseURL = Storage.shared.url.value
- let token = Storage.shared.token.value
- guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
- completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
- return
- }
- var request = URLRequest(url: url)
- request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
- let task = URLSession.shared.dataTask(with: request) { data, _, error in
- guard let data = data, error == nil else {
- completion(.failure(error!))
- return
- }
- let decoder = JSONDecoder()
- do {
- let decodedObject = try decoder.decode(T.self, from: data)
- DispatchQueue.main.async {
- completion(.success(decodedObject))
- }
- } catch let decodingError as DecodingError {
- let typeName = String(describing: T.self)
- switch decodingError {
- case let .typeMismatch(type, context):
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
- case let .valueNotFound(type, context):
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
- case let .keyNotFound(key, context):
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
- case .dataCorrupted:
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
- @unknown default:
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
- }
- completion(.failure(decodingError))
- } catch {
- LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
- completion(.failure(error))
- }
- }
- task.resume()
- }
- static func executeDynamicRequest(eventType: EventType, parameters: [String: String], completion: @escaping (Result<Any, Error>) -> Void) {
- let baseURL = Storage.shared.url.value
- let token = Storage.shared.token.value
- guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
- completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
- return
- }
- var request = URLRequest(url: url)
- request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
- let task = URLSession.shared.dataTask(with: request) { data, _, error in
- guard let data = data, error == nil else {
- completion(.failure(error!))
- return
- }
- do {
- if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
- DispatchQueue.main.async {
- completion(.success(jsonObject))
- }
- } else if let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] {
- DispatchQueue.main.async {
- completion(.success(jsonArray))
- }
- } else {
- completion(.failure(NSError(domain: "NightscoutUtils", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON Structure"])))
- }
- } catch {
- completion(.failure(error))
- }
- }
- task.resume()
- }
- static func createURLRequest(url: String, token: String?, path: String) -> URLRequest? {
- var requestURLString = "\(url)\(path)"
- if let token = token {
- let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token
- requestURLString += "?token=\(encodedToken)"
- }
- guard let requestURL = URL(string: requestURLString) else {
- return nil
- }
- var request = URLRequest(url: requestURL)
- request.httpMethod = "GET"
- return request
- }
- // Strip whitespace/newlines/control characters that can sneak in via paste.
- // Neither a URL nor a token may legally contain them, and a stray one breaks
- // WebSocket connect (invalid percent-encoded query traps on iOS 26) or makes
- // URL parsing fall back to a lossy cleanup that mangles the address.
- static func sanitizeConnectionInput(_ input: String) -> String {
- input.unicodeScalars
- .filter { !CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.controlCharacters.contains($0) }
- .map(String.init)
- .joined()
- }
- static func constructURL(baseURL: String, token: String?, endpoint: String, parameters: [String: String]) -> URL? {
- var components = URLComponents(string: baseURL)
- components?.path = endpoint
- var queryItems = [URLQueryItem]()
- if let token = token, !token.isEmpty {
- queryItems.append(URLQueryItem(name: "token", value: token))
- }
- for (key, value) in parameters {
- queryItems.append(URLQueryItem(name: key, value: value))
- }
- components?.queryItems = queryItems
- return components?.url
- }
- static func verifyURLAndToken(completion: @escaping (NightscoutError?, String?, Bool, Bool) -> Void) {
- let urlUser = Storage.shared.url.value
- let token = Storage.shared.token.value
- if urlUser.isEmpty {
- completion(.emptyAddress, nil, false, false)
- return
- }
- guard let _ = URL(string: urlUser), urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://") else {
- completion(.invalidURL, nil, false, false)
- return
- }
- guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json") else {
- completion(.invalidURL, nil, false, false)
- return
- }
- let task = URLSession.shared.dataTask(with: request) { data, response, error in
- var nsWriteAuth = false
- var nsAdminAuth = false
- if let httpResponse = response as? HTTPURLResponse {
- switch httpResponse.statusCode {
- case 200:
- if let data = data {
- do {
- if let jsonResponse = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let authorized = jsonResponse["authorized"] as? [String: Any],
- let token = authorized["token"] as? String,
- let permissionGroups = authorized["permissionGroups"] as? [[String]]
- {
- if permissionGroups.contains(where: { $0.contains("*") }) {
- nsWriteAuth = true
- nsAdminAuth = true
- } else if permissionGroups.contains(where: { $0.contains("api:treatments:create") }) {
- nsWriteAuth = true
- }
- completion(nil, token, nsWriteAuth, nsAdminAuth)
- } else {
- completion(nil, nil, false, false)
- }
- } catch {
- completion(nil, nil, false, false)
- }
- } else {
- completion(nil, nil, false, false)
- }
- case 401:
- if token.isEmpty {
- completion(.tokenRequired, nil, false, false)
- } else {
- completion(.invalidToken, nil, false, false)
- }
- default:
- completion(.unknown, nil, false, false)
- }
- } else {
- if let _ = error {
- completion(.siteNotFound, nil, false, false)
- } else {
- completion(.networkError, nil, false, false)
- }
- }
- }
- task.resume()
- }
- static func parseDate(_ rawString: String) -> Date? {
- var mutableDate = rawString
- if mutableDate.hasSuffix("Z") {
- mutableDate = String(mutableDate.dropLast())
- } else if let offsetRange = mutableDate.range(of: "[\\+\\-]\\d{2}:\\d{2}$",
- options: .regularExpression)
- {
- mutableDate.removeSubrange(offsetRange)
- }
- mutableDate = mutableDate.replacingOccurrences(
- of: "\\.\\d+",
- with: "",
- options: .regularExpression
- )
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
- dateFormatter.locale = Locale(identifier: "en_US")
- dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
- let result = dateFormatter.date(from: mutableDate)
- if result == nil {
- print("Unable to parse string: '\(mutableDate)'")
- }
- return result
- }
- static func retrieveJWTToken() async throws -> String {
- let urlUser = Storage.shared.url.value
- let token = Storage.shared.token.value
- if urlUser.isEmpty {
- throw NightscoutError.emptyAddress
- }
- guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json"),
- urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://")
- else {
- throw NightscoutError.invalidURL
- }
- let sessionConfig = URLSessionConfiguration.default
- sessionConfig.waitsForConnectivity = true
- sessionConfig.networkServiceType = .responsiveData
- let session = URLSession(configuration: sessionConfig)
- let (data, response) = try await session.data(for: request)
- guard let httpResponse = response as? HTTPURLResponse else {
- throw NightscoutError.networkError
- }
- switch httpResponse.statusCode {
- case 200:
- if let jsonResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let authorized = jsonResponse["authorized"] as? [String: Any],
- let jwtToken = authorized["token"] as? String
- {
- return jwtToken
- } else {
- throw NightscoutError.invalidToken
- }
- case 401:
- throw token.isEmpty ? NightscoutError.tokenRequired : NightscoutError.invalidToken
- default:
- throw NightscoutError.unknown
- }
- }
- static func executePostRequest<T: Decodable>(eventType: EventType, body: [String: Any]) async throws -> T {
- let jwtToken = try await retrieveJWTToken()
- let baseURL = Storage.shared.url.value
- guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
- throw NightscoutError.invalidURL
- }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
- request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
- let sessionConfig = URLSessionConfiguration.default
- sessionConfig.waitsForConnectivity = true
- sessionConfig.networkServiceType = .responsiveData
- let session = URLSession(configuration: sessionConfig)
- let (data, response) = try await session.data(for: request)
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
- throw NightscoutError.networkError
- }
- let decoder = JSONDecoder()
- return try decoder.decode(T.self, from: data)
- }
- static func executePostRequest(eventType: EventType, body: [String: Any]) async throws -> String {
- let jwtToken = try await retrieveJWTToken()
- let baseURL = Storage.shared.url.value
- guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
- throw NightscoutError.invalidURL
- }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
- request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
- let sessionConfig = URLSessionConfiguration.default
- sessionConfig.waitsForConnectivity = true
- sessionConfig.networkServiceType = .responsiveData
- let session = URLSession(configuration: sessionConfig)
- let (data, response) = try await session.data(for: request)
- var responseString: String
- responseString = String(data: data, encoding: .utf8) ?? ""
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
- if responseString != "" {
- return responseString
- } else {
- throw NightscoutError.networkError
- }
- }
- return responseString
- }
- static func extractErrorReason(from responseString: String) -> String {
- // 1) Try to parse the entire string as JSON and return the "message"
- if let data = responseString.data(using: .utf8) {
- if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let message = json["message"] as? String
- {
- return message
- }
- }
- // 2) If not valid JSON (or no "message"), try to parse it as HTML <title>
- if let startRange = responseString.range(of: "<title>"),
- let endRange = responseString.range(of: "</title>")
- {
- let titleRange = startRange.upperBound ..< endRange.lowerBound
- let titleContent = responseString[titleRange].trimmingCharacters(in: .whitespacesAndNewlines)
- if !titleContent.isEmpty {
- return titleContent
- }
- }
- // 3) Fallback: just return the entire raw string
- return responseString
- }
- }
|