Networking rework: simplified API calls.
This commit is contained in:
@@ -36,6 +36,11 @@
|
||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
|
||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
||||
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */; };
|
||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
||||
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; };
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; };
|
||||
@@ -92,6 +97,11 @@
|
||||
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
||||
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
|
||||
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
||||
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
||||
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
||||
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiRequest.swift; sourceTree = "<group>"; };
|
||||
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; };
|
||||
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = "<group>"; };
|
||||
@@ -191,6 +201,8 @@
|
||||
A70171B22AB211F000064C43 /* Network */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
|
||||
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
|
||||
A703226C2ABAF90D00D7C4ED /* APIController.swift */,
|
||||
A70171B32AB2122900064C43 /* NetworkRequests.swift */,
|
||||
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
|
||||
@@ -241,6 +253,7 @@
|
||||
children = (
|
||||
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */,
|
||||
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */,
|
||||
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -261,6 +274,24 @@
|
||||
path = RecipeImport;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A79AA8E72B062DB6007D25F2 /* CookbookApi */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
|
||||
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
|
||||
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
|
||||
);
|
||||
path = CookbookApi;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A79AA8EE2B063B33007D25F2 /* NextcloudApi */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */,
|
||||
);
|
||||
path = NextcloudApi;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -405,21 +436,26 @@
|
||||
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
|
||||
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
|
||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
|
||||
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */,
|
||||
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */,
|
||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
|
||||
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
|
||||
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
|
||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
||||
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */,
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
|
||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
||||
A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */,
|
||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
||||
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
|
||||
|
||||
@@ -146,6 +146,9 @@ extension RecipeDetail {
|
||||
|
||||
|
||||
struct RecipeImage {
|
||||
enum RecipeImageSize {
|
||||
case THUMB, FULL
|
||||
}
|
||||
var imageExists: Bool = true
|
||||
var thumb: UIImage?
|
||||
var full: UIImage?
|
||||
@@ -193,7 +196,4 @@ struct MetaData: Codable {
|
||||
}
|
||||
|
||||
|
||||
// Networking
|
||||
struct ServerMessage: Decodable {
|
||||
let msg: String
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,20 @@ class UserSettings: ObservableObject {
|
||||
@Published var username: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(username, forKey: "username")
|
||||
self.authString = setAuthString()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var token: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(token, forKey: "token")
|
||||
self.authString = setAuthString()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var authString: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(authString, forKey: "authString")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +63,21 @@ class UserSettings: ObservableObject {
|
||||
init() {
|
||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
||||
self.authString = UserDefaults.standard.object(forKey: "authString") as? String ?? ""
|
||||
self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? ""
|
||||
self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true
|
||||
self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? ""
|
||||
self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue
|
||||
self.downloadRecipes = UserDefaults.standard.object(forKey: "downloadRecipes") as? Bool ?? false
|
||||
}
|
||||
|
||||
func setAuthString() -> String {
|
||||
if token != "" && username != "" {
|
||||
let loginString = "\(self.username):\(self.token)"
|
||||
let loginData = loginString.data(using: String.Encoding.utf8)!
|
||||
return loginData.base64EncodedString()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ extension JSONDecoder {
|
||||
static func safeDecode<T: Decodable>(_ 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)
|
||||
print(error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Logger.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 13.11.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
extension Logger {
|
||||
private static var subsystem = Bundle.main.bundleIdentifier!
|
||||
|
||||
/// UI related logging
|
||||
static let view = Logger(subsystem: subsystem, category: "view")
|
||||
|
||||
/// Network related logging
|
||||
static let network = Logger(subsystem: subsystem, category: "network")
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// ApiRequest.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 16.11.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct ApiRequest {
|
||||
/// The server address, e.g. https://example.com
|
||||
let serverAddress: String
|
||||
let path: String
|
||||
let method: RequestMethod
|
||||
let authString: String?
|
||||
let headerFields: [HeaderField]
|
||||
let body: Data?
|
||||
|
||||
/// The path to the Cookbook application on the nextcloud server.
|
||||
let cookbookPath = "/index.php/apps/cookbook"
|
||||
|
||||
init(
|
||||
serverAdress: String,
|
||||
path: String,
|
||||
method: RequestMethod,
|
||||
authString: String? = nil,
|
||||
headerFields: [HeaderField] = [],
|
||||
body: Data? = nil
|
||||
) {
|
||||
self.serverAddress = serverAdress
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.headerFields = headerFields
|
||||
self.authString = authString
|
||||
self.body = body
|
||||
}
|
||||
|
||||
func send() async -> (Data?, NetworkError?) {
|
||||
Logger.network.debug("\(method.rawValue) \(path) sending ...")
|
||||
|
||||
// Prepare URL
|
||||
let urlString = serverAddress + cookbookPath + path
|
||||
Logger.network.debug("Full path: \(urlString)")
|
||||
guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) }
|
||||
guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) }
|
||||
|
||||
// Create URL request
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
|
||||
// Set authentication string, if needed
|
||||
if let authString = authString {
|
||||
request.setValue(
|
||||
"Basic \(authString)",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
}
|
||||
|
||||
// Set other header fields
|
||||
for headerField in headerFields {
|
||||
request.setValue(
|
||||
headerField.getValue(),
|
||||
forHTTPHeaderField: headerField.getField()
|
||||
)
|
||||
}
|
||||
|
||||
// Set http body
|
||||
if let body = body {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
// 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)
|
||||
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
||||
return (data, nil)
|
||||
} catch {
|
||||
let error = decodeURLResponse(response: response as? HTTPURLResponse)
|
||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)")
|
||||
return (nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// CookbookApi.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 13.11.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
|
||||
protocol CookbookApi {
|
||||
/// Not implemented yet.
|
||||
static func importRecipe(
|
||||
from serverAdress: String,
|
||||
auth: String,
|
||||
data: Data
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Get either the full image or a thumbnail sized version.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - id: The according recipe id.
|
||||
/// - size: The size of the image.
|
||||
/// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil.
|
||||
static func getImage(
|
||||
from serverAdress: String,
|
||||
auth: String,
|
||||
id: Int,
|
||||
size: RecipeImage.RecipeImageSize
|
||||
) async -> (UIImage?, NetworkError?)
|
||||
|
||||
/// Get all recipes.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - Returns: A list of all recipes.
|
||||
static func getRecipes(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> ([Recipe]?, NetworkError?)
|
||||
|
||||
/// Create a new recipe.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - Returns: A NetworkError if the request fails. Nil otherwise.
|
||||
static func createRecipe(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Get the recipe with the specified id.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - id: The recipe id.
|
||||
/// - Returns: The recipe if it exists. A NetworkError if the request fails.
|
||||
static func getRecipe(
|
||||
from serverAdress: String,
|
||||
auth: String, id: Int
|
||||
) async -> (RecipeDetail?, NetworkError?)
|
||||
|
||||
/// Update an existing recipe with new entries.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - id: The recipe id.
|
||||
/// - Returns: A NetworkError if the request fails. Nil otherwise.
|
||||
static func updateRecipe(
|
||||
from serverAdress: String,
|
||||
auth: String, id: Int
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Delete the recipe with the specified id.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - id: The recipe id.
|
||||
/// - Returns: A NetworkError if the request fails. Nil otherwise.
|
||||
static func deleteRecipe(
|
||||
from serverAdress: String,
|
||||
auth: String,
|
||||
id: Int
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Get all categories.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - Returns: A list of categories. A NetworkError if the request fails.
|
||||
static func getCategories(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> ([String]?, NetworkError?)
|
||||
|
||||
/// Get all recipes of a specified category.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - categoryName: The category name.
|
||||
/// - Returns: A list of recipes. A NetworkError if the request fails.
|
||||
static func getCategory(
|
||||
from serverAdress: String,
|
||||
auth: String,
|
||||
named categoryName: String
|
||||
) async -> ([Recipe]?, NetworkError?)
|
||||
|
||||
/// Rename an existing category.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - categoryName: The name of the category to be renamed.
|
||||
/// - newName: The new category name.
|
||||
/// - Returns: A NetworkError if the request fails.
|
||||
static func renameCategory(
|
||||
from serverAdress: String,
|
||||
auth: String,
|
||||
named categoryName: String,
|
||||
newName: String
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Get all keywords/tags.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - Returns: A list of tag strings. A NetworkError if the request fails.
|
||||
static func getTags(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> ([String]?, NetworkError?)
|
||||
|
||||
/// Get all recipes tagged with the specified keyword.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - keyword: The keyword.
|
||||
/// - Returns: A list of recipes tagged with the specified keyword. A NetworkError if the request fails.
|
||||
static func getRecipesTagged(
|
||||
from serverAdress: String,
|
||||
auth: String,
|
||||
keyword: String
|
||||
) async -> ([Recipe]?, NetworkError?)
|
||||
|
||||
/// Get the servers api version.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format https://example.com.
|
||||
/// - auth: Server authentication string.
|
||||
/// - Returns: A NetworkError if the request fails.
|
||||
static func getApiVersion(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Trigger a reindexing action on the server.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format. https://example.com
|
||||
/// - auth: Server authentication string
|
||||
/// - Returns: A NetworkError if the request fails.
|
||||
static func postReindex(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Get the current configuration of the Cookbook server application.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format. https://example.com
|
||||
/// - auth: Server authentication string
|
||||
/// - Returns: A NetworkError if the request fails.
|
||||
static func getConfig(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> (NetworkError?)
|
||||
|
||||
/// Set the current configuration of the Cookbook server application.
|
||||
/// - Parameters:
|
||||
/// - serverAdress: Server address in the format. https://example.com
|
||||
/// - auth: Server authentication string
|
||||
/// - Returns: A NetworkError if the request fails.
|
||||
static func postConfig(
|
||||
from serverAdress: String,
|
||||
auth: String
|
||||
) async -> (NetworkError?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//
|
||||
// CookbookApiV1.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 16.11.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
|
||||
class CookbookApiV1: CookbookApi {
|
||||
static func importRecipe(from serverAdress: String, auth: String, data: Data) async -> (NetworkError?) {
|
||||
return .unknownError
|
||||
}
|
||||
|
||||
static func getImage(from serverAdress: String, auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
|
||||
let imageSize = (size == .FULL ? "full" : "thumb")
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/recipes/\(id)/image?size=\(imageSize)",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (UIImage(data: data), error)
|
||||
}
|
||||
|
||||
static func getRecipes(from serverAdress: String, auth: String) async -> ([Recipe]?, NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/recipes",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
}
|
||||
|
||||
static func createRecipe(from serverAdress: String, auth: String) async -> (NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/recipes",
|
||||
method: .POST,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (error) }
|
||||
do {
|
||||
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||
if let id = json as? Int {
|
||||
return nil
|
||||
} else if let dict = json as? [String: Any] {
|
||||
return .serverError
|
||||
}
|
||||
} catch {
|
||||
return .decodingFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getRecipe(from serverAdress: String, auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/recipes/\(id)",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
}
|
||||
|
||||
static func updateRecipe(from serverAdress: String, auth: String, id: Int) async -> (NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/recipes/\(id)",
|
||||
method: .PUT,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (error) }
|
||||
do {
|
||||
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||
if let id = json as? Int {
|
||||
return nil
|
||||
} else if let dict = json as? [String: Any] {
|
||||
return .serverError
|
||||
}
|
||||
} catch {
|
||||
return .decodingFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func deleteRecipe(from serverAdress: String, auth: String, id: Int) async -> (NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/recipes/\(id)",
|
||||
method: .DELETE,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (error) }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getCategories(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/categories",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
}
|
||||
|
||||
static func getCategory(from serverAdress: String, auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/category/\(categoryName)",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
}
|
||||
|
||||
static func renameCategory(from serverAdress: String, auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/category/\(categoryName)",
|
||||
method: .PUT,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (error) }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getTags(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/keywords",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
}
|
||||
|
||||
static func getRecipesTagged(from serverAdress: String, auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) {
|
||||
let request = ApiRequest(
|
||||
serverAdress: serverAdress,
|
||||
path: "/api/v1/tags/\(keyword)",
|
||||
method: .GET,
|
||||
authString: auth,
|
||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||
)
|
||||
|
||||
let (data, error) = await request.send()
|
||||
guard let data = data else { return (nil, error) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
}
|
||||
|
||||
static func getApiVersion(from serverAdress: String, auth: String) async -> (NetworkError?) {
|
||||
return .none
|
||||
}
|
||||
|
||||
static func postReindex(from serverAdress: String, auth: String) async -> (NetworkError?) {
|
||||
return .none
|
||||
}
|
||||
|
||||
static func getConfig(from serverAdress: String, auth: String) async -> (NetworkError?) {
|
||||
return .none
|
||||
}
|
||||
|
||||
static func postConfig(from serverAdress: String, auth: String) async -> (NetworkError?) {
|
||||
return .none
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public enum NetworkError: String, Error {
|
||||
case missingUrl = "Missing URL."
|
||||
case parametersNil = "Parameters are nil."
|
||||
case encodingFailed = "Parameter encoding failed."
|
||||
case decodingFailed = "Data decoding failed."
|
||||
case redirectionError = "Redirection error"
|
||||
case clientError = "Client error"
|
||||
case serverError = "Server error"
|
||||
@@ -25,3 +26,4 @@ public enum NetworkError: String, Error {
|
||||
case unknownError = "Unknown error"
|
||||
case dataError = "Invalid data error."
|
||||
}
|
||||
|
||||
|
||||
@@ -14,35 +14,7 @@ enum RequestMethod: String {
|
||||
DELETE = "DELETE"
|
||||
}
|
||||
|
||||
enum RequestPath {
|
||||
case CATEGORIES,
|
||||
RECIPE_LIST(categoryName: String),
|
||||
RECIPE_DETAIL(recipeId: Int),
|
||||
NEW_RECIPE,
|
||||
IMAGE(recipeId: Int, thumb: Bool),
|
||||
CONFIG,
|
||||
KEYWORDS
|
||||
|
||||
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 .NEW_RECIPE: return "recipes"
|
||||
case .CONFIG: return "config"
|
||||
case .KEYWORDS: return "keywords"
|
||||
|
||||
case .LOGINV2REQ: return "/index.php/login/v2"
|
||||
case .CUSTOM(path: let path): return path
|
||||
case .NONE: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ContentType: String {
|
||||
case JSON = "application/json",
|
||||
@@ -75,6 +47,37 @@ struct HeaderField {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum RequestPath {
|
||||
case CATEGORIES,
|
||||
RECIPE_LIST(categoryName: String),
|
||||
RECIPE_DETAIL(recipeId: Int),
|
||||
NEW_RECIPE,
|
||||
IMAGE(recipeId: Int, thumb: Bool),
|
||||
CONFIG,
|
||||
KEYWORDS
|
||||
|
||||
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 .NEW_RECIPE: return "recipes"
|
||||
case .CONFIG: return "config"
|
||||
case .KEYWORDS: return "keywords"
|
||||
|
||||
case .LOGINV2REQ: return "/index.php/login/v2"
|
||||
case .CUSTOM(path: let path): return path
|
||||
case .NONE: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestWrapper {
|
||||
private let _method: RequestMethod
|
||||
private let _path: RequestPath
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// NextcloudApi.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 16.11.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class NextcloudApi {
|
||||
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import UIKit
|
||||
@Published var recipes: [String: [Recipe]] = [:]
|
||||
private var recipeDetails: [Int: RecipeDetail] = [:]
|
||||
private var imageCache: [Int: RecipeImage] = [:]
|
||||
private var requestQueue: [RequestWrapper] = []
|
||||
|
||||
let dataStore: DataStore
|
||||
var apiController: APIController? = nil
|
||||
@@ -206,18 +207,35 @@ import UIKit
|
||||
self.recipes = [:]
|
||||
self.imageCache = [:]
|
||||
self.recipeDetails = [:]
|
||||
self.requestQueue = []
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRecipe(withId id: Int, categoryName: String) {
|
||||
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert {
|
||||
let request = RequestWrapper.customRequest(
|
||||
method: .DELETE,
|
||||
path: .RECIPE_DETAIL(recipeId: id),
|
||||
headerFields: [
|
||||
HeaderField.accept(value: .JSON),
|
||||
HeaderField.ocsRequest(value: true)
|
||||
]
|
||||
)
|
||||
|
||||
let path = "recipe\(id).data"
|
||||
dataStore.delete(path: path)
|
||||
guard recipes[categoryName] != nil else { return }
|
||||
if recipes[categoryName] != nil {
|
||||
recipes[categoryName]!.removeAll(where: { recipe in
|
||||
recipe.recipe_id == id ? true : false
|
||||
})
|
||||
recipeDetails.removeValue(forKey: id)
|
||||
}
|
||||
if await sendRequest(request) {
|
||||
return .REQUEST_SUCCESS
|
||||
} else {
|
||||
requestQueue.append(request)
|
||||
return .REQUEST_DELAYED
|
||||
}
|
||||
}
|
||||
|
||||
func checkServerConnection() async -> Bool {
|
||||
guard let apiController = apiController else { return false }
|
||||
@@ -234,6 +252,54 @@ import UIKit
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert {
|
||||
var path: RequestPath? = nil
|
||||
if createNew {
|
||||
path = .NEW_RECIPE
|
||||
} else if let recipeId = Int(recipeDetail.id) {
|
||||
path = .RECIPE_DETAIL(recipeId: recipeId)
|
||||
}
|
||||
|
||||
guard let path = path else { return .REQUEST_DROPPED }
|
||||
|
||||
let request = RequestWrapper.customRequest(
|
||||
method: createNew ? .POST : .PUT,
|
||||
path: path,
|
||||
headerFields: [
|
||||
HeaderField.accept(value: .JSON),
|
||||
HeaderField.ocsRequest(value: true),
|
||||
HeaderField.contentType(value: .JSON)
|
||||
],
|
||||
body: JSONEncoder.safeEncode(recipeDetail)
|
||||
)
|
||||
|
||||
if await sendRequest(request) {
|
||||
return .REQUEST_SUCCESS
|
||||
} else {
|
||||
requestQueue.append(request)
|
||||
return .REQUEST_DELAYED
|
||||
}
|
||||
}
|
||||
|
||||
func sendRequest(_ request: RequestWrapper) async -> Bool {
|
||||
guard let apiController = apiController else { return false }
|
||||
let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request)
|
||||
guard let data = data else { return false }
|
||||
do {
|
||||
let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
|
||||
if let recipeId = json as? Int {
|
||||
return true
|
||||
} else if let message = json as? [String : Any] {
|
||||
print("Server message: ", message["msg"] ?? "-")
|
||||
return false
|
||||
}
|
||||
// TODO: Better error handling (Show error to user!)
|
||||
} catch {
|
||||
print("Could not decode server response")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -82,74 +82,32 @@ import SwiftUI
|
||||
return true
|
||||
}
|
||||
|
||||
func uploadNewRecipe() {
|
||||
func uploadNewRecipe() async -> RequestAlert {
|
||||
print("Uploading new recipe.")
|
||||
waitingForUpload = true
|
||||
createRecipe()
|
||||
guard recipeValid() else { return }
|
||||
let request = RequestWrapper.customRequest(
|
||||
method: .POST,
|
||||
path: .NEW_RECIPE,
|
||||
headerFields: [
|
||||
HeaderField.accept(value: .JSON),
|
||||
HeaderField.ocsRequest(value: true),
|
||||
HeaderField.contentType(value: .JSON)
|
||||
],
|
||||
body: JSONEncoder.safeEncode(self.recipe)
|
||||
)
|
||||
sendRequest(request)
|
||||
dismissEditView()
|
||||
guard recipeValid() else { return .REQUEST_DROPPED }
|
||||
|
||||
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true)
|
||||
}
|
||||
|
||||
func uploadEditedRecipe() {
|
||||
func uploadEditedRecipe() async -> RequestAlert {
|
||||
waitingForUpload = true
|
||||
print("Uploading changed recipe.")
|
||||
guard let recipeId = Int(recipe.id) else { return }
|
||||
guard let recipeId = Int(recipe.id) else { return .REQUEST_DROPPED }
|
||||
createRecipe()
|
||||
let request = RequestWrapper.customRequest(
|
||||
method: .PUT,
|
||||
path: .RECIPE_DETAIL(recipeId: recipeId),
|
||||
headerFields: [
|
||||
HeaderField.accept(value: .JSON),
|
||||
HeaderField.ocsRequest(value: true),
|
||||
HeaderField.contentType(value: .JSON)
|
||||
],
|
||||
body: JSONEncoder.safeEncode(self.recipe)
|
||||
)
|
||||
sendRequest(request)
|
||||
dismissEditView()
|
||||
|
||||
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false)
|
||||
}
|
||||
|
||||
func deleteRecipe() {
|
||||
guard let recipeId = Int(recipe.id) else { return }
|
||||
let request = RequestWrapper.customRequest(
|
||||
method: .DELETE,
|
||||
path: .RECIPE_DETAIL(recipeId: recipeId),
|
||||
headerFields: [
|
||||
HeaderField.accept(value: .JSON),
|
||||
HeaderField.ocsRequest(value: true)
|
||||
]
|
||||
)
|
||||
sendRequest(request)
|
||||
if let recipeIdInt = Int(recipe.id) {
|
||||
mainViewModel.deleteRecipe(withId: recipeIdInt, categoryName: recipe.recipeCategory)
|
||||
func deleteRecipe() async -> RequestAlert {
|
||||
guard let id = Int(recipe.id) else {
|
||||
return .REQUEST_DROPPED
|
||||
}
|
||||
dismissEditView()
|
||||
return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory)
|
||||
}
|
||||
|
||||
func sendRequest(_ request: RequestWrapper) {
|
||||
Task {
|
||||
guard let apiController = mainViewModel.apiController else { return }
|
||||
let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request)
|
||||
guard let data = data else { return }
|
||||
do {
|
||||
let error = try JSONDecoder().decode(ServerMessage.self, from: data)
|
||||
// TODO: Better error handling (Show error to user!)
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissEditView() {
|
||||
Task {
|
||||
|
||||
@@ -109,3 +109,30 @@ enum RecipeImportError: UserAlert {
|
||||
return [.OK]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum RequestAlert: UserAlert {
|
||||
case REQUEST_DELAYED,
|
||||
REQUEST_DROPPED,
|
||||
REQUEST_SUCCESS
|
||||
|
||||
var localizedDescription: LocalizedStringKey {
|
||||
switch self {
|
||||
case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection."
|
||||
case .REQUEST_DROPPED: return "Unable to complete action."
|
||||
case .REQUEST_SUCCESS: return "Action completed."
|
||||
}
|
||||
}
|
||||
|
||||
var localizedTitle: LocalizedStringKey {
|
||||
switch self {
|
||||
case .REQUEST_DELAYED: return "Action delayed"
|
||||
case .REQUEST_DROPPED: return "Error"
|
||||
case .REQUEST_SUCCESS: return "Success"
|
||||
}
|
||||
}
|
||||
|
||||
var alertButtons: [AlertButton] {
|
||||
return [.OK]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user