import Foundation import ManaCore /// Zitare-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// aus ManaCore, der die zitare-api-Endpoints kennt. actor ZitareAPI { let transport: AuthenticatedTransport let decoder: JSONDecoder let encoder: JSONEncoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) decoder = JSONDecoder() encoder = JSONEncoder() } /// `GET /healthz` — verifiziert dass zitare-api erreichbar ist. /// Öffentlicher Endpoint, läuft direkt via `URLSession` (nicht /// `AuthenticatedTransport`), damit auch nicht-eingeloggte Apps /// die API-Erreichbarkeit prüfen können. func healthCheck() async throws -> Bool { let url = AppConfig.apiBaseURL.appendingPathComponent("healthz") let (_, response) = try await URLSession.shared.data(from: url) guard let http = response as? HTTPURLResponse else { return false } return http.statusCode == 200 } /// `POST /api/v1/quotes` — Quote-Draft einreichen. Server-Schema: /// `zitare/apps/api/src/routes/quotes.ts:submissionSchema`. Pflicht: /// text + acceptedTos:true + (authorName ODER authorSlug). Optionale /// Source-Felder kommen mit, wenn der User sie ausgefüllt hat. func submitQuote(_ draft: QuoteDraft) async throws -> SubmittedQuote { let payload = try encoder.encode(draft) let (data, http) = try await transport.request( path: "/api/v1/quotes", method: "POST", body: payload ) try ensureOK(http, data: data) return try decoder.decode(SubmittedQuote.self, from: data) } // MARK: - Helpers private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { guard http.statusCode >= 400 else { return } let body = String(data: data, encoding: .utf8) ?? "" let code = Self.parseErrorCode(from: body) throw ZitareAPIError(status: http.statusCode, code: code, body: body) } private static func parseErrorCode(from body: String) -> String? { guard let data = body.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let code = obj["error"] as? String else { return nil } return code } } // MARK: - DTOs /// Submit-Payload für `POST /api/v1/quotes`. JSON-Keys matchen /// `submissionSchema` aus `zitare/apps/api/src/routes/quotes.ts`. struct QuoteDraft: Codable, Sendable, Equatable { var text: String var language: String var authorName: String? var authorSlug: String? var sourceTitle: String? var sourceKind: SourceKind? var sourceYear: Int? var editReason: String? var acceptedTos: Bool enum SourceKind: String, Codable, Sendable, CaseIterable, Identifiable { case book, article, talk, film, other var id: String { rawValue } } } /// Response von `POST /api/v1/quotes`. Server gibt das volle /// QuoteRow zurück — wir brauchen für die UI nur slug + status. struct SubmittedQuote: Codable, Sendable { let slug: String let status: String } /// Lokalisierter API-Fehler. `code` ist der server-seitige Code aus /// `{ "error": "" }`, der via `api.error.` im /// Localizable.xcstrings nachgeschlagen wird. struct ZitareAPIError: LocalizedError { let status: Int let code: String? let body: String var errorDescription: String? { if let code, !code.isEmpty { let key = "api.error.\(code)" let localized = Bundle.main.localizedString(forKey: key, value: nil, table: nil) if localized != key { return localized } return "Fehler \(status): \(code)" } return "HTTP \(status)" } }