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

276
APIClient.swift Normal file
View file

@ -0,0 +1,276 @@
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.33.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)
}
}