Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage, Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste Fachansicht die Kontenliste. Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen. Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
236 lines
9.4 KiB
Swift
236 lines
9.4 KiB
Swift
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() {}
|
||
}
|