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>
161 lines
5.8 KiB
Swift
161 lines
5.8 KiB
Swift
//
|
|
// 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<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 = 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)
|
|
}
|
|
}
|