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 } }