321 lines
13 KiB
Swift
321 lines
13 KiB
Swift
import Foundation
|
||
import UIKit
|
||
|
||
enum ProfileServiceError: LocalizedError {
|
||
case unexpectedStatus(String)
|
||
case decoding(debugDescription: String)
|
||
case encoding(String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .unexpectedStatus(let message):
|
||
return message
|
||
case .decoding(let debugDescription):
|
||
return AppConfig.DEBUG
|
||
? debugDescription
|
||
: NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile service decoding error")
|
||
case .encoding(let message):
|
||
return message
|
||
}
|
||
}
|
||
}
|
||
|
||
final class ProfileService {
|
||
private let client: NetworkClient
|
||
private let decoder: JSONDecoder
|
||
|
||
init(client: NetworkClient = .shared) {
|
||
self.client = client
|
||
self.decoder = JSONDecoder()
|
||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
||
}
|
||
|
||
func fetchMyProfile(completion: @escaping (Result<ProfileDataPayload, Error>) -> Void) {
|
||
client.request(
|
||
path: "/v1/profile/me",
|
||
method: .get,
|
||
requiresAuth: true
|
||
) { [decoder] result in
|
||
switch result {
|
||
case .success(let response):
|
||
do {
|
||
let apiResponse = try decoder.decode(APIResponse<ProfileDataPayload>.self, from: response.data)
|
||
guard apiResponse.status == "fine" else {
|
||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile unexpected status")
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
return
|
||
}
|
||
completion(.success(apiResponse.data))
|
||
} catch {
|
||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||
if AppConfig.DEBUG {
|
||
print("[ProfileService] decode profile failed: \(debugMessage)")
|
||
}
|
||
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
|
||
}
|
||
case .failure(let error):
|
||
if case let NetworkError.server(_, data) = error,
|
||
let data,
|
||
let message = Self.errorMessage(from: data) {
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
return
|
||
}
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
func fetchMyProfile() async throws -> ProfileDataPayload {
|
||
try await withCheckedThrowingContinuation { continuation in
|
||
fetchMyProfile { result in
|
||
continuation.resume(with: result)
|
||
}
|
||
}
|
||
}
|
||
|
||
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
|
||
let encoder = JSONEncoder()
|
||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||
|
||
guard let body = try? encoder.encode(payload) else {
|
||
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Profile update encoding error")
|
||
completion(.failure(ProfileServiceError.encoding(message)))
|
||
return
|
||
}
|
||
|
||
client.request(
|
||
path: "/v1/profile/edit",
|
||
method: .put,
|
||
body: body,
|
||
requiresAuth: true
|
||
) { result in
|
||
switch result {
|
||
case .success(let response):
|
||
do {
|
||
let decoder = JSONDecoder()
|
||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
||
guard apiResponse.status == "fine" else {
|
||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось сохранить изменения профиля.", comment: "Profile update unexpected status")
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
return
|
||
}
|
||
completion(.success(apiResponse.data.message))
|
||
} catch {
|
||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||
if AppConfig.DEBUG {
|
||
print("[ProfileService] decode update response failed: \(debugMessage)")
|
||
}
|
||
if AppConfig.DEBUG {
|
||
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
|
||
} else {
|
||
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Profile update decode error")
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
}
|
||
}
|
||
case .failure(let error):
|
||
if case let NetworkError.server(_, data) = error,
|
||
let data,
|
||
let message = Self.errorMessage(from: data) {
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
return
|
||
}
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
func updateProfile(_ payload: ProfileUpdateRequestPayload) async throws -> String {
|
||
try await withCheckedThrowingContinuation { continuation in
|
||
updateProfile(payload) { result in
|
||
continuation.resume(with: result)
|
||
}
|
||
}
|
||
}
|
||
|
||
func uploadAvatar(image: UIImage, completion: @escaping (Result<String, Error>) -> Void) {
|
||
guard let imageData = image.jpegData(compressionQuality: 0.9) else {
|
||
let message = NSLocalizedString("Не удалось подготовить изображение для загрузки.", comment: "Avatar encoding error")
|
||
completion(.failure(ProfileServiceError.encoding(message)))
|
||
return
|
||
}
|
||
|
||
let boundary = "Boundary-\(UUID().uuidString)"
|
||
let body = Self.makeMultipartBody(
|
||
data: imageData,
|
||
boundary: boundary,
|
||
fieldName: "file",
|
||
filename: "avatar.jpg",
|
||
mimeType: "image/jpeg"
|
||
)
|
||
|
||
client.request(
|
||
path: "/v1/storage/upload/avatar",
|
||
method: .post,
|
||
body: body,
|
||
contentType: "multipart/form-data; boundary=\(boundary)",
|
||
requiresAuth: true
|
||
) { result in
|
||
switch result {
|
||
case .success(let response):
|
||
do {
|
||
let decoder = JSONDecoder()
|
||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
let apiResponse = try decoder.decode(APIResponse<UploadAvatarPayload>.self, from: response.data)
|
||
guard apiResponse.status == "fine" else {
|
||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить аватар.", comment: "Avatar upload unexpected status")
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
return
|
||
}
|
||
completion(.success(apiResponse.data.fileId))
|
||
} catch {
|
||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||
if AppConfig.DEBUG {
|
||
print("[ProfileService] decode upload avatar failed: \(debugMessage)")
|
||
}
|
||
if AppConfig.DEBUG {
|
||
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
|
||
} else {
|
||
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Avatar upload decode error")
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
}
|
||
}
|
||
case .failure(let error):
|
||
if case let NetworkError.server(_, data) = error,
|
||
let data,
|
||
let message = Self.errorMessage(from: data) {
|
||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||
return
|
||
}
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
func uploadAvatar(image: UIImage) async throws -> String {
|
||
try await withCheckedThrowingContinuation { continuation in
|
||
uploadAvatar(image: image) { result in
|
||
continuation.resume(with: result)
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
||
let container = try decoder.singleValueContainer()
|
||
let string = try container.decode(String.self)
|
||
if let date = iso8601WithFractionalSeconds.date(from: string) {
|
||
return date
|
||
}
|
||
if let date = iso8601Simple.date(from: string) {
|
||
return date
|
||
}
|
||
throw DecodingError.dataCorruptedError(
|
||
in: container,
|
||
debugDescription: "Невозможно декодировать дату: \(string)"
|
||
)
|
||
}
|
||
|
||
private static func describeDecodingError(error: Error, data: Data) -> String {
|
||
var parts: [String] = []
|
||
|
||
if let decodingError = error as? DecodingError {
|
||
parts.append(decodingDescription(from: decodingError))
|
||
} else {
|
||
parts.append(error.localizedDescription)
|
||
}
|
||
|
||
if let payload = truncatedPayload(from: data) {
|
||
parts.append("payload=\(payload)")
|
||
}
|
||
|
||
return parts.joined(separator: "\n")
|
||
}
|
||
|
||
private static func decodingDescription(from error: DecodingError) -> String {
|
||
switch error {
|
||
case .typeMismatch(let type, let context):
|
||
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
||
case .valueNotFound(let type, let context):
|
||
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
||
case .keyNotFound(let key, let context):
|
||
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
|
||
case .dataCorrupted(let context):
|
||
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
|
||
@unknown default:
|
||
return error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private static func codingPath(from context: DecodingError.Context) -> String {
|
||
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
|
||
return path.isEmpty ? "root" : path.joined(separator: ".")
|
||
}
|
||
|
||
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
|
||
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!string.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
if string.count <= limit {
|
||
return string
|
||
}
|
||
|
||
let index = string.index(string.startIndex, offsetBy: limit)
|
||
return String(string[string.startIndex..<index]) + "…"
|
||
}
|
||
|
||
private static func makeMultipartBody(
|
||
data: Data,
|
||
boundary: String,
|
||
fieldName: String,
|
||
filename: String,
|
||
mimeType: String
|
||
) -> Data {
|
||
var body = Data()
|
||
let lineBreak = "\r\n"
|
||
if let boundaryData = "--\(boundary)\(lineBreak)".data(using: .utf8) {
|
||
body.append(boundaryData)
|
||
}
|
||
if let dispositionData = "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8) {
|
||
body.append(dispositionData)
|
||
}
|
||
if let typeData = "Content-Type: \(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8) {
|
||
body.append(typeData)
|
||
}
|
||
body.append(data)
|
||
if let closingData = "\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8) {
|
||
body.append(closingData)
|
||
}
|
||
return body
|
||
}
|
||
|
||
private static func errorMessage(from data: Data) -> String? {
|
||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||
if let detail = apiError.detail, !detail.isEmpty {
|
||
return detail
|
||
}
|
||
if let message = apiError.data?.message, !message.isEmpty {
|
||
return message
|
||
}
|
||
}
|
||
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
|
||
return string
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
return formatter
|
||
}()
|
||
|
||
private static let iso8601Simple: ISO8601DateFormatter = {
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime]
|
||
return formatter
|
||
}()
|
||
}
|