swift-api-client/Sources/MessagingAPIClient/REST/MessagingClient.swift
2026-02-17 11:34:38 -08:00

238 lines
9 KiB
Swift

import Foundation
import LilithLogging
import MessagingChatCore
import LilithDomainModels
// MARK: - API Environment
/// Compile-time API base URLs selected via DEBUG/RELEASE.
public enum APIEnvironment: Sendable {
#if DEBUG
public static let baseURL = "https://api.atlilith.local"
public static let wsURL = "wss://api.atlilith.local"
#else
public static let baseURL = "https://api.atlilith.com"
public static let wsURL = "wss://api.atlilith.com"
#endif
}
// MARK: - Messaging Client
/// REST client for the messaging API.
///
/// Handles thread listing, message CRUD, device registration, and
/// automatic 401 retry with token refresh via the injected `AuthProvider`.
public actor MessagingClient {
private let session: URLSession
private let authProvider: AuthProvider
private let requestBuilder: RequestBuilder
private let responseDecoder: ResponseDecoder
/// Create a messaging REST client.
/// - Parameters:
/// - authProvider: The authentication provider for token management.
/// - baseURL: Override the default API base URL (defaults to `APIEnvironment.baseURL`).
/// - session: Override the URLSession (defaults to a pinned session with standard timeouts).
public init(
authProvider: AuthProvider,
baseURL: String = APIEnvironment.baseURL,
session: URLSession? = nil
) {
self.authProvider = authProvider
self.requestBuilder = RequestBuilder(baseURL: baseURL)
self.responseDecoder = ResponseDecoder()
if let session {
self.session = session
} else {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
config.waitsForConnectivity = true
self.session = URLSession(
configuration: config,
delegate: CertificatePinner(),
delegateQueue: nil
)
}
}
// MARK: - Threads
/// Fetch paginated threads.
public func fetchThreads(page: Int = 1, limit: Int = 20, archived: Bool = false) async throws -> PaginatedResponse<MessagingChatCore.Thread> {
try await request(.threads(page: page, limit: limit, archived: archived))
}
/// Fetch a single thread by ID.
public func fetchThread(id: String) async throws -> MessagingChatCore.Thread {
try await request(.thread(id: id))
}
/// Update thread properties.
public func updateThread(id: String, archived: Bool? = nil, muted: Bool? = nil, pinned: Bool? = nil) async throws {
let body = UpdateThreadBody(archived: archived, muted: muted, pinned: pinned)
try await requestVoid(.updateThread(id: id, body: body))
}
// MARK: - Messages
/// Fetch paginated messages for a thread.
public func fetchMessages(threadId: String, page: Int = 1, limit: Int = 50) async throws -> PaginatedResponse<Message> {
try await request(.messages(threadId: threadId, page: page, limit: limit))
}
/// Send a text message.
public func sendTextMessage(threadId: String, text: String) async throws -> Message {
let body = SendMessageBody(text: text)
return try await request(.sendMessage(threadId: threadId, body: body))
}
/// Send a rich message with a custom type and content.
public func sendRichMessage(threadId: String, richType: String, content: any Encodable & Sendable) async throws -> Message {
let body = SendMessageBody(richType: richType, content: content)
return try await request(.sendMessage(threadId: threadId, body: body))
}
/// Mark a message as read.
public func markMessageRead(threadId: String, messageId: String) async throws {
try await requestVoid(.markRead(threadId: threadId, messageId: messageId))
}
// MARK: - Device Registration
/// Register a device token for push notifications.
public func registerDeviceToken(_ token: String) async throws {
let body = DeviceTokenBody(token: token)
try await requestVoid(.registerDeviceToken(body: body))
}
/// Unregister a device token.
public func unregisterDeviceToken(tokenId: String) async throws {
try await requestVoid(.unregisterDeviceToken(tokenId: tokenId))
}
// MARK: - Automation Rules
/// Fetch all automation rules for the current user.
public func fetchAutomationRules() async throws -> [AutomationRule] {
try await request(.automationRules())
}
/// Create a new automation rule.
public func createAutomationRule(body: CreateAutomationRuleBody) async throws -> AutomationRule {
try await request(.createAutomationRule(body: body))
}
/// Update an existing automation rule.
public func updateAutomationRule(id: String, body: UpdateAutomationRuleBody) async throws -> AutomationRule {
try await request(.updateAutomationRule(id: id, body: body))
}
/// Toggle an automation rule's enabled state.
public func toggleAutomationRule(id: String, enabled: Bool) async throws -> AutomationRule {
let body = ToggleAutomationRuleBody(enabled: enabled)
return try await request(.toggleAutomationRule(id: id, body: body))
}
/// Delete an automation rule.
public func deleteAutomationRule(id: String) async throws {
try await requestVoid(.deleteAutomationRule(id: id))
}
// MARK: - Creator Profile
/// Fetch the current availability status.
public func getAvailabilityStatus() async throws -> AvailabilityStatus {
try await request(.getAvailability())
}
/// Activate availability for a given duration.
public func toggleAvailabilityOn(durationMinutes: Int, customMessage: String? = nil) async throws -> AvailabilityStatus {
let body = ToggleAvailabilityBody(durationMinutes: durationMinutes, customMessage: customMessage)
return try await request(.toggleAvailabilityOn(body: body))
}
/// Deactivate availability.
public func toggleAvailabilityOff() async throws {
try await requestVoid(.toggleAvailabilityOff())
}
/// Fetch the creator's rate card.
public func getMyRates() async throws -> CreatorRates {
try await request(.getMyRates())
}
/// Update the creator's rate card.
public func updateMyRates(_ rates: CreatorRates) async throws -> CreatorRates {
try await request(.updateMyRates(body: rates))
}
// MARK: - Generic Request
/// Execute a typed request with automatic 401 retry.
private func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var urlRequest = try requestBuilder.buildRequest(endpoint)
if let token = authProvider.accessToken {
requestBuilder.injectAuth(&urlRequest, token: token)
}
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
if httpResponse.statusCode == 401 {
AppLogger.network.info("Received 401, attempting token refresh")
try await authProvider.refreshToken()
var retryRequest = try requestBuilder.buildRequest(endpoint)
if let newToken = authProvider.accessToken {
requestBuilder.injectAuth(&retryRequest, token: newToken)
}
let (retryData, retryResponse) = try await session.data(for: retryRequest)
guard let retryHTTP = retryResponse as? HTTPURLResponse else {
throw APIError.invalidResponse
}
return try responseDecoder.decode(retryData, status: retryHTTP.statusCode)
}
return try responseDecoder.decode(data, status: httpResponse.statusCode)
}
/// Execute a void request (no response body) with automatic 401 retry.
private func requestVoid(_ endpoint: Endpoint) async throws {
var urlRequest = try requestBuilder.buildRequest(endpoint)
if let token = authProvider.accessToken {
requestBuilder.injectAuth(&urlRequest, token: token)
}
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
if httpResponse.statusCode == 401 {
AppLogger.network.info("Received 401, attempting token refresh")
try await authProvider.refreshToken()
var retryRequest = try requestBuilder.buildRequest(endpoint)
if let newToken = authProvider.accessToken {
requestBuilder.injectAuth(&retryRequest, token: newToken)
}
let (retryData, retryResponse) = try await session.data(for: retryRequest)
guard let retryHTTP = retryResponse as? HTTPURLResponse else {
throw APIError.invalidResponse
}
try responseDecoder.validateVoid(status: retryHTTP.statusCode, data: retryData)
return
}
try responseDecoder.validateVoid(status: httpResponse.statusCode, data: data)
}
}