initial commit

This commit is contained in:
Jon Vanvik 2026-05-31 14:11:29 +02:00
commit 8de962eeb8
44 changed files with 4843 additions and 0 deletions

87
APIError.swift Normal file
View file

@ -0,0 +1,87 @@
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
}
}