login flow
This commit is contained in:
@@ -39,6 +39,11 @@ enum NetworkError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ApiErrorDescriptor {
|
||||
let code: String?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
struct ApiRequest: Sendable {
|
||||
let path: String
|
||||
let method: String
|
||||
@@ -166,6 +171,10 @@ private extension ApiClient {
|
||||
|
||||
let nsError = error as NSError
|
||||
let apiMessage = serverMessage(from: nsError)
|
||||
let payload = serverPayload(from: nsError)
|
||||
if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) {
|
||||
return .unauthorized(payload?.message ?? apiMessage)
|
||||
}
|
||||
|
||||
switch nsError.code {
|
||||
case 401, 403:
|
||||
@@ -228,6 +237,11 @@ private extension ApiClient {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = serverPayload(from: data)
|
||||
if isSessionExpiredPayload(code: payload?.code, message: payload?.message) {
|
||||
throw NetworkError.unauthorized(payload?.message)
|
||||
}
|
||||
|
||||
if http.statusCode == 429 {
|
||||
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
|
||||
throw NetworkError.rateLimited(retryAfter)
|
||||
@@ -283,19 +297,44 @@ private extension ApiClient {
|
||||
}
|
||||
|
||||
func serverMessage(from data: Data) -> String? {
|
||||
let payload = serverPayload(from: data)
|
||||
if let message = payload?.message, message.isEmpty == false {
|
||||
return message
|
||||
}
|
||||
if let code = payload?.code, code.isEmpty == false {
|
||||
return "Erro: \(code)"
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
|
||||
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
|
||||
return envelope.message
|
||||
return ApiErrorDescriptor(code: envelope.code, message: envelope.message)
|
||||
}
|
||||
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let message = object["message"] as? String {
|
||||
return message
|
||||
}
|
||||
if let code = object["code"] as? String {
|
||||
return "Erro: \(code)"
|
||||
let code = object["code"] as? String
|
||||
let message = object["message"] as? String
|
||||
if code != nil || message != nil {
|
||||
return ApiErrorDescriptor(code: code, message: message)
|
||||
}
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSessionExpiredPayload(code: String?, message: String?) -> Bool {
|
||||
let normalizedCode = (code ?? "").lowercased()
|
||||
let normalizedMessage = (message ?? "").lowercased()
|
||||
|
||||
if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") {
|
||||
return true
|
||||
}
|
||||
if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
@@ -318,6 +357,15 @@ private extension ApiClient {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
|
||||
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
|
||||
let data = reason.data(using: .utf8),
|
||||
let payload = serverPayload(from: data) {
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
|
||||
|
||||
@@ -32,14 +32,40 @@ final class ApiService {
|
||||
return try await client.send(req)
|
||||
} catch let error as NetworkError {
|
||||
if case .unauthorized(let message) = error {
|
||||
tokenStore.clear()
|
||||
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
||||
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()
|
||||
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
|
||||
@@ -53,19 +79,19 @@ final class ApiService {
|
||||
}
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
|
||||
return try await send(req)
|
||||
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 send(req)
|
||||
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 send(req)
|
||||
let response: ApiEnvelope<LoginResult> = try await sendEnvelope(req)
|
||||
if let token = response.result?.token {
|
||||
tokenStore.jwt = token
|
||||
}
|
||||
@@ -97,7 +123,7 @@ final class ApiService {
|
||||
|
||||
func profile() async throws -> ApiEnvelope<CustomerProfile> {
|
||||
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
|
||||
return try await send(req)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
@@ -112,7 +138,7 @@ final class ApiService {
|
||||
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
return try await send(req)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
|
||||
@@ -128,16 +154,22 @@ final class ApiService {
|
||||
}
|
||||
|
||||
let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true)
|
||||
return try await send(req)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
// MARK: - Stores
|
||||
|
||||
func listStores(lat: Double, lng: Double, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
|
||||
var items = [
|
||||
URLQueryItem(name: "lat", value: String(lat)),
|
||||
URLQueryItem(name: "lng", value: String(lng))
|
||||
]
|
||||
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {
|
||||
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -145,7 +177,7 @@ final class ApiService {
|
||||
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 send(req)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +254,12 @@ struct StoreSummary: Decodable {
|
||||
let statusLabel: String?
|
||||
}
|
||||
|
||||
struct PublicCategory: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let icon: String?
|
||||
}
|
||||
|
||||
struct CustomerProfileUpdatePayload: Encodable {
|
||||
let addressBook: [CustomerAddressPayload]
|
||||
|
||||
|
||||
139
pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift
Normal file
139
pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
|
||||
private struct PersistedAddressState: Codable {
|
||||
let selectedId: String?
|
||||
let display: String
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
}
|
||||
|
||||
enum SessionStateStore {
|
||||
private static let legacyAddressKey = "session.address.state.v1"
|
||||
private static let addressKeyPrefix = "session.address.state.v2."
|
||||
private static let activeUserKey = "session.active.user.v1"
|
||||
|
||||
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||
let id = (profileId ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if id.isEmpty == false {
|
||||
return "id:\(id)"
|
||||
}
|
||||
|
||||
let mail = (email ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if mail.isEmpty == false {
|
||||
return "email:\(mail)"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func setActiveUserKey(_ userKey: String?) {
|
||||
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: activeUserKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(trimmed, forKey: activeUserKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadActiveUserKey() -> String? {
|
||||
let value = UserDefaults.standard.string(forKey: activeUserKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, value.isEmpty == false {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func addressStorageKey(for userKey: String?) -> String {
|
||||
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let safe = key.replacingOccurrences(of: " ", with: "_")
|
||||
return addressKeyPrefix + safe
|
||||
}
|
||||
|
||||
static func loadAddress() -> AddressState? {
|
||||
let defaults = UserDefaults.standard
|
||||
let activeKey = loadActiveUserKey()
|
||||
let scopedKey = addressStorageKey(for: activeKey)
|
||||
|
||||
if let data = defaults.data(forKey: scopedKey),
|
||||
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
|
||||
return AddressState(
|
||||
selectedId: decoded.selectedId,
|
||||
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
|
||||
latitude: decoded.latitude,
|
||||
longitude: decoded.longitude,
|
||||
onboardingMessage: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Backward-compatible fallback for data persisted before user scoping.
|
||||
let anonymousKey = addressStorageKey(for: "anonymous")
|
||||
if let data = defaults.data(forKey: anonymousKey),
|
||||
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
|
||||
let recovered = AddressState(
|
||||
selectedId: decoded.selectedId,
|
||||
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
|
||||
latitude: decoded.latitude,
|
||||
longitude: decoded.longitude,
|
||||
onboardingMessage: nil
|
||||
)
|
||||
|
||||
// Migrate anonymous cache into the current active user namespace.
|
||||
if let activeKey, activeKey.isEmpty == false {
|
||||
let payload = PersistedAddressState(
|
||||
selectedId: recovered.selectedId,
|
||||
display: recovered.display,
|
||||
latitude: recovered.latitude,
|
||||
longitude: recovered.longitude
|
||||
)
|
||||
if let migratedData = try? JSONEncoder().encode(payload) {
|
||||
defaults.set(migratedData, forKey: scopedKey)
|
||||
}
|
||||
}
|
||||
|
||||
return recovered
|
||||
}
|
||||
|
||||
guard let data = defaults.data(forKey: legacyAddressKey),
|
||||
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let migrated = AddressState(
|
||||
selectedId: decoded.selectedId,
|
||||
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
|
||||
latitude: decoded.latitude,
|
||||
longitude: decoded.longitude,
|
||||
onboardingMessage: nil
|
||||
)
|
||||
saveAddress(migrated)
|
||||
defaults.removeObject(forKey: legacyAddressKey)
|
||||
return migrated
|
||||
}
|
||||
|
||||
static func saveAddress(_ state: AddressState) {
|
||||
let payload = PersistedAddressState(
|
||||
selectedId: state.selectedId,
|
||||
display: state.display,
|
||||
latitude: state.latitude,
|
||||
longitude: state.longitude
|
||||
)
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func clearAddress() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: addressStorageKey(for: nil))
|
||||
defaults.removeObject(forKey: legacyAddressKey)
|
||||
}
|
||||
|
||||
static func clearActiveUser() {
|
||||
UserDefaults.standard.removeObject(forKey: activeUserKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user