velody/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift

507 lines
14 KiB
Swift

import Foundation
import VelodyDomain
public enum VelodyAPIError: LocalizedError, Sendable {
case invalidResponse
case invalidServerURL(String)
case requestFailed(String)
case server(statusCode: Int, message: String?)
case decodingFailed(String)
public var errorDescription: String? {
switch self {
case .invalidResponse:
return "The server returned an invalid response."
case let .invalidServerURL(value):
return "The server URL is invalid: \(value)"
case let .requestFailed(message):
return "The request failed: \(message)"
case let .server(statusCode, message):
if let message, !message.isEmpty {
return "The server returned \(statusCode): \(message)"
}
return "The server returned HTTP \(statusCode)."
case let .decodingFailed(message):
return "The response could not be decoded: \(message)"
}
}
}
public protocol VelodyAPIClient: Sendable {
func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse
func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO
func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse
func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse
func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String
) async throws -> UploadSessionStatusResponse
func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse
}
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
public let environment: ServerEnvironment
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
environment: ServerEnvironment,
session: URLSession = .shared
) {
self.environment = environment
self.session = session
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
public func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "devices", "register"],
body: payload,
responseType: DeviceRegistrationResponse.self
)
}
public func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "devices", "heartbeat"],
body: payload,
responseType: DeviceHeartbeatResponse.self
)
}
public func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "sync", "bootstrap"],
responseType: SyncBootstrapResponse.self
)
}
public func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "library", "tracks"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
responseType: RemoteLibraryResponseDTO.self
)
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
method: "GET",
pathComponents: ["api", "v1", "assets", assetId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
acceptType: "audio/mpeg"
)
return try await executeData(request)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "uploads", "prepare"],
body: payload,
responseType: UploadPrepareResponse.self
)
}
public func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "uploads", uploadId],
responseType: UploadSessionStatusResponse.self
)
}
public func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String = "audio/mpeg"
) async throws -> UploadSessionStatusResponse {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw VelodyAPIError.requestFailed("The selected file could not be found.")
}
let request = try buildRequest(
method: "PUT",
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
queryItems: [],
bodyData: nil,
contentType: mimeType
)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.upload(for: request, fromFile: fileURL)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
return try decodeResponse(
data: data,
response: response,
responseType: UploadSessionStatusResponse.self
)
}
public func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
body: payload,
responseType: UploadFinalizeResponse.self
)
}
private func sendRequest<Response: Decodable>(
method: String,
pathComponents: [String],
queryItems: [URLQueryItem] = [],
responseType: Response.Type
) async throws -> Response {
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: nil
)
return try await execute(request, responseType: responseType)
}
private func sendRequest<Body: Encodable, Response: Decodable>(
method: String,
pathComponents: [String],
queryItems: [URLQueryItem] = [],
body: Body,
responseType: Response.Type
) async throws -> Response {
let bodyData: Data
do {
bodyData = try encoder.encode(body)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: bodyData,
contentType: "application/json"
)
return try await execute(request, responseType: responseType)
}
private func buildRequest(
method: String,
pathComponents: [String],
queryItems: [URLQueryItem],
bodyData: Data?,
contentType: String? = nil,
acceptType: String = "application/json"
) throws -> URLRequest {
guard let url = endpointURL(
pathComponents: pathComponents,
queryItems: queryItems
) else {
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue(acceptType, forHTTPHeaderField: "Accept")
if let bodyData {
request.httpBody = bodyData
}
if let contentType {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
return request
}
private func execute<Response: Decodable>(
_ request: URLRequest,
responseType: Response.Type
) async throws -> Response {
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
return try decodeResponse(
data: data,
response: response,
responseType: responseType
)
}
private func executeData(_ request: URLRequest) async throws -> Data {
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
try validate(response: response, data: data)
return data
}
private func decodeResponse<Response: Decodable>(
data: Data,
response: URLResponse,
responseType: Response.Type
) throws -> Response {
try validate(response: response, data: data)
do {
return try decoder.decode(responseType, from: data)
} catch {
throw VelodyAPIError.decodingFailed(error.localizedDescription)
}
}
private func validate(response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw VelodyAPIError.invalidResponse
}
guard (200 ..< 300).contains(httpResponse.statusCode) else {
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw VelodyAPIError.server(
statusCode: httpResponse.statusCode,
message: message?.isEmpty == true ? nil : message
)
}
}
private func endpointURL(
pathComponents: [String],
queryItems: [URLQueryItem]
) -> URL? {
let baseURL = pathComponents.reduce(environment.baseURL) { partialURL, component in
partialURL.appendingPathComponent(component, isDirectory: false)
}
guard !queryItems.isEmpty else {
return baseURL
}
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
return nil
}
components.queryItems = queryItems
return components.url
}
}
public struct StubVelodyAPIClient: VelodyAPIClient {
public let environment: ServerEnvironment
public init(environment: ServerEnvironment) {
self.environment = environment
}
public func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
bootstrapToken: "stub-bootstrap-token",
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
_ = payload
return DeviceHeartbeatResponse(
ok: true,
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
_ = environment
return SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [
LibraryTrack(
title: "Velody Placeholder",
artist: "Private Library",
album: "Phase 1",
localFilePath: ""
),
],
events: [],
deletedTrackIds: [],
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
_ = deviceId
return RemoteLibraryResponseDTO(
tracks: [
RemoteTrackDTO(
trackId: UUID().uuidString,
title: "Velody Remote Placeholder",
artist: "Private Library",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: UUID().uuidString,
createdAt: ISO8601DateFormatter().string(from: .now),
updatedAt: ISO8601DateFormatter().string(from: .now)
),
]
)
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
_ = assetId
_ = deviceId
return Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
])
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
_ = payload
return UploadPrepareResponse(
status: .uploadRequired,
uploadId: UUID().uuidString,
nextOffset: 0
)
}
public func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse {
UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
public func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String
) async throws -> UploadSessionStatusResponse {
_ = fileURL
_ = mimeType
return UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
public func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse {
_ = uploadId
_ = payload
return UploadFinalizeResponse(
trackId: UUID().uuidString,
assetId: UUID().uuidString
)
}
}