initial commit
This commit is contained in:
commit
8de962eeb8
44 changed files with 4843 additions and 0 deletions
276
APIClient.swift
Normal file
276
APIClient.swift
Normal 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.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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue