Files
LabWiseiOS/LabWiseKit/Sources/LabWiseKit/APIClient.swift

195 lines
6.7 KiB
Swift

import Foundation
// MARK: - API Errors
public enum APIError: Error, Sendable {
case unauthorized
case httpError(statusCode: Int, data: Data)
case decodingError(Error)
case encodingError(Error)
case networkError(Error)
case invalidURL
}
// MARK: - APIClient
public final class APIClient: Sendable {
public static let shared = APIClient()
private let baseURL = URL(string: "https://labwise.wahwa.com")!
private let session: URLSession
/// Called when a 401 is received UI layer should redirect to login.
public nonisolated(unsafe) var onUnauthorized: (@Sendable () -> Void)?
private init() {
let config = URLSessionConfiguration.default
config.httpCookieStorage = HTTPCookieStorage.shared
config.httpShouldSetCookies = true
config.httpCookieAcceptPolicy = .always
session = URLSession(configuration: config)
}
// MARK: - Request helpers
func request(
method: String,
path: String,
body: (any Encodable)? = nil,
contentType: String = "application/json"
) async throws -> Data {
guard let url = URL(string: path, relativeTo: baseURL) else {
throw APIError.invalidURL
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
req.setValue(baseURL.absoluteString, forHTTPHeaderField: "Origin")
if let body {
do {
req.httpBody = try JSONEncoder.api.encode(body)
} catch {
throw APIError.encodingError(error)
}
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
}
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
if http.statusCode == 401 {
clearSessionCookies()
onUnauthorized?()
throw APIError.unauthorized
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(statusCode: http.statusCode, data: data)
}
return data
}
func get<T: Decodable>(_ path: String) async throws -> T {
let data = try await request(method: "GET", path: path)
return try decode(T.self, from: data, decoder: .api)
}
func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
let data = try await request(method: "POST", path: path, body: body)
return try decode(Response.self, from: data, decoder: .api)
}
func patch<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
let data = try await request(method: "PATCH", path: path, body: body)
return try decode(Response.self, from: data, decoder: .api)
}
func delete(_ path: String) async throws {
_ = try await request(method: "DELETE", path: path)
}
// MARK: - Snake_case variants (for profile endpoint)
func getSnakeCase<T: Decodable>(_ path: String) async throws -> T {
let data = try await request(method: "GET", path: path)
return try decode(T.self, from: data, decoder: .snakeCase)
}
func postSnakeCase<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
let encoded: Data
do { encoded = try JSONEncoder.snakeCase.encode(body) }
catch { throw APIError.encodingError(error) }
let data = try await requestRaw(method: "POST", path: path, body: encoded)
return try decode(Response.self, from: data, decoder: .snakeCase)
}
// MARK: - Raw POST for auth (no decoded response needed)
public func postRaw(_ path: String, body: some Encodable) async throws -> Data {
try await request(method: "POST", path: path, body: body)
}
/// Low-level: sends pre-encoded body bytes.
func requestRaw(method: String, path: String, body: Data) async throws -> Data {
guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = body
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
if http.statusCode == 401 { clearSessionCookies(); onUnauthorized?(); throw APIError.unauthorized }
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(statusCode: http.statusCode, data: data)
}
return data
}
// MARK: - Helpers
private func decode<T: Decodable>(_ type: T.Type, from data: Data, decoder: JSONDecoder) throws -> T {
do {
return try decoder.decode(type, from: data)
} catch {
let body = String(data: data, encoding: .utf8) ?? "<binary \(data.count)b>"
print("[APIClient] Decoding \(T.self) failed: \(error)\nResponse body: \(body)")
throw APIError.decodingError(error)
}
}
func clearSessionCookies() {
let storage = HTTPCookieStorage.shared
// Clear all cookies for the server domain
if let cookies = storage.cookies(for: baseURL) {
for cookie in cookies {
storage.deleteCookie(cookie)
}
}
// Also sweep all cookies for either session token name variant
if let allCookies = storage.cookies {
for cookie in allCookies where
cookie.name == "better-auth.session_token" ||
cookie.name == "__Secure-better-auth.session_token" {
storage.deleteCookie(cookie)
}
}
}
}
// MARK: - Coder helpers
extension JSONEncoder {
/// Chemicals and auth endpoints use camelCase keys as-is.
static let api: JSONEncoder = JSONEncoder()
/// Profile endpoint uses snake_case keys.
static let snakeCase: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}()
}
extension JSONDecoder {
/// Chemicals and auth endpoints return camelCase keys as-is.
static let api: JSONDecoder = JSONDecoder()
/// Profile endpoint returns snake_case keys.
static let snakeCase: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
}