Files
MatchLiveTv/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
T
Emiliano FrascaroandCursor ea5da1eb86 Allinea iOS a i18n Android 2.0.5 e aggiunge monitoraggio termico nativo.
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>
2026-07-24 11:19:37 +02:00

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) }
}
}