WIP - Complete App refactoring

This commit is contained in:
VincentMeilinger
2025-05-26 15:52:12 +02:00
parent c4be0e98b9
commit 29fd3c668b
19 changed files with 691 additions and 23 deletions

View File

@@ -0,0 +1,103 @@
//
// ApiRequest.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.11.23.
//
import Foundation
import OSLog
struct ApiRequest {
let path: String
let method: RequestMethod
let authString: String?
let headerFields: [HeaderField]
let body: Data?
init(
path: String,
method: RequestMethod,
authString: String? = nil,
headerFields: [HeaderField] = [],
body: Data? = nil
) {
self.method = method
self.path = path
self.headerFields = headerFields
self.authString = authString
self.body = body
}
func send(pathCompletion: Bool = true) async -> (Data?, NetworkError?) {
Logger.network.debug("\(method.rawValue) \(path) sending ...")
// Prepare URL
let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path
print("Full path: \(urlString)")
//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!")
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
return (nil, error)
}
if let data = data {
print(data, String(data: data, encoding: .utf8) as Any)
return (data, nil)
}
return (nil, .unknownError)
} 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
}
print("Status code: ", response.statusCode)
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)
}
}
}

View File

@@ -0,0 +1,178 @@
//
// CookbookApi.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.11.23.
//
import Foundation
import OSLog
import UIKit
/// The Cookbook API class used for requests to the Nextcloud Cookbook service.
let cookbookApi: CookbookApi.Type = {
switch UserSettings.shared.cookbookApiVersion {
case .v1:
return CookbookApiV1.self
}
}()
/// The Cookbook API version.
enum CookbookApiVersion: String {
case v1 = "v1"
}
/// A protocol defining common API endpoints that are likely to remain the same over future Cookbook API versions.
protocol CookbookApi {
static var basePath: String { get }
/// Not implemented yet.
static func importRecipe(
auth: String,
data: Data
) async -> (RecipeDetail?, NetworkError?)
/// Get either the full image or a thumbnail sized version.
/// - Parameters:
/// - 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(
auth: String,
id: Int,
size: RecipeImage.RecipeImageSize
) async -> (UIImage?, NetworkError?)
/// Get all recipes.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A list of all recipes.
static func getRecipes(
auth: String
) async -> ([Recipe]?, NetworkError?)
/// Create a new recipe.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func createRecipe(
auth: String,
recipe: RecipeDetail
) async -> (NetworkError?)
/// Get the recipe with the specified id.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The recipe id.
/// - Returns: The recipe if it exists. A NetworkError if the request fails.
static func getRecipe(
auth: String, id: Int
) async -> (RecipeDetail?, NetworkError?)
/// Update an existing recipe with new entries.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The recipe id.
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func updateRecipe(
auth: String,
recipe: RecipeDetail
) async -> (NetworkError?)
/// Delete the recipe with the specified id.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The recipe id.
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func deleteRecipe(
auth: String,
id: Int
) async -> (NetworkError?)
/// Get all categories.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A list of categories. A NetworkError if the request fails.
static func getCategories(
auth: String
) async -> ([Category]?, NetworkError?)
/// Get all recipes of a specified category.
/// - Parameters:
/// - auth: Server authentication string.
/// - categoryName: The category name.
/// - Returns: A list of recipes. A NetworkError if the request fails.
static func getCategory(
auth: String,
named categoryName: String
) async -> ([Recipe]?, NetworkError?)
/// Rename an existing category.
/// - Parameters:
/// - 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(
auth: String,
named categoryName: String,
newName: String
) async -> (NetworkError?)
/// Get all keywords/tags.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A list of tag strings. A NetworkError if the request fails.
static func getTags(
auth: String
) async -> ([RecipeKeyword]?, NetworkError?)
/// Get all recipes tagged with the specified keyword.
/// - Parameters:
/// - 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(
auth: String,
keyword: String
) async -> ([Recipe]?, NetworkError?)
/// Get the servers api version.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A NetworkError if the request fails.
static func getApiVersion(
auth: String
) async -> (NetworkError?)
/// Trigger a reindexing action on the server.
/// - Parameters:
/// - auth: Server authentication string
/// - Returns: A NetworkError if the request fails.
static func postReindex(
auth: String
) async -> (NetworkError?)
/// Get the current configuration of the Cookbook server application.
/// - Parameters:
/// - auth: Server authentication string
/// - Returns: A NetworkError if the request fails.
static func getConfig(
auth: String
) async -> (NetworkError?)
/// Set the current configuration of the Cookbook server application.
/// - Parameters:
/// - auth: Server authentication string
/// - Returns: A NetworkError if the request fails.
static func postConfig(
auth: String
) async -> (NetworkError?)
}

View File

@@ -0,0 +1,217 @@
//
// CookbookApiV1.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.11.23.
//
import Foundation
import UIKit
class CookbookApiV1: CookbookApi {
static let basePath: String = "/index.php/apps/cookbook/api/v1"
static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/import",
method: .POST,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
}
static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
let imageSize = (size == .FULL ? "full" : "thumb")
let request = ApiRequest(
path: basePath + "/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(auth: String) async -> ([Recipe]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/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) }
print("\n\nRECIPE: ", String(data: data, encoding: .utf8))
return (JSONDecoder.safeDecode(data), nil)
}
static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
let request = ApiRequest(
path: basePath + "/recipes",
method: .POST,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)],
body: recipeData
)
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(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
let request = ApiRequest(
path: basePath + "/recipes/\(recipe.id)",
method: .PUT,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)],
body: recipeData
)
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(auth: String, id: Int) async -> (NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String) async -> ([Category]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String) async -> ([RecipeKeyword]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/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(auth: String) async -> (NetworkError?) {
return .none
}
static func postReindex(auth: String) async -> (NetworkError?) {
return .none
}
static func getConfig(auth: String) async -> (NetworkError?) {
return .none
}
static func postConfig(auth: String) async -> (NetworkError?) {
return .none
}
}

View File

@@ -0,0 +1,44 @@
//
// Models.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.05.24.
//
import Foundation
import SwiftUI
// MARK: - Login flow
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
}
struct LoginValidation: Codable {
let ocs: Ocs
}
struct Ocs: Codable {
let meta: MetaData
}
struct MetaData: Codable {
let status: String
let statuscode: Int
}

View File

@@ -0,0 +1,246 @@
//
// RecipeModels.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 17.02.24.
//
import Foundation
import SwiftUI
struct CookbookApiRecipeV1: Codable {
let name: String
let keywords: String?
let dateCreated: String?
let dateModified: String?
let imageUrl: String?
let imagePlaceholderUrl: String?
let recipe_id: Int
// Properties excluded from Codable
var storedLocally: Bool? = nil
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
}
}
extension CookbookApiRecipeV1: Identifiable, Hashable {
var id: String { name }
}
struct CookbookApiRecipeDetailV1: Codable {
var name: String
var keywords: String
var dateCreated: String?
var dateModified: String?
var imageUrl: String?
var id: String
var prepTime: String?
var cookTime: String?
var totalTime: String?
var description: String
var url: String?
var recipeYield: Int
var recipeCategory: String
var tool: [String]
var recipeIngredient: [String]
var recipeInstructions: [String]
var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
}
init() {
name = ""
keywords = ""
dateCreated = ""
dateModified = ""
imageUrl = ""
id = ""
prepTime = ""
cookTime = ""
totalTime = ""
description = ""
url = ""
recipeYield = 0
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
}
// Custom decoder to handle value type ambiguity
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
keywords = try container.decode(String.self, forKey: .keywords)
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
id = try container.decode(String.self, forKey: .id)
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
description = try container.decode(String.self, forKey: .description)
url = try container.decode(String.self, forKey: .url)
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
tool = try container.decode([String].self, forKey: .tool)
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
}
}
extension CookbookApiRecipeDetailV1 {
static var error: CookbookApiRecipeDetailV1 {
return CookbookApiRecipeDetailV1(
name: "Error: Unable to load recipe.",
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
id: "",
prepTime: "",
cookTime: "",
totalTime: "",
description: "",
url: "",
recipeYield: 0,
recipeCategory: "",
tool: [],
recipeIngredient: [],
recipeInstructions: [],
nutrition: [:]
)
}
func getKeywordsArray() -> [String] {
if keywords == "" { return [] }
return keywords.components(separatedBy: ",")
}
mutating func setKeywordsFromArray(_ keywordsArray: [String]) {
if !keywordsArray.isEmpty {
self.keywords = keywordsArray.joined(separator: ",")
}
}
}
struct RecipeImage {
enum RecipeImageSize: String {
case THUMB="thumb", FULL="full"
}
var imageExists: Bool = true
var thumb: UIImage?
var full: UIImage?
}
struct RecipeKeyword: Codable {
let name: String
let recipe_count: Int
}
enum Nutrition: CaseIterable {
case servingSize,
calories,
carbohydrateContent,
cholesterolContent,
fatContent,
saturatedFatContent,
unsaturatedFatContent,
transFatContent,
fiberContent,
proteinContent,
sodiumContent,
sugarContent
var localizedDescription: String {
switch self {
case .servingSize:
return NSLocalizedString("Serving size", comment: "Serving size")
case .calories:
return NSLocalizedString("Calories", comment: "Calories")
case .carbohydrateContent:
return NSLocalizedString("Carbohydrate content", comment: "Carbohydrate content")
case .cholesterolContent:
return NSLocalizedString("Cholesterol content", comment: "Cholesterol content")
case .fatContent:
return NSLocalizedString("Fat content", comment: "Fat content")
case .saturatedFatContent:
return NSLocalizedString("Saturated fat content", comment: "Saturated fat content")
case .unsaturatedFatContent:
return NSLocalizedString("Unsaturated fat content", comment: "Unsaturated fat content")
case .transFatContent:
return NSLocalizedString("Trans fat content", comment: "Trans fat content")
case .fiberContent:
return NSLocalizedString("Fiber content", comment: "Fiber content")
case .proteinContent:
return NSLocalizedString("Protein content", comment: "Protein content")
case .sodiumContent:
return NSLocalizedString("Sodium content", comment: "Sodium content")
case .sugarContent:
return NSLocalizedString("Sugar content", comment: "Sugar content")
}
}
var dictKey: String {
switch self {
case .servingSize:
"servingSize"
case .calories:
"calories"
case .carbohydrateContent:
"carbohydrateContent"
case .cholesterolContent:
"cholesterolContent"
case .fatContent:
"fatContent"
case .saturatedFatContent:
"saturatedFatContent"
case .unsaturatedFatContent:
"unsaturatedFatContent"
case .transFatContent:
"transFatContent"
case .fiberContent:
"fiberContent"
case .proteinContent:
"proteinContent"
case .sodiumContent:
"sodiumContent"
case .sugarContent:
"sugarContent"
}
}
}

View File

@@ -0,0 +1,8 @@
//
// CookbookProtocols.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.05.24.
//
import Foundation

View File

@@ -0,0 +1,23 @@
//
// CustomError.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 decodingFailed = "Data decoding 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."
}

View File

@@ -0,0 +1,50 @@
//
// NetworkRequests.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
enum RequestMethod: String {
case GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE"
}
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 RecipeImportRequest: Codable {
let url: String
}

View File

@@ -0,0 +1,122 @@
//
// NextcloudApi.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.11.23.
//
import Foundation
import SwiftUI
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
class NextcloudApi {
/// Initiates the login process with Nextcloud using the Login Flow v2.
///
/// This static function sends a POST request to the Nextcloud server to obtain a `LoginV2Request` object.
/// The object contains necessary details for the second step of the authentication process.
///
/// - Returns: A tuple containing an optional `LoginV2Request` and an optional `NetworkError`.
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Request() async -> (LoginV2Request?, NetworkError?) {
let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress
let request = ApiRequest(
path: path + "/index.php/login/v2",
method: .POST
)
let (data, error) = await request.send(pathCompletion: false)
if let error = error {
return (nil, error)
}
guard let data = data else {
return (nil, NetworkError.dataError)
}
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
return (nil, NetworkError.decodingFailed)
}
return (loginRequest, nil)
}
/// Completes the user authentication process with Nextcloud using the Login Flow v2.
///
/// This static function sends a POST request to the Nextcloud server with the login token obtained from `loginV2Request`.
/// On successful validation of the token, it returns a `LoginV2Response` object, completing the user login.
///
/// - Parameter req: A `LoginV2Request` object containing the token and endpoint information for the authentication request.
///
/// - Returns: A tuple containing an optional `LoginV2Response` and an optional `NetworkError`.
/// - `LoginV2Response?`: An object representing the response of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) {
let request = ApiRequest(
path: req.poll.endpoint,
method: .POST,
headerFields: [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM)
],
body: "token=\(req.poll.token)".data(using: .utf8)
)
let (data, error) = await request.send(pathCompletion: false)
if let error = error {
return (nil, error)
}
guard let data = data else {
return (nil, NetworkError.dataError)
}
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
return (nil, NetworkError.decodingFailed)
}
return (loginResponse, nil)
}
static func getAvatar() async -> (UIImage?, NetworkError?) {
let request = ApiRequest(
path: "/index.php/avatar/\(UserSettings.shared.username)/100",
method: .GET,
authString: UserSettings.shared.authString,
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 getHoverCard() async -> (UserData?, NetworkError?) {
let request = ApiRequest(
path: "/ocs/v2.php/hovercard/v1/\(UserSettings.shared.username)",
method: .GET,
authString: UserSettings.shared.authString,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
do {
let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
let data = (json?["ocs"] as? [String: Any])?["data"] as? [String: Any]
let userData = UserData(
userId: data?["userId"] as? String ?? "",
userDisplayName: data?["displayName"] as? String ?? ""
)
print(userData)
return (userData, nil)
} catch {
print(error.localizedDescription)
return (nil, NetworkError.decodingFailed)
}
}
}
struct UserData {
let userId: String
let userDisplayName: String
}