195 lines
6.7 KiB
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
|
|
}()
|
|
}
|