507 lines
14 KiB
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
|
|
)
|
|
}
|
|
}
|