This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,531 @@
import Foundation
enum ApiServiceError: Error, LocalizedError {
case sessionExpired(String?)
var errorDescription: String? {
switch self {
case .sessionExpired(let message):
return message ?? "Sessao expirada. Faca login novamente."
}
}
}
struct ApiEnvelope<T: Decodable & Sendable>: Decodable, Sendable {
let error: Bool
let code: String?
let message: String?
let result: T?
}
final class ApiService {
private let client: ApiClient
private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:"
private let favoritesCachePrefix = "api:favorites:"
private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client
self.tokenStore = tokenStore
}
private func send<T: Decodable & Sendable>(_ req: ApiRequest) async throws -> T {
do {
return try await client.send(req)
} catch let error as NetworkError {
if case .unauthorized(let message) = error {
expireSession(message)
throw ApiServiceError.sessionExpired(message)
}
throw error
}
}
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req)
if isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message)
}
return envelope
}
private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> Bool {
guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased()
let message = (envelope.message ?? "").lowercased()
if code.contains("auth") || code.contains("token") || code.contains("unauthorized") {
return true
}
if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) {
return true
}
return false
}
private func expireSession(_ message: String?) {
tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message)
}
private func invalidateFavoritesCache() {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt }
return String(jwt.prefix(16))
}
// MARK: - Auth
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
var payload: [String: String] = [
"name": name,
"email": email,
"phoneNumber": phoneNumber
]
if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate
}
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await sendEnvelope(req)
if let token = response.result?.token {
tokenStore.jwt = token
}
return response
}
private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data {
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else {
throw NetworkError.httpError(400, "Email e telefone são obrigatórios.")
}
var payload: [String: String] = [
"email": sanitizedEmail,
"phoneNumber": sanitizedPhone,
"phone": sanitizedPhone
]
if let otp, otp.isEmpty == false {
payload["otp"] = otp
}
guard JSONSerialization.isValidJSONObject(payload) else {
throw NetworkError.invalidResponse
}
return try JSONSerialization.data(withJSONObject: payload, options: [])
}
func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope<CustomerProfile> {
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let payload = CustomerIdentityUpdatePayload(
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture
)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ProfilePatchEnvelope = try await send(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let currentAddressBook = customer.addressBook ?? []
var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
let addressPayload = CustomerAddressPayload(from: address)
if let replacingAddressId,
let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) {
addressBook[replaceIndex] = addressPayload
} else {
addressBook.insert(addressPayload, at: 0)
}
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook)
}
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
var currentAddressBook = customer.addressBook ?? []
if let targetId = address.id, targetId.isEmpty == false {
if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) {
currentAddressBook.remove(at: index)
}
} else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) {
currentAddressBook.remove(at: index)
}
let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
}
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8))
let formatted: String
if normalized.count == 8 {
let prefix = String(normalized.prefix(5))
let suffix = String(normalized.dropFirst(5))
formatted = "\(prefix)-\(suffix)"
} else {
formatted = normalized
}
let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true)
return try await sendEnvelope(req)
}
private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope<CustomerProfile> {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else {
invalidateFavoritesCache()
}
return envelope
}
func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> {
let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
if isFavorite {
return try await addStoreToFavorites(storeId: storeId)
}
return try await removeStoreFromFavorites(storeId: storeId)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label &&
lhs.address == rhs.address &&
lhs.number == rhs.number &&
lhs.complement == rhs.complement &&
lhs.neighborhood == rhs.neighborhood &&
lhs.city == rhs.city &&
lhs.state == rhs.state &&
lhs.zipCode == rhs.zipCode
}
// MARK: - Stores
func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> {
if forceRefresh == false,
let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) {
return cached
}
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
var items: [URLQueryItem] = []
if let lat, let lng {
items.append(URLQueryItem(name: "lat", value: String(lat)))
items.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let category {
items.append(URLQueryItem(name: "category", value: category))
}
if let search {
items.append(URLQueryItem(name: "search", value: search))
}
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
return try await sendEnvelope(req)
}
func storeInfo(storeId: String) async throws -> ApiEnvelope<StoreInfoResult> {
let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> {
let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CreateOrderResult> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
}
return envelope
}
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> {
let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) {
return cached
}
let req = ApiRequest(
path: "/api/app/orders",
method: "GET",
module: .app,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
let req = ApiRequest(
path: "/api/public/orders/\(orderId)",
method: "GET",
module: .none,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(
path: "/api/public/orders/\(orderId)/review",
method: "POST",
module: .none,
requiresAuth: true,
body: body
)
return try await sendEnvelope(req)
}
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
let req = ApiRequest(
path: "/api/public/reviews/tags",
method: "GET",
module: .none,
requiresAuth: false
)
return try await sendEnvelope(req)
}
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
let req = ApiRequest(
path: "/api/public/store/\(storeId)/reviews",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
// MARK: - Cards
func listCards() async throws -> ApiEnvelope<[SavedCard]> {
let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func deleteCard(cardId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
// MARK: - Profile CPF
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {
let payload = ["cpf": cpf]
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
}
return result
}
}
extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false {
return trimmed
}
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return String(asInt)
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
if asDouble.rounded() == asDouble {
return String(Int(asDouble))
}
return String(asDouble)
}
}
return nil
}
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys {
if let value = try? container.decode(Double.self, forKey: key) {
return value
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return Double(asInt)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let parsed = Double(normalized) {
return parsed
}
}
}
return nil
}
static func decodeFlexibleInt<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
return Int(asDouble)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: "")
if let parsed = Int(normalized) {
return parsed
}
}
}
return nil
}
}