PONGo_ios/APIClient.swift
Jon Vanvik 203465913c Apply code-review fixes: identity/pagination, load races, TLS pin, leaks
Addresses a multi-agent code review (26 confirmed findings + a URLSession
leak) across correctness, concurrency, security, and cleanup:

- MongoDocument.id: deterministic fallback (was UUID() on every access) to stop
  SwiftUI List/ForEach identity churn; getAll pagination drives its cursor off
  the real _id via documentId, so a missing _id stops paging instead of looping
  on a fabricated cursor.
- View models: per-load generation guard (+ captured-tab guard on ONU detail)
  so a cancelled or overlapping load can't commit stale results or land data on
  the wrong tab.
- JSONValue.intValue: guard Int(d) against NaN/inf/out-of-range (crash).
- ServerTrustEvaluator: hash the real SubjectPublicKeyInfo (prepend the
  algorithm-specific ASN.1 SPKI header) so a standard openssl-captured
  public-key pin actually matches; unknown key types fail closed.
- AppEnvironment.configure: clear stale cookies + Keychain creds on host change.
- FirmwareFile.isCompatible: empty metadata no longer passes as compatible-for-all.
- APIClient: invalidate URLSession on deinit (per-configure/probe leak).
- Dashboard best-effort sections keep last-good on transient failure; unified
  optical thresholds; LoginView prefill-once; populatedBuckets unique ForEach id;
  Format rounding rollover; dead-code/docstring/dedup cleanups.

Verification pending on the Mac (no Swift toolchain on the Linux dev box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:36:49 +02:00

288 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
/// A `URLSession` created with a delegate retains that delegate (and itself)
/// until invalidated Apple: an un-invalidated session "leaks memory until
/// it exits." This client is rebuilt on every `configure`/`probe`, so tear the
/// session down when the client goes away.
deinit { session.invalidateAndCancel() }
// 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.
///
/// `idOf` MUST return the document's real `_id` (the cursor MCMS excludes
/// at/before), or nil. Pass `{ $0.documentId }`, never the synthesized
/// `Identifiable.id` a fabricated id is a meaningless cursor that makes the
/// server re-serve or skip pages. A nil id safely stops pagination.
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)
// Stop on a short/empty page, or when the last doc has no usable `_id`.
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)
}
}