import Foundation enum APIError: LocalizedError { case notConfigured case unauthorized case server(String) var errorDescription: String? { switch self { case .notConfigured: return "Server-Adresse fehlt. Bitte in den Einstellungen hinterlegen." case .unauthorized: return "Nicht angemeldet oder Sitzung abgelaufen." case .server(let message): return message } } } /// Schlanker Client für die Project-Good-REST-API. /// Alle Pfade laufen über /api (wie im Web, siehe web/nginx.conf). actor APIClient { static let shared = APIClient() private var baseURL: URL? { Session.shared.baseURL } private var token: String? { Session.shared.token } // MARK: - Basis private func makeRequest(_ path: String, method: String = "GET") throws -> URLRequest { guard let baseURL else { throw APIError.notConfigured } guard let url = URL(string: "api" + path, relativeTo: baseURL) else { throw APIError.notConfigured } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 15 if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } return request } private func send(_ request: URLRequest, as type: T.Type) async throws -> T { let (data, response) = try await URLSession.shared.data(for: request) try check(response, data: data) return try JSONDecoder().decode(T.self, from: data) } private func check(_ response: URLResponse, data: Data) throws { guard let http = response as? HTTPURLResponse else { return } guard !(200..<300).contains(http.statusCode) else { return } if http.statusCode == 401 { throw APIError.unauthorized } // FastAPI liefert Fehler als {"detail": "..."} if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let detail = object["detail"] as? String { throw APIError.server(detail) } throw APIError.server("Serverfehler (\(http.statusCode))") } private func jsonBody(_ request: inout URLRequest, _ value: T) throws { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(value) } // MARK: - Auth func login(username: String, password: String) async throws -> TokenResponse { var request = try makeRequest("/auth/login", method: "POST") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var components = URLComponents() components.queryItems = [ URLQueryItem(name: "username", value: username), URLQueryItem(name: "password", value: password), ] request.httpBody = components.percentEncodedQuery?.data(using: .utf8) return try await send(request, as: TokenResponse.self) } func me() async throws -> MeResponse { try await send(try makeRequest("/auth/me"), as: MeResponse.self) } // MARK: - Stammdaten func units() async throws -> [Unit] { try await send(try makeRequest("/units"), as: [Unit].self) } func locations() async throws -> [StorageLocation] { try await send(try makeRequest("/locations"), as: [StorageLocation].self) } /// `categoryId == 0` liefert Artikel ohne Kategorie; eine echte ID schliesst /// die Unterkategorien mit ein. func searchProducts(_ query: String, categoryId: Int? = nil) async throws -> [Product] { var parts: [String] = [] if !query.isEmpty { let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" parts.append("q=\(escaped)") } if let categoryId { parts.append("category_id=\(categoryId)") } let suffix = parts.isEmpty ? "" : "?" + parts.joined(separator: "&") return try await send(try makeRequest("/products" + suffix), as: [Product].self) } func product(id: Int) async throws -> Product { try await send(try makeRequest("/products/\(id)"), as: Product.self) } func lookup(barcode: String) async throws -> LookupResult { let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self) } func createProduct(_ payload: NewProductRequest) async throws -> Product { var request = try makeRequest("/products", method: "POST") try jsonBody(&request, payload) return try await send(request, as: Product.self) } func updateProduct(id: Int, _ payload: ProductUpdateRequest) async throws -> Product { var request = try makeRequest("/products/\(id)", method: "PATCH") try jsonBody(&request, payload) return try await send(request, as: Product.self) } func groups() async throws -> [GroupItem] { try await send(try makeRequest("/groups"), as: [GroupItem].self) } func categories() async throws -> [CategoryItem] { try await send(try makeRequest("/categories"), as: [CategoryItem].self) } func createGroup(_ payload: NewGroupRequest) async throws -> GroupItem { var request = try makeRequest("/groups", method: "POST") try jsonBody(&request, payload) return try await send(request, as: GroupItem.self) } func settings() async throws -> [SettingEntry] { try await send(try makeRequest("/settings"), as: [SettingEntry].self) } // MARK: - Listen func shoppingList() async throws -> [ShoppingItem] { try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self) } func shoppingGroups() async throws -> [GroupShoppingItem] { try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self) } func expiring(days: Int? = nil) async throws -> [ExpiringItem] { let path = days.map { "/expiring?days=\($0)" } ?? "/expiring" return try await send(try makeRequest(path), as: [ExpiringItem].self) } // MARK: - Bestand func lots(productId: Int) async throws -> [Lot] { try await send(try makeRequest("/lots?product_id=\(productId)"), as: [Lot].self) } func checkInBatch(_ payload: BatchCheckInRequest) async throws -> StockResponse { var request = try makeRequest("/stock/checkin/batch", method: "POST") try jsonBody(&request, payload) return try await send(request, as: StockResponse.self) } func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse { var request = try makeRequest("/stock/checkout", method: "POST") try jsonBody(&request, payload) return try await send(request, as: StockResponse.self) } func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot { var request = try makeRequest("/lots/\(id)", method: "PATCH") try jsonBody(&request, payload) return try await send(request, as: Lot.self) } func deleteLot(id: Int) async throws { let request = try makeRequest("/lots/\(id)", method: "DELETE") let (data, response) = try await URLSession.shared.data(for: request) try check(response, data: data) // 204: kein Rumpf zum Auswerten } }