276 lines
11 KiB
Swift
276 lines
11 KiB
Swift
import Foundation
|
||
|
||
/// Transport layer for the MCMS REST API.
|
||
///
|
||
/// Responsibilities:
|
||
/// - build versioned requests from `MCMSEndpoint` + `APIQuery`
|
||
/// - inject auth headers: `Referer`, `Content-Type`, and `X-CSRFToken`
|
||
/// (sourced from the CSRF cookie) on mutating requests
|
||
/// - let `URLSession` manage the session/CSRF cookies
|
||
/// - unwrap the `{ status, data, details }` envelope and map status codes to `APIError`
|
||
///
|
||
/// Works identically for direct and proxied access — only `APIConfiguration.baseURL`
|
||
/// (and optionally `refererOverride`) differ.
|
||
@MainActor
|
||
final class APIClient {
|
||
|
||
let config: APIConfiguration
|
||
private let session: URLSession
|
||
private let trustDelegate: ServerTrustEvaluator
|
||
private let session_: SessionStore
|
||
|
||
/// Invoked when a request fails with 401/403 so the app can route back to
|
||
/// login. Set by `MCMSConnection`/`AppEnvironment`.
|
||
var onAuthExpired: (@MainActor () -> Void)?
|
||
|
||
/// Documents decode their bodies into `JSONValue`, so no `Date` is ever
|
||
/// decoded here — a custom `dateDecodingStrategy` would be dead code (and it
|
||
/// tripped a `@MainActor`-isolation warning). Timestamps are surfaced as
|
||
/// strings and parsed for display by `MongoTimestamp`. If you promote a field
|
||
/// to a typed `Date`, install a strategy backed by `MongoTimestamp.date(from:)`.
|
||
static func makeDecoder() -> JSONDecoder { JSONDecoder() }
|
||
|
||
private let decoder = APIClient.makeDecoder()
|
||
private let encoder = JSONEncoder()
|
||
|
||
init(config: APIConfiguration, session: SessionStore) {
|
||
self.config = config
|
||
self.session_ = session
|
||
self.trustDelegate = ServerTrustEvaluator(policy: config.trustPolicy)
|
||
|
||
let cfg = URLSessionConfiguration.default
|
||
cfg.httpCookieStorage = session.cookieStorage
|
||
cfg.httpShouldSetCookies = true
|
||
cfg.httpCookieAcceptPolicy = .always
|
||
cfg.timeoutIntervalForRequest = config.timeout
|
||
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||
self.session = URLSession(configuration: cfg, delegate: trustDelegate, delegateQueue: nil)
|
||
}
|
||
|
||
// MARK: - Public API
|
||
|
||
/// GET returning a decoded `T` from the envelope's `data`.
|
||
func get<T: Decodable>(
|
||
_ endpoint: MCMSEndpoint,
|
||
query: APIQuery = .empty,
|
||
as type: T.Type = T.self,
|
||
version: String? = nil
|
||
) async throws -> T {
|
||
let request = try makeRequest(method: "GET", endpoint: endpoint, query: query, version: version)
|
||
return try await execute(request)
|
||
}
|
||
|
||
/// GET returning the raw JSON body as `JSONValue`, bypassing the
|
||
/// `{status,data}` envelope — for web-helper endpoints (e.g. the CPE helper)
|
||
/// that return a bare array.
|
||
func getRaw(_ endpoint: MCMSEndpoint, query: APIQuery = .empty, version: String? = nil) async throws -> JSONValue {
|
||
let request = try makeRequest(method: "GET", endpoint: endpoint, query: query, version: version)
|
||
let (data, response) = try await perform(request)
|
||
let http = try httpResponse(response)
|
||
guard (200...299).contains(http.statusCode) else {
|
||
throw failure(status: http.statusCode, data: data)
|
||
}
|
||
guard !data.isEmpty else { return .null }
|
||
do { return try decoder.decode(JSONValue.self, from: data) }
|
||
catch { throw APIError.decoding(message: error.localizedDescription) }
|
||
}
|
||
|
||
/// PUT/POST/PATCH with an `Encodable` body. By default the body is wrapped
|
||
/// as `{ "data": <body> }` per the standard endpoints; pass
|
||
/// `wrapInData: false` for action endpoints that take a raw body
|
||
/// (e.g. disable-onu `{ "disable": true }`).
|
||
@discardableResult
|
||
func send<T: Decodable, B: Encodable>(
|
||
_ method: String,
|
||
_ endpoint: MCMSEndpoint,
|
||
body: B,
|
||
wrapInData: Bool = true,
|
||
query: APIQuery = .empty,
|
||
as type: T.Type = T.self,
|
||
version: String? = nil
|
||
) async throws -> T {
|
||
let data: Data
|
||
do {
|
||
data = wrapInData
|
||
? try encoder.encode(DataEnvelope(data: body))
|
||
: try encoder.encode(body)
|
||
} catch {
|
||
throw APIError.encoding(message: error.localizedDescription)
|
||
}
|
||
var request = try makeRequest(method: method, endpoint: endpoint, query: query, version: version)
|
||
request.httpBody = data
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
injectCSRF(into: &request)
|
||
return try await execute(request)
|
||
}
|
||
|
||
/// Mutating request with no body (e.g. ONU reset, broadcast-enable).
|
||
@discardableResult
|
||
func action<T: Decodable>(
|
||
_ method: String,
|
||
_ endpoint: MCMSEndpoint,
|
||
as type: T.Type = T.self,
|
||
version: String? = nil
|
||
) async throws -> T {
|
||
var request = try makeRequest(method: method, endpoint: endpoint, query: .empty, version: version)
|
||
injectCSRF(into: &request)
|
||
return try await execute(request)
|
||
}
|
||
|
||
/// DELETE; returns the raw envelope details (often empty / 204).
|
||
func delete(_ endpoint: MCMSEndpoint, version: String? = nil) async throws {
|
||
var request = try makeRequest(method: "DELETE", endpoint: endpoint, query: .empty, version: version)
|
||
injectCSRF(into: &request)
|
||
let _: JSONValue? = try await executeOptional(request)
|
||
}
|
||
|
||
/// Fetches a full list by following the `next` cursor until a short page
|
||
/// is returned. `pageLimit` defaults to 100: full Mongo docs are ~30 KB, so
|
||
/// larger pages (the server default is 1000) can exceed the front-end proxy
|
||
/// timeout on big fleets. See MCMS field guide §3.3–3.4.
|
||
func getAll<T: Decodable>(
|
||
_ endpoint: MCMSEndpoint,
|
||
baseQuery: APIQuery = .empty,
|
||
pageLimit: Int = 100,
|
||
idOf: (T) -> String?,
|
||
version: String? = nil
|
||
) async throws -> [T] {
|
||
var all: [T] = []
|
||
var cursor: String? = nil
|
||
repeat {
|
||
var q = baseQuery
|
||
q.limit = pageLimit
|
||
q.next = cursor
|
||
let page: [T] = try await get(endpoint, query: q, as: [T].self, version: version)
|
||
all.append(contentsOf: page)
|
||
cursor = page.count == pageLimit ? page.last.flatMap(idOf) : nil
|
||
} while cursor != nil
|
||
return all
|
||
}
|
||
|
||
// MARK: - Request construction
|
||
|
||
private func makeRequest(
|
||
method: String,
|
||
endpoint: MCMSEndpoint,
|
||
query: APIQuery,
|
||
version: String?
|
||
) throws -> URLRequest {
|
||
let path = endpoint.path(version: version ?? config.apiVersion)
|
||
guard var components = URLComponents(url: config.baseURL, resolvingAgainstBaseURL: false)
|
||
else { throw APIError.invalidURL }
|
||
// Append the endpoint path and let URLComponents percent-encode it ONCE.
|
||
// `appendingPathComponent` re-encodes an already-encoded id (a MAC's `:` →
|
||
// `%3A` → `%253A`), which MCMS rejects as an invalid id — so ids are kept
|
||
// raw in MCMSEndpoint and encoded here.
|
||
components.path += path
|
||
|
||
let items = query.queryItems()
|
||
if !items.isEmpty { components.queryItems = items }
|
||
guard let url = components.url else { throw APIError.invalidURL }
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = method
|
||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
request.setValue(config.refererValue, forHTTPHeaderField: "Referer")
|
||
return request
|
||
}
|
||
|
||
/// Echo the CSRF cookie value as the `X-CSRFToken` header. Without this,
|
||
/// writes are rejected with 403.
|
||
private func injectCSRF(into request: inout URLRequest) {
|
||
if let token = session_.csrfToken() {
|
||
request.setValue(token, forHTTPHeaderField: "X-CSRFToken")
|
||
}
|
||
}
|
||
|
||
// MARK: - Execution
|
||
|
||
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||
let (data, response) = try await perform(request)
|
||
let http = try httpResponse(response)
|
||
|
||
guard (200...299).contains(http.statusCode) else {
|
||
throw failure(status: http.statusCode, data: data)
|
||
}
|
||
if http.statusCode == 204 || data.isEmpty {
|
||
if let empty = emptyValue(for: T.self) { return empty }
|
||
throw APIError.decoding(message: "Empty body for non-optional response")
|
||
}
|
||
return try decodeEnvelope(data)
|
||
}
|
||
|
||
private func executeOptional<T: Decodable>(_ request: URLRequest) async throws -> T? {
|
||
let (data, response) = try await perform(request)
|
||
let http = try httpResponse(response)
|
||
guard (200...299).contains(http.statusCode) else {
|
||
throw failure(status: http.statusCode, data: data)
|
||
}
|
||
if http.statusCode == 204 || data.isEmpty { return nil }
|
||
return try decodeEnvelope(data)
|
||
}
|
||
|
||
/// Maps a non-2xx status to an `APIError` and fires the re-auth hook on 401/403.
|
||
private func failure(status: Int, data: Data) -> APIError {
|
||
let error = APIError.from(statusCode: status, details: decodeDetails(data))
|
||
if error.requiresReauthentication { onAuthExpired?() }
|
||
return error
|
||
}
|
||
|
||
/// The "empty" instance for a type that opts into `EmptyRepresentable`.
|
||
private func emptyValue<T>(for type: T.Type) -> T? {
|
||
(type as? EmptyRepresentable.Type)?.emptyValue as? T
|
||
}
|
||
|
||
private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) {
|
||
do {
|
||
return try await session.data(for: request)
|
||
} catch let urlError as URLError {
|
||
switch urlError.code {
|
||
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||
.serverCertificateHasUnknownRoot, .serverCertificateNotYetValid,
|
||
.secureConnectionFailed:
|
||
throw APIError.serverTrust
|
||
default:
|
||
throw APIError.transport(message: urlError.localizedDescription)
|
||
}
|
||
} catch {
|
||
throw APIError.transport(message: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func httpResponse(_ response: URLResponse) throws -> HTTPURLResponse {
|
||
guard let http = response as? HTTPURLResponse else {
|
||
throw APIError.transport(message: "Non-HTTP response")
|
||
}
|
||
return http
|
||
}
|
||
|
||
private func decodeEnvelope<T: Decodable>(_ data: Data) throws -> T {
|
||
do {
|
||
let envelope = try decoder.decode(APIResponse<T>.self, from: data)
|
||
guard envelope.isSuccess else {
|
||
throw APIError.apiFailure(details: envelope.details)
|
||
}
|
||
if let value = envelope.data { return value }
|
||
// A successful PUT/POST/PATCH replies `{"status":"success"}` with no
|
||
// `data` (only GET returns `data`). Hand back the type's empty value
|
||
// instead of failing a request that actually succeeded.
|
||
if let empty = emptyValue(for: T.self) { return empty }
|
||
throw APIError.decoding(message: "Success response missing `data`")
|
||
} catch let apiError as APIError {
|
||
throw apiError
|
||
} catch {
|
||
throw APIError.decoding(message: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// Best-effort decode of the `details` payload from an error response.
|
||
private func decodeDetails(_ data: Data) -> JSONValue? {
|
||
guard !data.isEmpty else { return nil }
|
||
if let env = try? decoder.decode(APIResponse<JSONValue>.self, from: data) {
|
||
return env.details
|
||
}
|
||
return try? decoder.decode(JSONValue.self, from: data)
|
||
}
|
||
}
|