// // CookbookApiV1.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 16.11.23. // import Foundation import OSLog import UIKit final class CookbookApiClient: CookbookApiProtocol { private let basePath = "/index.php/apps/cookbook/api/v1" private let settings: UserSettings private struct RecipeImportRequest: Codable { let url: String } 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(_ 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 = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE) let data = try await sendRaw(request) return UIImage(data: data) } func getRecipes() async throws -> [Recipe] { let request = makeRequest(path: "/recipes", method: .GET) return try await sendAndDecode(request) } func createRecipe(_ recipe: RecipeDetail) async throws -> Int { guard let body = JSONEncoder.safeEncode(recipe) else { throw NetworkError.encodingFailed(detail: "Failed to encode recipe") } 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 } throw NetworkError.decodingFailed(detail: "Expected recipe ID in response") } func getRecipe(id: Int) async throws -> RecipeDetail { let request = makeRequest(path: "/recipes/\(id)", method: .GET) return try await sendAndDecode(request) } func updateRecipe(_ recipe: RecipeDetail) async throws -> Int { guard let body = JSONEncoder.safeEncode(recipe) else { throw NetworkError.encodingFailed(detail: "Failed to encode recipe") } 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 } throw NetworkError.decodingFailed(detail: "Expected recipe ID in response") } func deleteRecipe(id: Int) async throws { let request = makeRequest(path: "/recipes/\(id)", method: .DELETE) let _ = try await sendRaw(request) } func getCategories() async throws -> [Category] { let request = makeRequest(path: "/categories", method: .GET) return try await sendAndDecode(request) } func getCategory(named categoryName: String) async throws -> [Recipe] { let request = makeRequest(path: "/category/\(categoryName)", method: .GET) return try await sendAndDecode(request) } 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) } func getTags() async throws -> [RecipeKeyword] { let request = makeRequest(path: "/keywords", method: .GET) return try await sendAndDecode(request) } func getRecipesTagged(keyword: String) async throws -> [Recipe] { let request = makeRequest(path: "/tags/\(keyword)", method: .GET) return try await sendAndDecode(request) } func searchRecipes(query: String) async throws -> [Recipe] { let request = makeRequest(path: "/search/\(query)", method: .GET) return try await sendAndDecode(request) } }