Modernize networking layer and fix category navigation and recipe list bugs
Network layer: - Replace static CookbookApi protocol with instance-based CookbookApiProtocol using async/throws instead of tuple returns - Refactor ApiRequest to use URLComponents for proper URL encoding, replace print statements with OSLog, and return typed NetworkError cases - Add structured NetworkError variants (httpError, connectionError, etc.) - Remove global cookbookApi constant in favor of injected dependency on AppState - Delete unused RecipeEditViewModel, RecipeScraper, and Scraper playground Data & model fixes: - Add custom Decodable for RecipeDetail with safe fallbacks for malformed JSON - Make Category Hashable/Equatable use only `name` so NavigationSplitView selection survives category refreshes with updated recipe_count - Return server-assigned ID from uploadRecipe so new recipes get their ID before the post-upload refresh block executes View updates: - Refresh both old and new category recipe lists after upload when category changes, mapping empty recipeCategory to "*" for uncategorized recipes - Raise deployment target to iOS 18, adopt new SwiftUI API conventions - Clean up alerts, onboarding views, and settings Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ struct ApiRequest {
|
||||
let authString: String?
|
||||
let headerFields: [HeaderField]
|
||||
let body: Data?
|
||||
|
||||
|
||||
init(
|
||||
path: String,
|
||||
method: RequestMethod,
|
||||
@@ -28,21 +28,21 @@ struct ApiRequest {
|
||||
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) }
|
||||
|
||||
guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) }
|
||||
// Ensure path percent encoding is applied correctly
|
||||
components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path
|
||||
guard let url = components.url else { return (nil, .missingUrl) }
|
||||
|
||||
// Create URL request
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
|
||||
|
||||
// Set authentication string, if needed
|
||||
if let authString = authString {
|
||||
request.setValue(
|
||||
@@ -50,7 +50,7 @@ struct ApiRequest {
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Set other header fields
|
||||
for headerField in headerFields {
|
||||
request.setValue(
|
||||
@@ -58,46 +58,38 @@ struct ApiRequest {
|
||||
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)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
if let error = decodeURLResponse(response: response as? HTTPURLResponse, data: data) {
|
||||
Logger.network.debug("\(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)
|
||||
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)
|
||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
||||
return (nil, .connectionError(underlying: error))
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
||||
|
||||
private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? {
|
||||
guard let response = response else {
|
||||
return NetworkError.unknownError
|
||||
return .unknownError(detail: "No HTTP response")
|
||||
}
|
||||
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)
|
||||
let statusCode = response.statusCode
|
||||
switch statusCode {
|
||||
case 200...299:
|
||||
return nil
|
||||
default:
|
||||
let body = data.flatMap { String(data: $0, encoding: .utf8) }
|
||||
return .httpError(statusCode: statusCode, body: body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,169 +10,38 @@ 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?)
|
||||
/// A protocol defining common API endpoints for the Cookbook API.
|
||||
protocol CookbookApiProtocol {
|
||||
func importRecipe(url: String) async throws -> RecipeDetail
|
||||
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage?
|
||||
func getRecipes() async throws -> [Recipe]
|
||||
func createRecipe(_ recipe: RecipeDetail) async throws -> Int
|
||||
func getRecipe(id: Int) async throws -> RecipeDetail
|
||||
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int
|
||||
func deleteRecipe(id: Int) async throws
|
||||
func getCategories() async throws -> [Category]
|
||||
func getCategory(named: String) async throws -> [Recipe]
|
||||
func renameCategory(named: String, to newName: String) async throws
|
||||
func getTags() async throws -> [RecipeKeyword]
|
||||
func getRecipesTagged(keyword: String) async throws -> [Recipe]
|
||||
func searchRecipes(query: String) async throws -> [Recipe]
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
enum CookbookApiFactory {
|
||||
static func makeClient(
|
||||
version: CookbookApiVersion = UserSettings.shared.cookbookApiVersion,
|
||||
settings: UserSettings = .shared
|
||||
) -> CookbookApiProtocol {
|
||||
switch version {
|
||||
case .v1:
|
||||
return CookbookApiClient(settings: settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,212 +6,155 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
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)
|
||||
final class CookbookApiClient: CookbookApiProtocol {
|
||||
private let basePath = "/index.php/apps/cookbook/api/v1"
|
||||
private let settings: UserSettings
|
||||
|
||||
private struct RecipeImportRequest: Codable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
|
||||
|
||||
init(settings: UserSettings = .shared) {
|
||||
self.settings = settings
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private var auth: String { settings.authString }
|
||||
|
||||
private func makeRequest(
|
||||
path: String,
|
||||
method: RequestMethod,
|
||||
accept: ContentType = .JSON,
|
||||
contentType: ContentType? = nil,
|
||||
body: Data? = nil
|
||||
) -> ApiRequest {
|
||||
var headers = [
|
||||
HeaderField.ocsRequest(value: true),
|
||||
HeaderField.accept(value: accept)
|
||||
]
|
||||
if let contentType = contentType {
|
||||
headers.append(HeaderField.contentType(value: contentType))
|
||||
}
|
||||
return ApiRequest(
|
||||
path: basePath + path,
|
||||
method: method,
|
||||
authString: auth,
|
||||
headerFields: headers,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
|
||||
private func sendAndDecode<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||
let (data, error) = await request.send()
|
||||
if let error = error { throw error }
|
||||
guard let data = data else { throw NetworkError.unknownError(detail: "No data received") }
|
||||
guard let decoded: T = JSONDecoder.safeDecode(data) else {
|
||||
throw NetworkError.decodingFailed(detail: "Failed to decode \(T.self)")
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func sendRaw(_ request: ApiRequest) async throws -> Data {
|
||||
let (data, error) = await request.send()
|
||||
if let error = error { throw error }
|
||||
guard let data = data else { throw NetworkError.unknownError(detail: "No data received") }
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Protocol implementation
|
||||
|
||||
func importRecipe(url: String) async throws -> RecipeDetail {
|
||||
let importRequest = RecipeImportRequest(url: url)
|
||||
guard let body = JSONEncoder.safeEncode(importRequest) else {
|
||||
throw NetworkError.encodingFailed(detail: "Failed to encode import request")
|
||||
}
|
||||
let request = makeRequest(path: "/import", method: .POST, contentType: .JSON, body: body)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage? {
|
||||
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)
|
||||
let request = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE)
|
||||
let data = try await sendRaw(request)
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func getRecipes() async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/recipes", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
|
||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
||||
return .dataError
|
||||
|
||||
func createRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
||||
guard let body = JSONEncoder.safeEncode(recipe) else {
|
||||
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
||||
}
|
||||
|
||||
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
|
||||
let request = makeRequest(path: "/recipes", method: .POST, contentType: .JSON, body: body)
|
||||
let data = try await sendRaw(request)
|
||||
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||
if let id = json as? Int {
|
||||
return id
|
||||
}
|
||||
return nil
|
||||
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func getRecipe(id: Int) async throws -> RecipeDetail {
|
||||
let request = makeRequest(path: "/recipes/\(id)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
static func updateRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
|
||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
||||
return .dataError
|
||||
|
||||
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
||||
guard let body = JSONEncoder.safeEncode(recipe) else {
|
||||
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
||||
}
|
||||
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
|
||||
let request = makeRequest(path: "/recipes/\(recipe.id)", method: .PUT, contentType: .JSON, body: body)
|
||||
let data = try await sendRaw(request)
|
||||
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||
if let id = json as? Int {
|
||||
return id
|
||||
}
|
||||
return nil
|
||||
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
func deleteRecipe(id: Int) async throws {
|
||||
let request = makeRequest(path: "/recipes/\(id)", method: .DELETE)
|
||||
let _ = try await sendRaw(request)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func getCategories() async throws -> [Category] {
|
||||
let request = makeRequest(path: "/categories", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func getCategory(named categoryName: String) async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/category/\(categoryName)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
func renameCategory(named categoryName: String, to newName: String) async throws {
|
||||
guard let body = JSONEncoder.safeEncode(["name": newName]) else {
|
||||
throw NetworkError.encodingFailed(detail: "Failed to encode category name")
|
||||
}
|
||||
let request = makeRequest(path: "/category/\(categoryName)", method: .PUT, contentType: .JSON, body: body)
|
||||
let _ = try await sendRaw(request)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func getTags() async throws -> [RecipeKeyword] {
|
||||
let request = makeRequest(path: "/keywords", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func getRecipesTagged(keyword: String) async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/tags/\(keyword)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
func searchRecipes(query: String) async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/search/\(query)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,45 @@
|
||||
|
||||
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."
|
||||
public enum NetworkError: Error, LocalizedError {
|
||||
case missingUrl
|
||||
case encodingFailed(detail: String? = nil)
|
||||
case decodingFailed(detail: String? = nil)
|
||||
case httpError(statusCode: Int, body: String? = nil)
|
||||
case connectionError(underlying: Error? = nil)
|
||||
case invalidRequest
|
||||
case unknownError(detail: String? = nil)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingUrl:
|
||||
return "Missing URL."
|
||||
case .encodingFailed(let detail):
|
||||
return "Parameter encoding failed." + (detail.map { " \($0)" } ?? "")
|
||||
case .decodingFailed(let detail):
|
||||
return "Data decoding failed." + (detail.map { " \($0)" } ?? "")
|
||||
case .httpError(let statusCode, let body):
|
||||
return "HTTP error \(statusCode)." + (body.map { " \($0)" } ?? "")
|
||||
case .connectionError(let underlying):
|
||||
return "Connection error." + (underlying.map { " \($0.localizedDescription)" } ?? "")
|
||||
case .invalidRequest:
|
||||
return "Invalid request."
|
||||
case .unknownError(let detail):
|
||||
return "Unknown error." + (detail.map { " \($0)" } ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
var isClientError: Bool {
|
||||
if case .httpError(let statusCode, _) = self {
|
||||
return (400...499).contains(statusCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isServerError: Bool {
|
||||
if case .httpError(let statusCode, _) = self {
|
||||
return (500...599).contains(statusCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,3 @@ struct HeaderField {
|
||||
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecipeImportRequest: Codable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
|
||||
@@ -33,10 +34,10 @@ class NextcloudApi {
|
||||
return (nil, error)
|
||||
}
|
||||
guard let data = data else {
|
||||
return (nil, NetworkError.dataError)
|
||||
return (nil, NetworkError.encodingFailed())
|
||||
}
|
||||
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
|
||||
return (nil, NetworkError.decodingFailed)
|
||||
return (nil, NetworkError.decodingFailed())
|
||||
}
|
||||
return (loginRequest, nil)
|
||||
}
|
||||
@@ -69,10 +70,10 @@ class NextcloudApi {
|
||||
return (nil, error)
|
||||
}
|
||||
guard let data = data else {
|
||||
return (nil, NetworkError.dataError)
|
||||
return (nil, NetworkError.encodingFailed())
|
||||
}
|
||||
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
|
||||
return (nil, NetworkError.decodingFailed)
|
||||
return (nil, NetworkError.decodingFailed())
|
||||
}
|
||||
return (loginResponse, nil)
|
||||
}
|
||||
@@ -107,11 +108,11 @@ class NextcloudApi {
|
||||
userId: data?["userId"] as? String ?? "",
|
||||
userDisplayName: data?["displayName"] as? String ?? ""
|
||||
)
|
||||
print(userData)
|
||||
Logger.network.debug("Loaded hover card for user \(userData.userId)")
|
||||
return (userData, nil)
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
return (nil, NetworkError.decodingFailed)
|
||||
Logger.network.error("Failed to decode hover card: \(error.localizedDescription)")
|
||||
return (nil, NetworkError.decodingFailed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user