diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 63e4ed7..79500ac 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -15,10 +15,10 @@ A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; }; A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; }; A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* MainViewModel.swift */; }; - A70171AF2AB2116B00064C43 /* NetworkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkController.swift */; }; + A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; }; A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; }; A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; }; - A70171B92AB399FB00064C43 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B82AB399FB00064C43 /* Extensions.swift */; }; + A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */; }; A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BB2AB4983500064C43 /* CategoryCardView.swift */; }; A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; }; A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; }; @@ -28,6 +28,8 @@ A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C82AB4CBB400064C43 /* OnboardingView.swift */; }; A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserDefaults.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; + A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; }; + A703226D2ABAF90D00D7C4ED /* APIInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226C2ABAF90D00D7C4ED /* APIInterface.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,10 +62,10 @@ A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = ""; }; A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = ""; }; A70171AC2AA8EF4700064C43 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; - A70171AE2AB2116B00064C43 /* NetworkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkController.swift; sourceTree = ""; }; + A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = ""; }; A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = ""; }; A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = ""; }; - A70171B82AB399FB00064C43 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatterExtension.swift; sourceTree = ""; }; A70171BB2AB4983500064C43 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = ""; }; A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = ""; }; A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = ""; }; @@ -73,6 +75,8 @@ A70171C82AB4CBB400064C43 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A70171CA2AB4CD1700064C43 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = ""; }; + A703226C2ABAF90D00D7C4ED /* APIInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIInterface.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -128,6 +132,7 @@ A70171BA2AB4980100064C43 /* Views */, A70171B72AB2445700064C43 /* ViewModels */, A70171B22AB211F000064C43 /* Network */, + A703226B2ABAF60D00D7C4ED /* Extensions */, A70171852AA8E71F00064C43 /* Assets.xcassets */, A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */, A70171882AA8E71F00064C43 /* Preview Content */, @@ -163,9 +168,9 @@ A70171B22AB211F000064C43 /* Network */ = { isa = PBXGroup; children = ( - A70171B82AB399FB00064C43 /* Extensions.swift */, + A703226C2ABAF90D00D7C4ED /* APIInterface.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */, - A70171AE2AB2116B00064C43 /* NetworkController.swift */, + A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, A70171B02AB211DF00064C43 /* CustomError.swift */, ); path = Network; @@ -203,6 +208,15 @@ path = Data; sourceTree = ""; }; + A703226B2ABAF60D00D7C4ED /* Extensions */ = { + isa = PBXGroup; + children = ( + A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */, + A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -335,9 +349,9 @@ files = ( A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, - A70171AF2AB2116B00064C43 /* NetworkController.swift in Sources */, + A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, - A70171B92AB399FB00064C43 /* Extensions.swift in Sources */, + A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */, A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */, A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, @@ -346,6 +360,8 @@ A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */, + A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, + A703226D2ABAF90D00D7C4ED /* APIInterface.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */, diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 74fd09c..470d301 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -47,7 +47,8 @@ struct RecipeDetail: Codable { keywords: "", dateCreated: "", dateModified: "", - imageUrl: "", id: "", + imageUrl: "", + id: "", prepTime: "", cookTime: "", totalTime: "", @@ -68,4 +69,18 @@ struct RecipeImage { var full: UIImage? } +struct LoginV2Request: Codable { + let poll: LoginV2Poll + let login: String +} +struct LoginV2Poll: Codable { + let token: String + let endpoint: String +} + +struct LoginV2Response: Codable { + let server: String + let loginName: String + let appPassword: String +} diff --git a/Nextcloud Cookbook iOS Client/Network/Extensions.swift b/Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift similarity index 96% rename from Nextcloud Cookbook iOS Client/Network/Extensions.swift rename to Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift index 384735c..8d69ec6 100644 --- a/Nextcloud Cookbook iOS Client/Network/Extensions.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/DateFormatterExtension.swift @@ -1,5 +1,5 @@ // -// Extensions.swift +// DateFormatterExtension.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 14.09.23. diff --git a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift new file mode 100644 index 0000000..89b8498 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift @@ -0,0 +1,33 @@ +// +// JSONCoderExtension.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 20.09.23. +// + +import Foundation + +extension JSONDecoder { + static func safeDecode(_ data: Data) -> T? { + let decoder = JSONDecoder() + do { + print("Decoding type ", T.self, " ...") + return try decoder.decode(T.self, from: data) + } catch (let error) { + print("JSONDecoder - safeDecode(): Failed to decode data.") + print("Error: ", error) + return nil + } + } +} + +extension JSONEncoder { + static func safeEncode(_ object: T) -> Data? { + do { + return try JSONEncoder().encode(object) + } catch { + print("JSONDecoder - safeEncode(): Could not encode object \(T.self)") + } + return nil + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/APIInterface.swift b/Nextcloud Cookbook iOS Client/Network/APIInterface.swift new file mode 100644 index 0000000..abd809f --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/APIInterface.swift @@ -0,0 +1,78 @@ +// +// APIInterface.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 20.09.23. +// + +import Foundation + +class APIInterface { + var userSettings: UserSettings + + var apiPath: String + var authString: String + let apiVersion = "1" + + init(userSettings: UserSettings) { + print("Initializing NetworkController.") + self.userSettings = userSettings + + self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" + + let loginString = "\(userSettings.username):\(userSettings.token)" + let loginData = loginString.data(using: String.Encoding.utf8)! + self.authString = loginData.base64EncodedString() + } + + func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? { + do { + let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb)) + let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: apiPath, + authString: authString + ) + guard let data = data else { + print("Error receiving or decoding data.") + return nil + } + return data + } catch { + print("Could not load image from server.") + } + return nil + } +} + +extension APIInterface { + func sendDataRequest(_ request: RequestWrapper) async -> (D?, Error?) { + do { + let (data, error) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: apiPath, + authString: authString + ) + if let data = data { + return (JSONDecoder.safeDecode(data), error) + } + return (nil, error) + } catch { + print("An unknown network error occured.") + } + return (nil, NetworkError.unknownError) + } + + func sendRequest(_ request: RequestWrapper) async -> Error? { + do { + return try await NetworkHandler.sendHTTPRequest( + request, + hostPath: apiPath, + authString: authString + ).1 + } catch { + print("An unknown network error occured.") + } + return NetworkError.unknownError + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/CustomError.swift b/Nextcloud Cookbook iOS Client/Network/CustomError.swift index c9f6727..c3f8867 100644 --- a/Nextcloud Cookbook iOS Client/Network/CustomError.swift +++ b/Nextcloud Cookbook iOS Client/Network/CustomError.swift @@ -13,3 +13,15 @@ public enum NotImplementedError: Error, CustomStringConvertible { return "Function not implemented." } } + +public enum NetworkError: String, Error { + case missingUrl = "Missing URL." + case parametersNil = "Parameters are nil." + case encodingFailed = "Parameter encoding failed." + case redirectionError = "Redirection error" + case clientError = "Client error" + case serverError = "Server error" + case invalidRequest = "Invalid request" + case unknownError = "Unknown error" + case dataError = "Invalid data error." +} diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkController.swift b/Nextcloud Cookbook iOS Client/Network/NetworkController.swift deleted file mode 100644 index 25b728e..0000000 --- a/Nextcloud Cookbook iOS Client/Network/NetworkController.swift +++ /dev/null @@ -1,248 +0,0 @@ -// -// NetworkController.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 13.09.23. -// - -import Foundation - -public enum NetworkError: String, Error { - case missingUrl = "Missing URL." - case parametersNil = "Parameters are nil." - case encodingFailed = "Parameter encoding failed." - case redirectionError = "Redirection error" - case clientError = "Client error" - case serverError = "Server error" - case invalidRequest = "Invalid request" - case unknownError = "Unknown error" - case dataError = "Invalid data error." -} - -class NetworkController { - var userSettings: UserSettings - var authString: String - var cookBookUrlString: String - - let apiVersion = "1" - - init() { - print("Initializing NetworkController.") - self.userSettings = UserSettings() - self.cookBookUrlString = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" - - let loginString = "\(userSettings.username):\(userSettings.token)" - let loginData = loginString.data(using: String.Encoding.utf8)! - self.authString = loginData.base64EncodedString() - } - - func fetchData(path: String) async throws -> Data? { - - let url = URL(string: "\(cookBookUrlString)/\(path)")! - - var request = URLRequest(url: url) - - request.httpMethod = "GET" - request.setValue( - "true", - forHTTPHeaderField: "OCS-APIRequest" - ) - request.setValue( - "Basic \(authString)", - forHTTPHeaderField: "Authorization" - ) - - do { - let (data, _) = try await URLSession.shared.data(for: request) - return data - } catch { - - return nil - } - } - - func sendHTTPRequest(_ requestWrapper: RequestWrapper) async throws -> (Data?, NetworkError?) { - print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.prepend(cookBookPath: cookBookUrlString))) ...") - let urlStringSanitized = requestWrapper.prepend(cookBookPath: cookBookUrlString).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - let url = URL(string: urlStringSanitized!)! - var request = URLRequest(url: url) - request.setValue( - "true", - forHTTPHeaderField: "OCS-APIRequest" - ) - request.setValue( - "Basic \(authString)", - forHTTPHeaderField: "Authorization" - ) - - request.setValue( - requestWrapper.accept.rawValue, - forHTTPHeaderField: "Accept" - ) - - request.httpMethod = requestWrapper.method.rawValue - - switch requestWrapper.method { - case .GET: break - case .POST, .PUT: - guard let httpBody = requestWrapper.body else { return (nil, nil) } - do { - print("Encoding request ...") - request.httpBody = try JSONEncoder().encode(httpBody) - print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")") - } catch { - throw error - } - case .DELETE: throw NotImplementedError.notImplemented - } - - var data: Data? = nil - var response: URLResponse? = nil - do { - (data, response) = try await URLSession.shared.data(for: request) - print("Response: ", response) - return (data, nil) - } catch { - return (nil, decodeURLResponse(response: response as? HTTPURLResponse)) - } - } - - private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { - guard let response = response else { - return NetworkError.unknownError - } - switch response.statusCode { - case 200...299: return (nil) - case 300...399: return (NetworkError.redirectionError) - case 400...499: return (NetworkError.clientError) - case 500...599: return (NetworkError.serverError) - case 600: return (NetworkError.invalidRequest) - default: return (NetworkError.unknownError) - } - } - - func sendDataRequest(_ request: RequestWrapper) async -> (D?, Error?) { - do { - let (data, error) = try await sendHTTPRequest(request) - if let data = data { - return (decodeData(data), error) - } - return (nil, error) - } catch { - print("An unknown network error occured.") - } - return (nil, NetworkError.unknownError) - } - - func sendRequest(_ request: RequestWrapper) async -> Error? { - do { - return try await sendHTTPRequest(request).1 - } catch { - print("An unknown network error occured.") - } - return NetworkError.unknownError - } - - private func decodeData(_ data: Data) -> D? { - let decoder = JSONDecoder() - do { - print("Decoding type ", D.self, " ...") - return try decoder.decode(D.self, from: data) - } catch (let error) { - print("DataController - decodeData(): Failed to decode data.") - print("Error: ", error) - return nil - } - } -} - - - -struct NetworkHandler { - static func sendHTTPRequest(_ requestWrapper: RequestWrapper, authString: String? = nil) async throws -> (Data?, NetworkError?) { - print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.path)) ...") - let urlStringSanitized = requestWrapper.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - let url = URL(string: urlStringSanitized!)! - var request = URLRequest(url: url) - request.setValue( - "true", - forHTTPHeaderField: "OCS-APIRequest" - ) - if let authString = authString { - request.setValue( - "Basic \(authString)", - forHTTPHeaderField: "Authorization" - ) - } - request.setValue( - requestWrapper.accept.rawValue, - forHTTPHeaderField: "Accept" - ) - - request.httpMethod = requestWrapper.method.rawValue - - switch requestWrapper.method { - case .GET: break - case .POST, .PUT: - guard let httpBody = requestWrapper.body else { break } - do { - print("Encoding request ...") - request.httpBody = try JSONEncoder().encode(httpBody) - print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")") - } catch { - throw error - } - case .DELETE: throw NotImplementedError.notImplemented - } - - var data: Data? = nil - var response: URLResponse? = nil - do { - (data, response) = try await URLSession.shared.data(for: request) - print("Response: ", response) - return (data, nil) - } catch { - return (nil, decodeURLResponse(response: response as? HTTPURLResponse)) - } - } - - static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { - guard let response = response else { - return NetworkError.unknownError - } - switch response.statusCode { - case 200...299: return (nil) - case 300...399: return (NetworkError.redirectionError) - case 400...499: return (NetworkError.clientError) - case 500...599: return (NetworkError.serverError) - case 600: return (NetworkError.invalidRequest) - default: return (NetworkError.unknownError) - } - } - - static func sendDataRequest(_ request: RequestWrapper) async -> (D?, Error?) { - do { - let (data, error) = try await sendHTTPRequest(request) - if let data = data { - print(String(data: data, encoding: .utf8)) - return (decodeData(data), error) - } - return (nil, error) - } catch { - print("An unknown network error occured.") - } - return (nil, NetworkError.unknownError) - } - - private static func decodeData(_ data: Data) -> D? { - let decoder = JSONDecoder() - do { - print("Decoding type ", D.self, " ...") - return try decoder.decode(D.self, from: data) - } catch (let error) { - print("DataController - decodeData(): Failed to decode data.") - print("Error: ", error) - return nil - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift b/Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift new file mode 100644 index 0000000..e37b49f --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift @@ -0,0 +1,79 @@ +// +// NetworkHandler.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 13.09.23. +// + +import Foundation + + + +struct NetworkHandler { + static func sendHTTPRequest( + _ requestWrapper: RequestWrapper, + hostPath: String, + authString: String? + ) async throws -> (Data?, NetworkError?) { + print("Sending \(requestWrapper.getMethod()) request (path: \(requestWrapper.getPath())) ...") + + // Prepare URL + let urlString = hostPath + requestWrapper.getPath() + let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + let url = URL(string: urlStringSanitized!)! + + // Create URL request + var request = URLRequest(url: url) + + // Set URL method + request.httpMethod = requestWrapper.getMethod() + + // Set authentication string, if needed + if let authString = authString { + request.setValue( + "Basic \(authString)", + forHTTPHeaderField: "Authorization" + ) + } + + // Set other header fields + for headerField in requestWrapper.getHeaderFields() { + request.setValue( + headerField.getValue(), + forHTTPHeaderField: headerField.getField() + ) + } + + // Set http body + if let body = requestWrapper.getBody() { + request.httpBody = body + } + + print("Request:\nMethod: \(request.httpMethod)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)") + + // Wait for and return data and (decoded) response + var data: Data? = nil + var response: URLResponse? = nil + do { + (data, response) = try await URLSession.shared.data(for: request) + print("Response: ", response) + return (data, nil) + } catch { + return (nil, decodeURLResponse(response: response as? HTTPURLResponse)) + } + } + + private static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { + guard let response = response else { + return NetworkError.unknownError + } + switch response.statusCode { + case 200...299: return (nil) + case 300...399: return (NetworkError.redirectionError) + case 400...499: return (NetworkError.clientError) + case 500...599: return (NetworkError.serverError) + case 600: return (NetworkError.invalidRequest) + default: return (NetworkError.unknownError) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift index a2d44b5..5de6d64 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift @@ -8,47 +8,153 @@ import Foundation enum RequestMethod: String { - case GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE" + case GET = "GET", + POST = "POST", + PUT = "PUT", + DELETE = "DELETE" } -enum RequestPath: String { - case GET_CATEGORIES = "categories" +enum RequestPath { + case CATEGORIES, + RECIPE_LIST(categoryName: String), + RECIPE_DETAIL(recipeId: Int), + IMAGE(recipeId: Int, thumb: Bool) + + case LOGINV2REQ, + CUSTOM(path: String), + NONE + + var stringValue: String { + switch self { + case .CATEGORIES: return "categories" + case .RECIPE_LIST(categoryName: let name): return "category/\(name)" + case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)" + case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" + + case .LOGINV2REQ: return "/index.php/login/v2" + case .CUSTOM(path: let path): return path + case .NONE: return "" + } + } } -enum AcceptHeader: String { - case JSON = "application/json", IMAGE = "image/jpeg" +enum ContentType: String { + case JSON = "application/json", + IMAGE = "image/jpeg", + FORM = "application/x-www-form-urlencoded" +} + +struct HeaderField { + private let _field: String + private let _value: String + + func getField() -> String { + return _field + } + + func getValue() -> String { + return _value + } + + static func accept(value: ContentType) -> HeaderField { + return HeaderField(_field: "accept", _value: value.rawValue) + } + + static func ocsRequest(value: Bool) -> HeaderField { + return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false") + } + + static func contentType(value: ContentType) -> HeaderField { + return HeaderField(_field: "Content-Type", _value: value.rawValue) + } } struct RequestWrapper { - let method: RequestMethod - var path: String - let accept: AcceptHeader - let body: Codable? + private let _method: RequestMethod + private let _path: RequestPath + private let _headerFields: [HeaderField] + private let _body: Data? + private let _authenticate: Bool = true - init(method: RequestMethod, path: String, body: Codable? = nil, accept: AcceptHeader = .JSON) { - self.method = method - self.path = path - self.body = body - self.accept = accept + private init( + method: RequestMethod, + path: RequestPath, + headerFields: [HeaderField] = [], + body: Data? = nil, + authenticate: Bool = true + ) { + self._method = method + self._path = path + self._headerFields = headerFields + self._body = body } - func prepend(cookBookPath: String) -> String { - return cookBookPath + self.path + func getMethod() -> String { + return self._method.rawValue + } + + func getPath() -> String { + return self._path.stringValue + } + + func getHeaderFields() -> [HeaderField] { + return self._headerFields + } + + func getBody() -> Data? { + return _body + } + + func needsAuth() -> Bool { + return _authenticate } } -struct LoginV2Request: Codable { - let poll: LoginV2Poll - let login: String +extension RequestWrapper { + static func customRequest( + method: RequestMethod, + path: RequestPath, + headerFields: [HeaderField] = [], + body: Data? = nil, + authenticate: Bool = true + ) -> RequestWrapper { + let request = RequestWrapper( + method: method, + path: path, + headerFields: headerFields, + body: body, + authenticate: authenticate + ) + return request + } + + static func jsonGetRequest(path: RequestPath) -> RequestWrapper { + let headerFields = [ + HeaderField.ocsRequest(value: true), + HeaderField.accept(value: .JSON) + ] + let request = RequestWrapper( + method: .GET, + path: path, + headerFields: headerFields, + authenticate: true + ) + return request + } + + static func imageRequest(path: RequestPath) -> RequestWrapper { + let headerFields = [ + HeaderField.ocsRequest(value: true), + HeaderField.accept(value: .IMAGE) + ] + let request = RequestWrapper( + method: .GET, + path: path, + headerFields: headerFields, + authenticate: true + ) + return request + } } -struct LoginV2Poll: Codable { - let token: String - let endpoint: String -} -struct LoginV2Response: Codable { - let server: String - let loginName: String - let appPassword: String -} diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index ad4e15d..3b66654 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -10,12 +10,16 @@ import SwiftUI @main struct Nextcloud_Cookbook_iOS_ClientApp: App { @StateObject var userSettings = UserSettings() + @StateObject var mainViewModel = MainViewModel() var body: some Scene { WindowGroup { - MainView(userSettings: userSettings) - .fullScreenCover(isPresented: $userSettings.onboarding) { + ZStack { + if userSettings.onboarding { OnboardingView(userSettings: userSettings) + } else { + MainView(userSettings: userSettings, viewModel: mainViewModel) } + }.transition(.slide) } } } diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index a0a0722..63fd4e0 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -15,20 +15,19 @@ import UIKit private var imageCache: [Int: RecipeImage] = [:] let dataStore: DataStore - let networkController: NetworkController + var apiInterface: APIInterface? = nil /// The path of an image in storage - private var localImagePath: (Int, Bool) -> (String) = { recipeId, full in - return "image\(recipeId)_\(full ? "full" : "thumb")" + private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in + return "image\(recipeId)_\(thumb ? "thumb" : "full")" } /// The path of an image on the server - private var networkImagePath: (Int, Bool) -> (String) = { recipeId, full in - return "recipes/\(recipeId)/image?size=\(full ? "full" : "thumb")" + private var networkImagePath: (Int, Bool) -> (String) = { recipeId, thumb in + return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" } init() { - self.networkController = NetworkController() self.dataStore = DataStore() } @@ -36,7 +35,11 @@ import UIKit /// - Parameters /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. func loadCategoryList(needsUpdate: Bool = false) async { - if let categoryList: [Category] = await load(localPath: "categories.data", networkPath: "categories", needsUpdate: needsUpdate) { + if let categoryList: [Category] = await loadObject( + localPath: "categories.data", + networkPath: .CATEGORIES, + needsUpdate: needsUpdate + ) { self.categories = categoryList } } @@ -46,7 +49,11 @@ import UIKit /// - categoryName: The name of the category containing the requested list of recipes. /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { - if let recipeList: [Recipe] = await load(localPath: "category_\(categoryName).data", networkPath: "category/\(categoryName)", needsUpdate: needsUpdate) { + if let recipeList: [Recipe] = await loadObject( + localPath: "category_\(categoryName).data", + networkPath: .RECIPE_LIST(categoryName: categoryName), + needsUpdate: needsUpdate + ) { recipes[categoryName] = recipeList } } @@ -62,7 +69,11 @@ import UIKit return recipeDetail } } - if let recipeDetail: RecipeDetail = await load(localPath: "recipe\(recipeId).data", networkPath: "recipes/\(recipeId)", needsUpdate: needsUpdate) { + if let recipeDetail: RecipeDetail = await loadObject( + localPath: "recipe\(recipeId).data", + networkPath: .RECIPE_DETAIL(recipeId: recipeId), + needsUpdate: needsUpdate + ) { recipeDetails[recipeId] = recipeDetail return recipeDetail } @@ -75,7 +86,7 @@ import UIKit guard let recipeList = recipes[category.name] else { continue } for recipe in recipeList { let _ = await loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - let _ = await loadImage(recipeId: recipe.recipe_id, full: false) + let _ = await loadImage(recipeId: recipe.recipe_id, thumb: true) } } } @@ -100,17 +111,18 @@ import UIKit /// - full: If true, load the full resolution image. Otherwise, load a thumbnail-sized image. /// - needsUpdate: Determines wether the image should be loaded directly from the server, or if it should be loaded from cache/store first. /// - Returns: The image if found locally or on the server, otherwise nil. - func loadImage(recipeId: Int, full: Bool, needsUpdate: Bool = false) async -> UIImage? { - print("loadImage(recipeId: \(recipeId), full: \(full), needsUpdate: \(needsUpdate))") + func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? { + print("loadImage(recipeId: \(recipeId), thumb: \(thumb), needsUpdate: \(needsUpdate))") // If the image needs an update, request it from the server and overwrite the stored image if needsUpdate { - if let data = await imageDataFromServer(recipeId: recipeId, full: full) { + guard let apiInterface = apiInterface else { return nil } + if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) { guard let image = UIImage(data: data) else { imageCache[recipeId] = RecipeImage(imageExists: false) return nil } - await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, full)) - imageToCache(image: image, recipeId: recipeId, full: full) + await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb)) + imageToCache(image: image, recipeId: recipeId, thumb: thumb) return image } else { imageCache[recipeId] = RecipeImage(imageExists: false) @@ -126,30 +138,31 @@ import UIKit // Try to load image from cache print("Attempting to load image from cache ...") - if let image = imageFromCache(recipeId: recipeId, full: full) { + if let image = imageFromCache(recipeId: recipeId, thumb: thumb) { print("Image found in cache.") return image } // Try to load from store print("Attempting to load image from local storage ...") - if let image = await imageFromStore(recipeId: recipeId, full: full) { + if let image = await imageFromStore(recipeId: recipeId, thumb: thumb) { print("Image found in local storage.") - imageToCache(image: image, recipeId: recipeId, full: full) + imageToCache(image: image, recipeId: recipeId, thumb: thumb) return image } // Try to load from the server. Store if successfull. print("Attempting to load image from server ...") - if let data = await imageDataFromServer(recipeId: recipeId, full: full) { + guard let apiInterface = apiInterface else { return nil } + if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) { print("Image data received.") // Create empty RecipeImage for each recipe even if no image found, so that further server requests are only sent if explicitly requested. guard let image = UIImage(data: data) else { imageCache[recipeId] = RecipeImage(imageExists: false) return nil } - await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, full)) - imageToCache(image: image, recipeId: recipeId, full: full) + await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb)) + imageToCache(image: image, recipeId: recipeId, thumb: thumb) return image } imageCache[recipeId] = RecipeImage(imageExists: false) @@ -170,14 +183,15 @@ import UIKit extension MainViewModel { - private func load(localPath: String, networkPath: String, needsUpdate: Bool = false) async -> D? { + private func loadObject(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? { do { - if !needsUpdate, let data: D = try await dataStore.load(fromPath: localPath) { + if !needsUpdate, let data: T = try await dataStore.load(fromPath: localPath) { print("Data found locally.") return data } else { - let request = RequestWrapper(method: .GET, path: networkPath) - let (data, error): (D?, Error?) = await networkController.sendDataRequest(request) + guard let apiInterface = apiInterface else { return nil } + let request = RequestWrapper.jsonGetRequest(path: networkPath) + let (data, error): (T?, Error?) = await apiInterface.sendDataRequest(request) print(error as Any) if let data = data { await dataStore.save(data: data, toPath: localPath) @@ -190,33 +204,33 @@ extension MainViewModel { return nil } - private func imageToCache(image: UIImage, recipeId: Int, full: Bool) { + private func imageToCache(image: UIImage, recipeId: Int, thumb: Bool) { if imageCache[recipeId] == nil { imageCache[recipeId] = RecipeImage(imageExists: true) } - if full { - imageCache[recipeId]!.imageExists = true - imageCache[recipeId]!.full = image - } else { + if thumb { imageCache[recipeId]!.imageExists = true imageCache[recipeId]!.thumb = image + } else { + imageCache[recipeId]!.imageExists = true + imageCache[recipeId]!.full = image } } - private func imageFromCache(recipeId: Int, full: Bool) -> UIImage? { + private func imageFromCache(recipeId: Int, thumb: Bool) -> UIImage? { if imageCache[recipeId] != nil { - if full { - return imageCache[recipeId]!.full - } else { + if thumb { return imageCache[recipeId]!.thumb + } else { + return imageCache[recipeId]!.full } } return nil } - private func imageFromStore(recipeId: Int, full: Bool) async -> UIImage? { + private func imageFromStore(recipeId: Int, thumb: Bool) async -> UIImage? { do { - let localPath = localImagePath(recipeId, full) + let localPath = localImagePath(recipeId, thumb) if let data: String = try await dataStore.load(fromPath: localPath) { guard let dataDecoded = Data(base64Encoded: data) else { return nil } let image = UIImage(data: dataDecoded) @@ -228,22 +242,6 @@ extension MainViewModel { } return nil } - - private func imageDataFromServer(recipeId: Int, full: Bool) async -> Data? { - do { - let networkPath = networkImagePath(recipeId, full) - let request = RequestWrapper(method: .GET, path: networkPath, accept: .IMAGE) - let (data, _): (Data?, Error?) = try await networkController.sendHTTPRequest(request) - guard let data = data else { - print("Error receiving or decoding data.") - return nil - } - return data - } catch { - print("Could not load image from server.") - } - return nil - } } diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index bef7e70..64b5c9c 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -59,7 +59,7 @@ struct RecipeBookView: View { for recipe in recipes { Task { let _ = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) - let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, full: true) + let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index db58207..de64daa 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -8,9 +8,17 @@ import SwiftUI struct MainView: View { - @StateObject var viewModel = MainViewModel() - @StateObject var userSettings: UserSettings + @ObservedObject var viewModel: MainViewModel + @ObservedObject var userSettings: UserSettings var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] + + init(userSettings: UserSettings, viewModel: MainViewModel) { + self.userSettings = userSettings + self.viewModel = viewModel + self.viewModel.apiInterface = APIInterface(userSettings: userSettings) + + } + var body: some View { NavigationView { ScrollView(.vertical, showsIndicators: false) { @@ -60,11 +68,6 @@ struct MainView: View { } } -struct MainView_Previews: PreviewProvider { - static var previews: some View { - MainView(userSettings: UserSettings()) - } -} diff --git a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift index 68e4344..02a1e36 100644 --- a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift +++ b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift @@ -141,7 +141,7 @@ struct LoginTab: View { } } } - Text("Submitting will open a web browser. Please follow the login instructions provided there.\nAfter a successfull login, return to this application and press 'Done'.") + Text("Submitting will open a web browser. Please follow the login instructions provided there.\nAfter a successfull login, return to this application and press 'Validate'.") .font(.subheadline) .padding(.bottom) .tint(.white) @@ -152,10 +152,13 @@ struct LoginTab: View { // fetch login v2 response Task { guard let res = await fetchLoginV2Response() else { return } - print(res.loginName) + print("Login successfull for user \(res.loginName)!") + userSettings.username = res.loginName + userSettings.token = res.appPassword + userSettings.onboarding = false } } label: { - Text("Done") + Text("Validate") .foregroundColor(.white) .font(.headline) .padding() @@ -188,23 +191,64 @@ struct LoginTab: View { } func sendLoginV2Request() async { - let request = RequestWrapper( + let hostPath = "https://\(userSettings.serverAddress)" + let headerFields: [HeaderField] = [ + //HeaderField.ocsRequest(value: true), + //HeaderField.accept(value: .JSON) + ] + let request = RequestWrapper.customRequest( method: .POST, - path: "https://\(userSettings.serverAddress)/index.php/login/v2" + path: .LOGINV2REQ, + headerFields: headerFields ) - let (loginReq, _): (LoginV2Request?, Error?) = await NetworkHandler.sendDataRequest(request) - self.loginRequest = loginReq + do { + let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: hostPath, + authString: nil + ) + + guard let data = data else { return } + print("Data: \(data)") + let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data) + self.loginRequest = loginReq + } catch { + print("Could not establish communication with the server.") + } + } func fetchLoginV2Response() async -> LoginV2Response? { guard let loginRequest = loginRequest else { return nil } - let request = RequestWrapper( + let headerFields = [ + HeaderField.ocsRequest(value: true), + HeaderField.accept(value: .JSON), + HeaderField.contentType(value: .FORM) + ] + let request = RequestWrapper.customRequest( method: .POST, - path: loginRequest.poll.endpoint, - body: "token=\(loginRequest.poll.token)" + path: .NONE, + headerFields: headerFields, + body: "token=\(loginRequest.poll.token)".data(using: .utf8), + authenticate: false ) - let (loginRes, _): (LoginV2Response?, Error?) = await NetworkHandler.sendDataRequest(request) - return loginRes + + var (data, error): (Data?, Error?) = (nil, nil) + do { + (data, error) = try await NetworkHandler.sendHTTPRequest( + request, + hostPath: loginRequest.poll.endpoint, + authString: nil + ) + } catch { + print("Error: ", error) + } + guard let data = data else { return nil } + if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) { + return loginRes + } + print("Could not decode.") + return nil } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift index 083aba1..e947ba6 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift @@ -36,10 +36,10 @@ struct RecipeCardView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) .task { - recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, full: false) + recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true) } .refreshable { - recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, full: false, needsUpdate: true) + recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 6c40f91..ffdd72b 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -65,11 +65,11 @@ struct RecipeDetailView: View { .navigationTitle(showTitle ? recipe.name : "") .task { recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) - recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, full: true) + recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) } .refreshable { recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, full: true, needsUpdate: true) + recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) } }