import Foundation /// Der einzige Weg der App zum Backend. /// /// Ein Actor, weil Adresse und Marke sich zur Laufzeit ändern (an- und /// abmelden) und von überall gelesen werden. /// /// Zwei Anmeldewege, und zwar nacheinander, nicht nebeneinander: /// Beim Anmelden setzt das Backend ein Sitzungs-Cookie, das `URLSession` /// von selbst mitführt. Noch in dieser Sitzung holt sich die App eine /// Gerätemarke und benutzt danach nur noch den Header `X-Certain-Token`. /// Das Cookie wird dann weggeworfen – siehe `vergissCookies()`. actor CertainClient { private(set) var basis: URL private(set) var marke: String? private let sitzung: URLSession private let decoder = JSONDecoder() private let encoder = JSONEncoder() init(basis: URL, marke: String? = nil) { self.basis = basis self.marke = marke let einstellungen = URLSessionConfiguration.ephemeral einstellungen.httpCookieStorage = HTTPCookieStorage.shared einstellungen.httpShouldSetCookies = true einstellungen.httpCookieAcceptPolicy = .always // Alle /api-Antworten tragen `Cache-Control: no-store`; den lokalen // Zwischenspeicher führen wir selbst (siehe `Zwischenspeicher`), weil // wir ihn auch offline und mit Datumsangabe brauchen. einstellungen.requestCachePolicy = .reloadIgnoringLocalCacheData einstellungen.timeoutIntervalForRequest = 30 einstellungen.timeoutIntervalForResource = 120 einstellungen.waitsForConnectivity = false sitzung = URLSession(configuration: einstellungen) } // MARK: - Zustand func setzeBasis(_ neue: URL) { basis = neue } func setzeMarke(_ neue: String?) { marke = neue } /// Nach dem Holen der Marke: Das Cookie wird nicht mehr gebraucht und soll /// nicht länger auf dem Gerät liegen als nötig. func vergissCookies() { guard let speicher = sitzung.configuration.httpCookieStorage else { return } for keks in speicher.cookies(for: basis) ?? [] { speicher.deleteCookie(keks) } } // MARK: - Aufrufe enum Methode: String, Sendable { case get = "GET" case post = "POST" case patch = "PATCH" case put = "PUT" case delete = "DELETE" } /// Lesen. func hole(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A { let daten = try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil) return try entschluessele(daten) } /// Schreiben ohne Nutzdaten (etwa `POST /uebertraege/suchen`). @discardableResult func schicke(_ methode: Methode, _ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A { let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil) return try entschluessele(daten) } /// Schreiben mit JSON-Körper. @discardableResult func schicke(_ methode: Methode, _ pfad: String, koerper: K, abfrage: [URLQueryItem] = []) async throws -> A { let roh = try encoder.encode(koerper) let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh) return try entschluessele(daten) } /// Für 204-Antworten: Es kommt nichts zurück, und das ist richtig so. func schickeStumm(_ methode: Methode, _ pfad: String, abfrage: [URLQueryItem] = []) async throws { _ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil) } func schickeStumm(_ methode: Methode, _ pfad: String, koerper: K, abfrage: [URLQueryItem] = []) async throws { let roh = try encoder.encode(koerper) _ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh) } /// Die Anmeldung geht als Formular, nicht als JSON – das Backend nutzt /// `OAuth2PasswordRequestForm` (`backend/app/routers/auth.py`). func schickeFormular(_ pfad: String, felder: [String: String]) async throws -> A { var teile = URLComponents() teile.queryItems = felder.map { URLQueryItem(name: $0.key, value: $0.value) } // `+` muss als Zeichen durch, sonst wird daraus beim Server ein Leerzeichen. let text = (teile.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") let daten = try await fuehreAus( .post, pfad, abfrage: [], koerper: Data(text.utf8), inhaltstyp: "application/x-www-form-urlencoded") return try entschluessele(daten) } /// Datei hochladen (Logos, Kontoauszüge, Belege). func schickeDatei(_ pfad: String, feldname: String = "file", dateiname: String, inhalt: Data, typ: String = "application/octet-stream", weitereFelder: [String: String] = [:], abfrage: [URLQueryItem] = []) async throws -> A { let grenze = "Grenze-\(UUID().uuidString)" var koerper = Data() func anhaengen(_ text: String) { koerper.append(Data(text.utf8)) } for (schluessel, wert) in weitereFelder { anhaengen("--\(grenze)\r\n") anhaengen("Content-Disposition: form-data; name=\"\(schluessel)\"\r\n\r\n") anhaengen("\(wert)\r\n") } anhaengen("--\(grenze)\r\n") anhaengen("Content-Disposition: form-data; name=\"\(feldname)\"; filename=\"\(dateiname)\"\r\n") anhaengen("Content-Type: \(typ)\r\n\r\n") koerper.append(inhalt) anhaengen("\r\n--\(grenze)--\r\n") let daten = try await fuehreAus( .post, pfad, abfrage: abfrage, koerper: koerper, inhaltstyp: "multipart/form-data; boundary=\(grenze)") return try entschluessele(daten) } /// Rohe Bytes holen – für Bilder (Abo-Logos) und Dateien. func holeBytes(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> Data { try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil) } // MARK: - Innenleben private func baueAdresse(_ pfad: String, _ abfrage: [URLQueryItem]) throws -> URL { let voll = basis.appendingPathComponent("api").appendingPathComponent( pfad.hasPrefix("/") ? String(pfad.dropFirst()) : pfad) guard var teile = URLComponents(url: voll, resolvingAgainstBaseURL: false) else { throw APIFehler.antwortUnlesbar(pfad) } // Leere Werte gar nicht erst mitschicken – das Backend liest "" sonst // als gesetzten Filter. let gefiltert = abfrage.filter { ($0.value ?? "").isEmpty == false } teile.queryItems = gefiltert.isEmpty ? nil : gefiltert guard let fertig = teile.url else { throw APIFehler.antwortUnlesbar(pfad) } return fertig } private func fuehreAus(_ methode: Methode, _ pfad: String, abfrage: [URLQueryItem], koerper: Data?, inhaltstyp: String = "application/json") async throws -> Data { var anfrage = URLRequest(url: try baueAdresse(pfad, abfrage)) anfrage.httpMethod = methode.rawValue anfrage.setValue("application/json", forHTTPHeaderField: "Accept") if let marke { anfrage.setValue(marke, forHTTPHeaderField: "X-Certain-Token") } if let koerper { anfrage.httpBody = koerper anfrage.setValue(inhaltstyp, forHTTPHeaderField: "Content-Type") } let daten: Data let antwort: URLResponse do { (daten, antwort) = try await sitzung.data(for: anfrage) } catch let fehler as URLError { throw APIFehler.keineVerbindung(fehler.localizedDescription) } catch { throw APIFehler.keineVerbindung(error.localizedDescription) } guard let http = antwort as? HTTPURLResponse else { throw APIFehler.antwortUnlesbar("keine HTTP-Antwort") } guard (200..<300).contains(http.statusCode) else { throw deute(status: http.statusCode, daten: daten) } return daten } private func deute(status: Int, daten: Data) -> APIFehler { let text = Fehlerantwort.ausDaten(daten) switch status { case 401: return .nichtAngemeldet case 403: if let pflicht = APIFehler.Pflicht(rawValue: text) { return .pflichtOffen(pflicht) } return .verboten(text) case 404: return .nichtGefunden case 409: return .konflikt(text) case 400, 422: return .ungueltig(text) default: return .server(status: status, text: text) } } private func entschluessele(_ daten: Data) throws -> A { // 204 und andere leere Antworten: Wer hier `Leer` erwartet, bekommt es. if daten.isEmpty, let leer = Leer() as? A { return leer } do { return try decoder.decode(A.self, from: daten) } catch { throw APIFehler.antwortUnlesbar(String(describing: error)) } } } /// Platzhalter für Antworten ohne Inhalt. struct Leer: Codable, Sendable { init() {} }