87 lines
3.9 KiB
Swift
87 lines
3.9 KiB
Swift
import Foundation
|
|
|
|
/// Typed errors surfaced by `APIClient`. HTTP status codes from the MCMS docs
|
|
/// are mapped to specific cases so the UI can react (e.g. re-auth on `.unauthorized`).
|
|
enum APIError: Error, Equatable {
|
|
/// No usable base URL / malformed endpoint.
|
|
case invalidURL
|
|
/// The request never produced an HTTP response (offline, host unreachable, VPN off).
|
|
case transport(message: String)
|
|
/// TLS trust evaluation failed for the server.
|
|
case serverTrust
|
|
/// Could not encode the request body.
|
|
case encoding(message: String)
|
|
/// Could not decode the response body into the expected type.
|
|
case decoding(message: String)
|
|
|
|
/// 400 — request/validation error. `details` carries the server payload.
|
|
case badRequest(details: JSONValue?)
|
|
/// 401 — not authenticated (failed login or expired session). Trigger re-login.
|
|
case unauthorized
|
|
/// 403 — authenticated but not permitted, OR a missing/invalid CSRF token.
|
|
case forbidden(details: JSONValue?)
|
|
/// 404 — resource (URL, file, or document) not found.
|
|
case notFound
|
|
/// 409 — ID already in use (uploads / creates).
|
|
case conflict(details: JSONValue?)
|
|
/// 415 — unsupported Content-Type.
|
|
case unsupportedMediaType
|
|
/// 500 — generic server error.
|
|
case server(details: JSONValue?)
|
|
|
|
/// The API returned HTTP 2xx but `status == "fail"`.
|
|
case apiFailure(details: JSONValue?)
|
|
/// Any other unexpected status code.
|
|
case unexpectedStatus(code: Int, details: JSONValue?)
|
|
|
|
/// Maps an HTTP status code (with optional decoded `details`) to a case.
|
|
static func from(statusCode: Int, details: JSONValue?) -> APIError {
|
|
switch statusCode {
|
|
case 400: return .badRequest(details: details)
|
|
case 401: return .unauthorized
|
|
case 403: return .forbidden(details: details)
|
|
case 404: return .notFound
|
|
case 409: return .conflict(details: details)
|
|
case 415: return .unsupportedMediaType
|
|
case 500: return .server(details: details)
|
|
default: return .unexpectedStatus(code: statusCode, details: details)
|
|
}
|
|
}
|
|
|
|
/// True when the right response is to send the user back to the login screen.
|
|
/// Only 401 (expired/invalid session) qualifies — a 403 is usually a
|
|
/// permission or CSRF issue where re-login won't help, so it must NOT bounce
|
|
/// the user out mid-action.
|
|
var requiresReauthentication: Bool {
|
|
switch self {
|
|
case .unauthorized: return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension APIError: LocalizedError {
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "The request URL was invalid."
|
|
case .transport(let m): return "Network error: \(m)"
|
|
case .serverTrust: return "The server's TLS certificate could not be trusted."
|
|
case .encoding(let m): return "Failed to encode request: \(m)"
|
|
case .decoding(let m): return "Failed to read server response: \(m)"
|
|
case .badRequest(let d): return "Request rejected (400). \(Self.detailText(d))"
|
|
case .unauthorized: return "Not authenticated (401). Please sign in again."
|
|
case .forbidden(let d): return "Forbidden (403) — your account may not have permission for this action. \(Self.detailText(d))"
|
|
case .notFound: return "Resource not found (404)."
|
|
case .conflict(let d): return "Conflict (409) — ID already in use. \(Self.detailText(d))"
|
|
case .unsupportedMediaType: return "Unsupported media type (415)."
|
|
case .server(let d): return "Server error (500). \(Self.detailText(d))"
|
|
case .apiFailure(let d): return "Request failed. \(Self.detailText(d))"
|
|
case .unexpectedStatus(let c, let d): return "Unexpected response (\(c)). \(Self.detailText(d))"
|
|
}
|
|
}
|
|
|
|
private static func detailText(_ details: JSONValue?) -> String {
|
|
guard let details else { return "" }
|
|
return details.compactDescription
|
|
}
|
|
}
|