Connect macOS app to backend device bootstrap
This commit is contained in:
@@ -1,21 +1,189 @@
|
||||
import Foundation
|
||||
import VelodyDomain
|
||||
|
||||
public struct HealthStatusSummary: Hashable, Sendable {
|
||||
public var databaseStatus: String
|
||||
public var storageStatus: String
|
||||
public enum VelodyAPIError: LocalizedError, Sendable {
|
||||
case invalidResponse
|
||||
case invalidServerURL(String)
|
||||
case requestFailed(String)
|
||||
case server(statusCode: Int, message: String?)
|
||||
case decodingFailed(String)
|
||||
|
||||
public init(
|
||||
databaseStatus: String,
|
||||
storageStatus: String
|
||||
) {
|
||||
self.databaseStatus = databaseStatus
|
||||
self.storageStatus = storageStatus
|
||||
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 fetchHealthStatus() async throws -> HealthStatusSummary
|
||||
func registerDevice(
|
||||
_ payload: DeviceRegistrationPayload
|
||||
) async throws -> DeviceRegistrationResponse
|
||||
|
||||
func sendHeartbeat(
|
||||
_ payload: DeviceHeartbeatPayload
|
||||
) async throws -> DeviceHeartbeatResponse
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private func sendRequest<Response: Decodable>(
|
||||
method: String,
|
||||
pathComponents: [String],
|
||||
responseType: Response.Type
|
||||
) async throws -> Response {
|
||||
let request = try buildRequest(
|
||||
method: method,
|
||||
pathComponents: pathComponents,
|
||||
bodyData: nil
|
||||
)
|
||||
|
||||
return try await execute(request, responseType: responseType)
|
||||
}
|
||||
|
||||
private func sendRequest<Body: Encodable, Response: Decodable>(
|
||||
method: String,
|
||||
pathComponents: [String],
|
||||
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,
|
||||
bodyData: bodyData
|
||||
)
|
||||
|
||||
return try await execute(request, responseType: responseType)
|
||||
}
|
||||
|
||||
private func buildRequest(
|
||||
method: String,
|
||||
pathComponents: [String],
|
||||
bodyData: Data?
|
||||
) throws -> URLRequest {
|
||||
guard let url = endpointURL(pathComponents: pathComponents) else {
|
||||
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
if let bodyData {
|
||||
request.httpBody = bodyData
|
||||
request.setValue("application/json", 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)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(responseType, from: data)
|
||||
} catch {
|
||||
throw VelodyAPIError.decodingFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func endpointURL(pathComponents: [String]) -> URL? {
|
||||
pathComponents.reduce(environment.baseURL) { partialURL, component in
|
||||
partialURL.appendingPathComponent(component, isDirectory: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct StubVelodyAPIClient: VelodyAPIClient {
|
||||
@@ -25,11 +193,45 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
public func fetchHealthStatus() async throws -> HealthStatusSummary {
|
||||
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 HealthStatusSummary(
|
||||
databaseStatus: "placeholder",
|
||||
storageStatus: "placeholder"
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user