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>
107 lines
4.1 KiB
Swift
107 lines
4.1 KiB
Swift
import Foundation
|
||
|
||
/// Was beim Reden mit dem Certain-Backend schiefgehen kann.
|
||
///
|
||
/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht
|
||
/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm,
|
||
/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt,
|
||
/// alles übrige ist eine Meldung.
|
||
enum APIFehler: Error, LocalizedError, Sendable, Equatable {
|
||
/// Netz weg, Server nicht erreichbar, Zeitüberschreitung.
|
||
case keineVerbindung(String)
|
||
/// Unter der Adresse antwortet etwas, aber kein Certain.
|
||
case keinCertainServer
|
||
/// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen.
|
||
case nichtAngemeldet
|
||
/// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden.
|
||
case pflichtOffen(Pflicht)
|
||
/// 403 aus einem anderen Grund.
|
||
case verboten(String)
|
||
/// 404 – gibt es nicht, oder gehört jemand anderem.
|
||
case nichtGefunden
|
||
/// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true`
|
||
/// wiederholen lässt. Der Text ist für den Nutzer geschrieben.
|
||
case konflikt(String)
|
||
/// 400 oder 422.
|
||
case ungueltig(String)
|
||
/// Alles übrige, was der Server sagt.
|
||
case server(status: Int, text: String)
|
||
/// Die Antwort passt nicht zu dem, was wir erwarten.
|
||
case antwortUnlesbar(String)
|
||
|
||
/// Was der Benutzer erledigen muss, bevor die API ihn durchlässt.
|
||
/// Die Werte sind die Schlüssel aus `backend/app/deps.py`.
|
||
enum Pflicht: String, Sendable {
|
||
case passwortWechseln = "password_change_required"
|
||
case zweiterFaktor = "mfa_setup_required"
|
||
}
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .keineVerbindung(let grund):
|
||
return String(format: String(localized: "fehler.keineVerbindung"), grund)
|
||
case .keinCertainServer:
|
||
return String(localized: "fehler.keinCertainServer")
|
||
case .nichtAngemeldet:
|
||
return String(localized: "fehler.nichtAngemeldet")
|
||
case .pflichtOffen(.passwortWechseln):
|
||
return String(localized: "fehler.passwortWechseln")
|
||
case .pflichtOffen(.zweiterFaktor):
|
||
return String(localized: "fehler.zweiterFaktor")
|
||
case .verboten(let text), .konflikt(let text), .ungueltig(let text):
|
||
return text
|
||
case .nichtGefunden:
|
||
return String(localized: "fehler.nichtGefunden")
|
||
case .server(let status, let text):
|
||
return text.isEmpty
|
||
? String(format: String(localized: "fehler.server"), status)
|
||
: text
|
||
case .antwortUnlesbar(let grund):
|
||
return String(format: String(localized: "fehler.antwortUnlesbar"), grund)
|
||
}
|
||
}
|
||
|
||
/// Ob ein erneuter Versuch überhaupt Sinn ergibt.
|
||
var lohntWiederholung: Bool {
|
||
switch self {
|
||
case .keineVerbindung, .server:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Der Fehlerkörper des Backends.
|
||
///
|
||
/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`),
|
||
/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch,
|
||
/// sonst verliert die App bei jedem Eingabefehler die Meldung.
|
||
struct Fehlerantwort: Decodable, Sendable {
|
||
let detail: String
|
||
|
||
private enum Schluessel: String, CodingKey { case detail }
|
||
|
||
private struct Einzelfehler: Decodable {
|
||
let msg: String?
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let behaelter = try decoder.container(keyedBy: Schluessel.self)
|
||
if let text = try? behaelter.decode(String.self, forKey: .detail) {
|
||
detail = text
|
||
} else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) {
|
||
detail = liste.compactMap(\.msg).joined(separator: ", ")
|
||
} else {
|
||
detail = ""
|
||
}
|
||
}
|
||
|
||
/// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl,
|
||
/// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier
|
||
/// bereits im Fehlerfall.
|
||
static func ausDaten(_ daten: Data) -> String {
|
||
(try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? ""
|
||
}
|
||
}
|