Files
certain/ios/Netzwerk/CertainClient.swift
GuidoandClaude Opus 5 ca83bc7e13 fix: Die Netzwerkschicht uebersetzt - leeres Dictionary braucht [:]
Der Vorgabewert von weitereFelder in schickeDatei stand auf [], was Swift als
leeres Array liest. Der einzige Uebersetzungsfehler in den 2600 blind
geschriebenen Zeilen der Stufe 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 05:45:42 +02:00

236 lines
9.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<A: Decodable>(_ 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<A: Decodable>(_ 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<A: Decodable, K: Encodable>(_ 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<K: Encodable>(_ 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<A: Decodable>(_ 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<A: Decodable>(_ 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<A: Decodable>(_ 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() {}
}