ChadMusicAPIClient.swift 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import Foundation
  2. /// HTTP client for the Chad Music REST API.
  3. @MainActor
  4. @Observable
  5. final class ChadMusicAPIClient {
  6. static let shared = ChadMusicAPIClient()
  7. // MARK: - Configuration
  8. var serverURL: String {
  9. get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" }
  10. set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") }
  11. }
  12. var isConfigured: Bool {
  13. if ProcessInfo.processInfo.arguments.contains("-MockNetwork") { return true }
  14. return !serverURL.isEmpty && KeychainService.loadAPIKey() != nil
  15. }
  16. // MARK: - Private
  17. @ObservationIgnored private let session: URLSession
  18. @ObservationIgnored private let decoder: JSONDecoder
  19. init() {
  20. let config = URLSessionConfiguration.default
  21. config.timeoutIntervalForRequest = 15
  22. config.timeoutIntervalForResource = 60
  23. // Inject mock URL protocol for UI testing
  24. if ProcessInfo.processInfo.arguments.contains("-MockNetwork") {
  25. config.protocolClasses = [MockURLProtocol.self] + (config.protocolClasses ?? [])
  26. }
  27. self.session = URLSession(configuration: config)
  28. self.decoder = JSONDecoder()
  29. }
  30. // MARK: - API Methods
  31. func fetchStats() async throws -> ChadStats {
  32. try await get("api/stats")
  33. }
  34. func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] {
  35. try await get("api/cat/\(category.rawValue)")
  36. }
  37. func fetchAlbums() async throws -> [ChadAlbum] {
  38. try await get("api/cat/album")
  39. }
  40. func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] {
  41. try await get("api/album/\(albumId)/tracks")
  42. }
  43. func testConnection() async -> Result<ChadStats, ChadMusicError> {
  44. do {
  45. let stats = try await fetchStats()
  46. return .success(stats)
  47. } catch let error as ChadMusicError {
  48. return .failure(error)
  49. } catch {
  50. return .failure(.networkError(error))
  51. }
  52. }
  53. func streamURL(for trackPath: String) -> URL? {
  54. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  55. guard !trimmed.isEmpty else { return nil }
  56. let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed
  57. let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath
  58. return URL(string: base + path)
  59. }
  60. var authHeaders: [String: String] {
  61. guard let key = KeychainService.loadAPIKey() else { return [:] }
  62. return ["Authorization": "Bearer \(key)"]
  63. }
  64. // MARK: - Private Helpers
  65. private var baseURL: URL? {
  66. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  67. guard !trimmed.isEmpty else { return nil }
  68. let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
  69. return URL(string: normalized)
  70. }
  71. private func get<T: Decodable>(_ path: String) async throws -> T {
  72. guard let base = baseURL else {
  73. throw ChadMusicError.notConfigured
  74. }
  75. let url = base.appending(path: path)
  76. var request = URLRequest(url: url)
  77. request.httpMethod = "GET"
  78. if let apiKey = KeychainService.loadAPIKey() {
  79. request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
  80. }
  81. let (data, response) = try await session.data(for: request)
  82. guard let httpResponse = response as? HTTPURLResponse else {
  83. throw ChadMusicError.invalidResponse
  84. }
  85. switch httpResponse.statusCode {
  86. case 200..<300:
  87. do {
  88. return try decoder.decode(T.self, from: data)
  89. } catch {
  90. throw ChadMusicError.decodingFailed(error)
  91. }
  92. case 401:
  93. throw ChadMusicError.unauthorized
  94. case 403:
  95. throw ChadMusicError.forbidden
  96. case 404:
  97. throw ChadMusicError.notFound(path)
  98. default:
  99. throw ChadMusicError.httpError(httpResponse.statusCode)
  100. }
  101. }
  102. }
  103. // MARK: - Errors
  104. enum ChadMusicError: LocalizedError {
  105. case notConfigured
  106. case unauthorized
  107. case forbidden
  108. case notFound(String)
  109. case httpError(Int)
  110. case invalidResponse
  111. case decodingFailed(Error)
  112. case networkError(Error)
  113. var errorDescription: String? {
  114. switch self {
  115. case .notConfigured:
  116. "Chad Music server not configured. Set the server URL and API key in Settings."
  117. case .unauthorized:
  118. "Invalid API key (401 Unauthorized)."
  119. case .forbidden:
  120. "Access denied (403 Forbidden)."
  121. case .notFound(let path):
  122. "Endpoint not found: \(path)"
  123. case .httpError(let code):
  124. "Server returned HTTP \(code)."
  125. case .invalidResponse:
  126. "Invalid server response."
  127. case .decodingFailed(let error):
  128. "Failed to decode response: \(error.localizedDescription)"
  129. case .networkError(let error):
  130. "Network error: \(error.localizedDescription)"
  131. }
  132. }
  133. }