Completa L10n su login/hub/wizard/broadcast e introduce ThermalStateManager su iOS/Android con degradazione qualità e indicatore in overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
309 lines
11 KiB
Swift
309 lines
11 KiB
Swift
import Foundation
|
|
|
|
enum APIError: LocalizedError {
|
|
case invalidURL
|
|
case http(Int, String?)
|
|
case decoding(Error)
|
|
case unauthorized
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return L10n.t("api.error.invalid.url")
|
|
case .http(let code, let body): return Self.friendlyHttpMessage(code: code, body: body)
|
|
case .decoding: return L10n.t("api.error.decoding")
|
|
case .unauthorized: return L10n.t("api.error.unauthorized")
|
|
}
|
|
}
|
|
|
|
private static func friendlyHttpMessage(code: Int, body: String?) -> String {
|
|
if let body, !body.isEmpty,
|
|
let data = body.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
if let message = json["message"] as? String, !message.isEmpty {
|
|
return message
|
|
}
|
|
if let error = json["error"] as? String, !error.isEmpty, !error.hasPrefix("{") {
|
|
if code >= 500 { return L10n.t("api.error.server") }
|
|
return error
|
|
}
|
|
}
|
|
if let body, !body.isEmpty, !body.hasPrefix("{") {
|
|
return body
|
|
}
|
|
switch code {
|
|
case 500, 502, 503: return L10n.t("api.error.server")
|
|
case 404: return L10n.t("api.error.not.found")
|
|
case 403: return L10n.t("api.error.forbidden")
|
|
default: return L10n.t("api.error.http", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class MatchLiveAPI: @unchecked Sendable {
|
|
private let session: URLSession
|
|
private var accessToken: String?
|
|
private let baseURL: URL
|
|
/// Chiamato su 401 per rinnovare il token e ritentare la richiesta (una volta).
|
|
var tokenRefresher: (() async throws -> String?)?
|
|
|
|
init(baseURL: URL = URL(string: AppConfig.apiV1)!, session: URLSession = .shared) {
|
|
self.baseURL = baseURL
|
|
self.session = session
|
|
}
|
|
|
|
func setAccessToken(_ token: String?) {
|
|
accessToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
// MARK: - Auth
|
|
|
|
func login(email: String, password: String) async throws -> LoginResponse {
|
|
try await post("auth/login", body: LoginRequest(email: email, password: password))
|
|
}
|
|
|
|
func refresh(refreshToken: String) async throws -> LoginResponse {
|
|
try await post("auth/refresh", body: RefreshRequest(refreshToken: refreshToken))
|
|
}
|
|
|
|
func me() async throws -> UserDto {
|
|
try await get("auth/me")
|
|
}
|
|
|
|
func logout() async throws {
|
|
try await postVoid("auth/logout")
|
|
}
|
|
|
|
// MARK: - Sports & Teams
|
|
|
|
func sports() async throws -> [SportDto] {
|
|
try await get("sports")
|
|
}
|
|
|
|
func teams() async throws -> [TeamDto] {
|
|
try await get("teams")
|
|
}
|
|
|
|
func team(id: String) async throws -> TeamDto {
|
|
try await get("teams/\(id)")
|
|
}
|
|
|
|
func updateTeam(id: String, body: UpdateTeamRequest) async throws -> TeamDto {
|
|
try await patch("teams/\(id)", body: body)
|
|
}
|
|
|
|
func updateTeamMultipart(
|
|
id: String,
|
|
primaryColor: String?,
|
|
secondaryColor: String?,
|
|
logoData: Data?,
|
|
logoFilename: String?
|
|
) async throws -> TeamDto {
|
|
var fields: [MultipartField] = []
|
|
if let primaryColor { fields.append(.text("team[primary_color]", primaryColor)) }
|
|
if let secondaryColor { fields.append(.text("team[secondary_color]", secondaryColor)) }
|
|
if let logoData, let logoFilename {
|
|
fields.append(.file("logo_file", logoData, filename: logoFilename, mime: "image/png"))
|
|
}
|
|
return try await multipartPatch("teams/\(id)", fields: fields)
|
|
}
|
|
|
|
// MARK: - Matches
|
|
|
|
func matches(teamId: String) async throws -> [MatchDto] {
|
|
try await get("teams/\(teamId)/matches")
|
|
}
|
|
|
|
func createMatch(teamId: String, body: CreateMatchRequest) async throws -> MatchDto {
|
|
try await post("teams/\(teamId)/matches", body: body)
|
|
}
|
|
|
|
func match(id: String) async throws -> MatchDto {
|
|
try await get("matches/\(id)")
|
|
}
|
|
|
|
func updateMatch(id: String, body: UpdateMatchRequestFull) async throws -> MatchDto {
|
|
try await patch("matches/\(id)", body: body)
|
|
}
|
|
|
|
func updateMatchMultipart(id: String, fields: [MultipartField]) async throws -> MatchDto {
|
|
try await multipartPatch("matches/\(id)", fields: fields)
|
|
}
|
|
|
|
func deleteMatch(id: String) async throws {
|
|
try await delete("matches/\(id)")
|
|
}
|
|
|
|
// MARK: - Sessions
|
|
|
|
func createSession(matchId: String, body: CreateSessionRequest) async throws -> StreamSessionDto {
|
|
try await post("matches/\(matchId)/sessions", body: body)
|
|
}
|
|
|
|
func session(id: String) async throws -> StreamSessionDto {
|
|
try await get("sessions/\(id)")
|
|
}
|
|
|
|
func startSession(id: String) async throws -> StreamSessionDto {
|
|
try await patch("sessions/\(id)/start", body: EmptyBody())
|
|
}
|
|
|
|
func stopSession(id: String) async throws -> StreamSessionDto {
|
|
try await patch("sessions/\(id)/stop", body: EmptyBody())
|
|
}
|
|
|
|
func pauseSession(id: String) async throws -> StreamSessionDto {
|
|
try await patch("sessions/\(id)/pause", body: EmptyBody())
|
|
}
|
|
|
|
func resumeSession(id: String) async throws -> StreamSessionDto {
|
|
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
|
}
|
|
|
|
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
|
try await post("sessions/\(sessionId)/network_test", body: body)
|
|
}
|
|
|
|
func regiaLink(sessionId: String) async throws -> RegiaLinkResponse {
|
|
try await post("sessions/\(sessionId)/regia_link", body: EmptyBody())
|
|
}
|
|
|
|
func syncScore(sessionId: String, body: ScoreSyncRequest) async throws -> StreamSessionDto {
|
|
try await patch("sessions/\(sessionId)/score", body: body)
|
|
}
|
|
|
|
func applyScoreAction(sessionId: String, body: ScoreActionRequest) async throws -> StreamSessionDto {
|
|
try await post("sessions/\(sessionId)/score_action", body: body)
|
|
}
|
|
|
|
func postTelemetry(sessionId: String, body: TelemetryRequest) async throws {
|
|
try await postVoid("sessions/\(sessionId)/telemetry", body: body)
|
|
}
|
|
|
|
// MARK: - HTTP helpers
|
|
|
|
private struct EmptyBody: Encodable {}
|
|
|
|
private func get<T: Decodable>(_ path: String) async throws -> T {
|
|
try await request(path, method: "GET")
|
|
}
|
|
|
|
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
|
try await request(path, method: "POST", body: body)
|
|
}
|
|
|
|
private func postVoid<B: Encodable>(_ path: String, body: B = EmptyBody()) async throws {
|
|
let _: EmptyResponse = try await request(path, method: "POST", body: body)
|
|
}
|
|
|
|
private func postVoid(_ path: String) async throws {
|
|
let _: EmptyResponse = try await request(path, method: "POST", body: EmptyBody())
|
|
}
|
|
|
|
private func patch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
|
try await request(path, method: "PATCH", body: body)
|
|
}
|
|
|
|
private func delete(_ path: String) async throws {
|
|
let _: EmptyResponse = try await request(path, method: "DELETE")
|
|
}
|
|
|
|
private func multipartPatch<T: Decodable>(_ path: String, fields: [MultipartField]) async throws -> T {
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
|
request.httpMethod = "PATCH"
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
|
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
|
|
return try await execute(request)
|
|
}
|
|
|
|
private func request<T: Decodable, B: Encodable>(
|
|
_ path: String,
|
|
method: String,
|
|
body: B? = nil as EmptyBody?
|
|
) async throws -> T {
|
|
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
|
request.httpMethod = method
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
if body != nil {
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try ApiInstant.encoder.encode(body)
|
|
}
|
|
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
|
return try await execute(request)
|
|
}
|
|
|
|
private func execute<T: Decodable>(_ request: URLRequest, canRefresh: Bool = true) async throws -> T {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else { throw APIError.invalidURL }
|
|
if http.statusCode == 401 {
|
|
if canRefresh, !isPublicAuthRequest(request), let tokenRefresher {
|
|
if let newToken = try await tokenRefresher() {
|
|
accessToken = newToken
|
|
var retry = request
|
|
retry.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
|
|
return try await execute(retry, canRefresh: false)
|
|
}
|
|
}
|
|
throw APIError.unauthorized
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
let body = String(data: data, encoding: .utf8)
|
|
throw APIError.http(http.statusCode, body)
|
|
}
|
|
if T.self == EmptyResponse.self {
|
|
return EmptyResponse() as! T
|
|
}
|
|
do {
|
|
return try ApiInstant.decoder.decode(T.self, from: data)
|
|
} catch {
|
|
throw APIError.decoding(error)
|
|
}
|
|
}
|
|
|
|
private func isPublicAuthRequest(_ request: URLRequest) -> Bool {
|
|
guard let path = request.url?.path else { return false }
|
|
return path.hasSuffix("/auth/login")
|
|
|| path.hasSuffix("/auth/refresh")
|
|
|| path.hasSuffix("/auth/register")
|
|
}
|
|
}
|
|
|
|
private struct EmptyResponse: Decodable {}
|
|
|
|
enum MultipartField {
|
|
case text(String, String)
|
|
case file(String, Data, filename: String, mime: String)
|
|
}
|
|
|
|
enum MultipartBuilder {
|
|
static func build(fields: [MultipartField], boundary: String) -> Data {
|
|
var data = Data()
|
|
let crlf = "\r\n"
|
|
for field in fields {
|
|
switch field {
|
|
case .text(let name, let value):
|
|
data.append("--\(boundary)\(crlf)")
|
|
data.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)")
|
|
data.append(value)
|
|
data.append(crlf)
|
|
case .file(let name, let fileData, let filename, let mime):
|
|
data.append("--\(boundary)\(crlf)")
|
|
data.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\(crlf)")
|
|
data.append("Content-Type: \(mime)\(crlf)\(crlf)")
|
|
data.append(fileData)
|
|
data.append(crlf)
|
|
}
|
|
}
|
|
data.append("--\(boundary)--\(crlf)")
|
|
return data
|
|
}
|
|
}
|
|
|
|
private extension Data {
|
|
mutating func append(_ string: String) {
|
|
if let d = string.data(using: .utf8) { append(d) }
|
|
}
|
|
}
|