feat: Enhance User model with roles and improved decoding, and add an API debugger to the profile view.

This commit is contained in:
Ho Ngoc Hai
2026-01-16 18:44:18 +07:00
parent 420d98e140
commit 070d1e94b3
13 changed files with 389 additions and 61 deletions

View File

@@ -72,3 +72,56 @@ struct ListResponse<T: Decodable>: Decodable {
let error: String?
let pagination: Pagination?
}
// MARK: - APIResponse Extensions
// Extensions cho APIResponse
extension APIResponse {
/// Unwrap data or throw error
/// Unwrap data hoc throw error
/// - Returns: Unwrapped data / D liu đã unwrap
/// - Throws: APIError if not successful / APIError nếu không thành công
func unwrap() throws -> T {
guard success else {
throw APIError.serverError(
statusCode: 400,
message: error ?? "API request failed"
)
}
guard let data = data else {
throw APIError.noData
}
return data
}
/// Check if response is successful with data
/// Kim tra response có thành công và có data không
var isSuccessful: Bool {
success && data != nil
}
}
extension ListResponse {
/// Unwrap data or throw error
/// Unwrap data hoc throw error
/// - Returns: Unwrapped data array / Mng d liu đã unwrap
/// - Throws: APIError if not successful / APIError nếu không thành công
func unwrap() throws -> [T] {
guard success else {
throw APIError.serverError(
statusCode: 400,
message: error ?? "API request failed"
)
}
return data ?? []
}
/// Check if response is successful
/// Kim tra response có thành công không
var isSuccessful: Bool {
success
}
}

View File

@@ -214,23 +214,58 @@ struct APITestView: View {
decoder.dateDecodingStrategy = .iso8601
do {
let user = try decoder.decode(User.self, from: data)
testResult += """
✅ Decoded User Successfully:
ID: \(user.id)
Email: \(user.email)
Name: \(user.name)
Avatar: \(user.avatarUrl ?? "nil")
Phone: \(user.phoneNumber ?? "nil")
Email Verified: \(user.isEmailVerified)
"""
} catch let decodingError {
testResult += """
❌ Decoding Failed:
\(decodingError.localizedDescription)
// First try to decode with wrapper
// Th decode vi wrapper trưc
let response = try decoder.decode(APIResponse<User>.self, from: data)
if response.success, let user = response.data {
testResult += """
✅ Decoded User Successfully (with wrapper):
Success: \(response.success)
ID: \(user.id)
Email: \(user.email)
Name: \(user.name)
Avatar: \(user.avatarUrl ?? "nil")
Phone: \(user.phoneNumber ?? "nil")
Email Verified: \(user.isEmailVerified)
Roles: \(user.roles?.joined(separator: ", ") ?? "none")
"""
} else {
testResult += """
⚠️ Response decoded but no user data:
Success: \(response.success)
Error: \(response.error ?? "nil")
"""
}
} catch let wrapperError {
testResult += """
❌ Decoding with wrapper failed, trying direct decode...
Wrapper Error: \(wrapperError.localizedDescription)
💡 Check Raw JSON below để xem field nào bị thiếu hoặc sai tên
"""
// Try direct decode without wrapper
// Th decode trc tiếp không có wrapper
do {
let user = try decoder.decode(User.self, from: data)
testResult += """
✅ Decoded User Successfully (direct, no wrapper):
ID: \(user.id)
Email: \(user.email)
Name: \(user.name)
Avatar: \(user.avatarUrl ?? "nil")
Phone: \(user.phoneNumber ?? "nil")
Email Verified: \(user.isEmailVerified)
Roles: \(user.roles?.joined(separator: ", ") ?? "none")
"""
} catch let directError {
testResult += """
❌ Both decoding methods failed:
Direct decode error: \(directError.localizedDescription)
💡 Check Raw JSON below để xem field nào bị thiếu hoặc sai tên
"""
}
}
} else if httpResponse.statusCode == 401 {
testResult += "❌ Status: UNAUTHORIZED (Token invalid or expired)\n"

View File

@@ -6,8 +6,19 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
901364A42F19F3040097E0A7 /* DebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901364A32F19F3040097E0A7 /* DebugLogger.swift */; };
901364A82F19F4830097E0A7 /* APITestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901364A72F19F4830097E0A7 /* APITestView.swift */; };
901364AA2F19F5060097E0A7 /* APIResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901364A92F19F5060097E0A7 /* APIResponse.swift */; };
901364AE2F19F5D00097E0A7 /* APIUsageExamples.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901364AD2F19F5D00097E0A7 /* APIUsageExamples.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
901363EA2F19DDEB0097E0A7 /* AppClientBaseSwift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppClientBaseSwift.app; sourceTree = BUILT_PRODUCTS_DIR; };
901364A32F19F3040097E0A7 /* DebugLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLogger.swift; sourceTree = "<group>"; };
901364A72F19F4830097E0A7 /* APITestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITestView.swift; sourceTree = "<group>"; };
901364A92F19F5060097E0A7 /* APIResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIResponse.swift; sourceTree = "<group>"; };
901364AD2F19F5D00097E0A7 /* APIUsageExamples.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIUsageExamples.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -34,6 +45,10 @@
children = (
901363EC2F19DDEB0097E0A7 /* AppClientBaseSwift */,
901363EB2F19DDEB0097E0A7 /* Products */,
901364A32F19F3040097E0A7 /* DebugLogger.swift */,
901364A72F19F4830097E0A7 /* APITestView.swift */,
901364A92F19F5060097E0A7 /* APIResponse.swift */,
901364AD2F19F5D00097E0A7 /* APIUsageExamples.swift */,
);
sourceTree = "<group>";
};
@@ -120,6 +135,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
901364A42F19F3040097E0A7 /* DebugLogger.swift in Sources */,
901364A82F19F4830097E0A7 /* APITestView.swift in Sources */,
901364AA2F19F5060097E0A7 /* APIResponse.swift in Sources */,
901364AE2F19F5D00097E0A7 /* APIUsageExamples.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -232,21 +232,6 @@ extension String {
return first + rest
}
/// Pretty print JSON string
/// In đp JSON string
var prettyPrintedJSON: String? {
guard let data = self.data(using: .utf8),
let jsonObject = try? JSONSerialization.jsonObject(with: data),
let prettyData = try? JSONSerialization.data(
withJSONObject: jsonObject,
options: .prettyPrinted
),
let prettyString = String(data: prettyData, encoding: .utf8) else {
return nil
}
return prettyString
}
}
// MARK: - Example Usage

View File

@@ -22,8 +22,8 @@ enum APIConfig {
/// Tin t phiên bn API
static let apiVersion = "/api/v1"
/// OAuth2 token endpoint (no version prefix)
/// OAuth2 token endpoint (không có version prefix)
/// OAuth2 token endpoint (no version prefix, routed through Traefik)
/// OAuth2 token endpoint (không có version prefix, route qua Traefik)
static let tokenEndpoint = "/connect/token"
/// OAuth2 client ID for password grant

View File

@@ -46,6 +46,10 @@ struct User: Codable, Identifiable, Equatable {
/// Last update date
/// Ngày cp nht cui
let updatedAt: Date?
/// User roles
/// Các vai trò ca ngưi dùng
let roles: [String]?
// MARK: - CodingKeys
@@ -60,6 +64,7 @@ struct User: Codable, Identifiable, Equatable {
case isEmailVerified = "emailConfirmed"
case createdAt
case updatedAt
case roles
}
// MARK: - Custom Decoding
@@ -69,8 +74,25 @@ struct User: Codable, Identifiable, Equatable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
email = try container.decode(String.self, forKey: .email)
// Decode required fields with better error handling
// Gii mã các field bt buc vi x lý li tt hơn
guard let userId = try? container.decode(String.self, forKey: .id) else {
throw DecodingError.dataCorruptedError(
forKey: .id,
in: container,
debugDescription: "Missing or invalid 'id' field"
)
}
id = userId
guard let userEmail = try? container.decode(String.self, forKey: .email) else {
throw DecodingError.dataCorruptedError(
forKey: .email,
in: container,
debugDescription: "Missing or invalid 'email' field"
)
}
email = userEmail
// Handle name from either "name" field or "firstName" + "lastName"
// X lý name t field "name" hoc "firstName" + "lastName"
@@ -79,14 +101,25 @@ struct User: Codable, Identifiable, Equatable {
} else {
let firstName = try container.decodeIfPresent(String.self, forKey: .firstName) ?? ""
let lastName = try container.decodeIfPresent(String.self, forKey: .lastName) ?? ""
name = "\(firstName) \(lastName)".trimmingCharacters(in: .whitespaces)
let combinedName = "\(firstName) \(lastName)".trimmingCharacters(in: .whitespaces)
// If both firstName and lastName are empty, use email as fallback
// Nếu c firstName và lastName đu rng, dùng email làm d phòng
name = combinedName.isEmpty ? email.components(separatedBy: "@").first ?? email : combinedName
}
avatarUrl = try container.decodeIfPresent(String.self, forKey: .avatarUrl)
phoneNumber = try container.decodeIfPresent(String.self, forKey: .phoneNumber)
isEmailVerified = try container.decodeIfPresent(Bool.self, forKey: .isEmailVerified) ?? false
// Handle dates with multiple formats
// X lý dates vi nhiu format
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
// Decode roles array
// Gii mã mng roles
roles = try container.decodeIfPresent([String].self, forKey: .roles)
}
// MARK: - Custom Encoding
@@ -103,6 +136,7 @@ struct User: Codable, Identifiable, Equatable {
try container.encode(isEmailVerified, forKey: .isEmailVerified)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(roles, forKey: .roles)
}
// MARK: - Standard Init
@@ -117,7 +151,8 @@ struct User: Codable, Identifiable, Equatable {
phoneNumber: String? = nil,
isEmailVerified: Bool = false,
createdAt: Date? = nil,
updatedAt: Date? = nil
updatedAt: Date? = nil,
roles: [String]? = nil
) {
self.id = id
self.email = email
@@ -127,6 +162,7 @@ struct User: Codable, Identifiable, Equatable {
self.isEmailVerified = isEmailVerified
self.createdAt = createdAt
self.updatedAt = updatedAt
self.roles = roles
}
}
@@ -161,6 +197,18 @@ extension User {
var maskedPhone: String? {
phoneNumber?.maskedPhone
}
/// Check if user has specific role
/// Kim tra user có role c th không
func hasRole(_ role: String) -> Bool {
roles?.contains(role) ?? false
}
/// Check if user is admin
/// Kim tra user có phi admin không
var isAdmin: Bool {
hasRole("admin") || hasRole("Admin") || hasRole("Administrator")
}
}
// MARK: - Mock Data

View File

@@ -56,32 +56,23 @@ enum APIError: Error, LocalizedError {
/// OAuth2 token response
/// Response token OAuth2
/// Note: No CodingKeys needed - decoder uses convertFromSnakeCase strategy
/// Lưu ý: Không cn CodingKeys - decoder dùng convertFromSnakeCase t đng
struct OAuthTokenResponse: Decodable {
let accessToken: String
let tokenType: String
let expiresIn: Int
let refreshToken: String?
let scope: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case scope
}
}
/// OAuth2 error response
/// Response li OAuth2
/// Note: No CodingKeys needed - decoder uses convertFromSnakeCase strategy
/// Lưu ý: Không cn CodingKeys - decoder dùng convertFromSnakeCase t đng
struct OAuthErrorResponse: Decodable {
let error: String
let errorDescription: String?
enum CodingKeys: String, CodingKey {
case error
case errorDescription = "error_description"
}
}
// MARK: - HTTP Method
@@ -205,6 +196,17 @@ final class APIService: APIServiceProtocol {
request.httpBody = try encoder.encode(body)
}
// Log request for debugging
// Ghi log request đ debug
#if DEBUG
DebugLogger.logRequest(
method: method.rawValue,
url: url.absoluteString,
headers: request.allHTTPHeaderFields,
body: request.httpBody
)
#endif
// Perform request
// Thc hin request
let (data, response) = try await session.data(for: request)
@@ -215,13 +217,46 @@ final class APIService: APIServiceProtocol {
throw APIError.unknown
}
// Log response for debugging
// Ghi log response đ debug
#if DEBUG
DebugLogger.logResponse(statusCode: httpResponse.statusCode, data: data)
#endif
// Check status code
// Kim tra status code
switch httpResponse.statusCode {
case 200...299:
// Try to decode the response
// Th decode response
do {
return try decoder.decode(T.self, from: data)
let result = try decoder.decode(T.self, from: data)
#if DEBUG
print("✅ Decoded successfully: \(T.self)")
#endif
return result
} catch let decodingError as DecodingError {
// Enhanced decoding error details
// Chi tiết li decoding đưc ci thin
let errorDetails = formatDecodingError(decodingError, data: data)
#if DEBUG
print("❌ Decoding Error Details:")
print(errorDetails)
print("\n💡 Debugging Tips:")
print("1. Check if server returns wrapper format: { success, data, error }")
print("2. Verify all field names match between API and model")
print("3. Use APITestView to see raw JSON response")
print("4. Check if snake_case/camelCase conversion is working")
#endif
throw APIError.decodingError(decodingError)
} catch {
#if DEBUG
print("❌ Unknown decoding error: \(error)")
#endif
throw APIError.decodingError(error)
}
case 401:
@@ -235,6 +270,9 @@ final class APIService: APIServiceProtocol {
throw APIError.rateLimited
default:
let message = String(data: data, encoding: .utf8)
#if DEBUG
print("❌ Server Error (\(httpResponse.statusCode)): \(message ?? "No message")")
#endif
throw APIError.serverError(statusCode: httpResponse.statusCode, message: message)
}
}
@@ -269,6 +307,59 @@ final class APIService: APIServiceProtocol {
// MARK: - OAuth2 Methods
// Các phương thc OAuth2
/// Format decoding error for debugging
/// Format li decoding cho debugging
private func formatDecodingError(_ error: DecodingError, data: Data) -> String {
var details = ""
switch error {
case .typeMismatch(let type, let context):
details = """
🔴 Type Mismatch:
- Expected type: \(type)
- Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: " -> "))
- Debug description: \(context.debugDescription)
"""
case .valueNotFound(let type, let context):
details = """
🔴 Value Not Found:
- Missing type: \(type)
- Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: " -> "))
- Debug description: \(context.debugDescription)
"""
case .keyNotFound(let key, let context):
details = """
🔴 Key Not Found:
- Missing key: \(key.stringValue)
- Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: " -> "))
- Debug description: \(context.debugDescription)
"""
case .dataCorrupted(let context):
details = """
🔴 Data Corrupted:
- Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: " -> "))
- Debug description: \(context.debugDescription)
"""
@unknown default:
details = "🔴 Unknown decoding error: \(error)"
}
// Add raw JSON response for debugging
// Thêm raw JSON response cho debugging
if let jsonString = String(data: data, encoding: .utf8) {
details += "\n\n📄 Raw JSON Response:\n\(jsonString)"
}
return details
}
// MARK: - OAuth2 Methods
// Các phương thc OAuth2
/// POST form-urlencoded request (for OAuth2 token endpoint)
/// Request POST dng form-urlencoded (cho OAuth2 token endpoint)
/// - Parameters:

View File

@@ -160,11 +160,6 @@ final class AuthManager: ObservableObject {
let password: String
}
struct RegisterResponse: Decodable {
let success: Bool
let data: RegisterData?
}
struct RegisterData: Decodable {
let userId: String
let email: String
@@ -176,10 +171,17 @@ final class AuthManager: ObservableObject {
email: email,
password: password
)
let _: RegisterResponse = try await APIService.shared.post(
// Use APIResponse wrapper for register
// Dùng APIResponse wrapper cho đăng ký
let response: APIResponse<RegisterData> = try await APIService.shared.post(
endpoint: "/auth/register",
body: request
)
// Unwrap response or throw error
// Unwrap response hoc throw error
_ = try response.unwrap()
// Auto login after successful registration
// T đng đăng nhp sau khi đăng ký thành công
@@ -218,7 +220,13 @@ final class AuthManager: ObservableObject {
/// Ly thông tin user hin ti t API
@MainActor func fetchCurrentUser() async {
do {
let user: User = try await APIService.shared.get(endpoint: "/users/me")
// Fetch user with wrapper response
// Ly user vi wrapper response
let response: APIResponse<User> = try await APIService.shared.get(endpoint: "/users/me")
// Unwrap response data
// Unwrap d liu response
let user = try response.unwrap()
// Cache user data
// Cache d liu user
@@ -227,8 +235,22 @@ final class AuthManager: ObservableObject {
}
authState = .authenticated(user)
print("✅ User fetched successfully: \(user.email)")
} catch let error as APIError {
// Enhanced error logging
// Ghi log li chi tiết hơn
print("❌ Failed to fetch user: \(error.localizedDescription)")
if case .decodingError(let decodingError) = error {
print("💡 Suggestion: Check if the API response matches the APIResponse<User> structure")
print(" Expected wrapper: { success: Bool, data: User, error: String?, pagination: Pagination? }")
print(" Decoding error: \(decodingError)")
}
authState = .unauthenticated
} catch {
print("Failed to fetch user: \(error)")
print("Failed to fetch user: \(error.localizedDescription)")
authState = .unauthenticated
}
}

View File

@@ -157,6 +157,25 @@ final class AuthViewModel: ObservableObject {
do {
try await AuthManager.shared.login(email: loginEmail, password: loginPassword)
} catch let error as APIError {
// More detailed error messages
// Thông báo li chi tiết hơn
switch error {
case .decodingError:
errorMessage = "Đăng nhập thất bại: Lỗi dữ liệu từ server. Vui lòng liên hệ hỗ trợ."
case .unauthorized:
errorMessage = "Đăng nhập thất bại: Email hoặc mật khẩu không đúng"
case .networkError:
errorMessage = "Đăng nhập thất bại: Không thể kết nối đến server"
case .serverError(let statusCode, let message):
if statusCode == 400 {
errorMessage = "Đăng nhập thất bại: Email hoặc mật khẩu không đúng"
} else {
errorMessage = "Đăng nhập thất bại: Lỗi server (\(statusCode))"
}
default:
errorMessage = "Đăng nhập thất bại: \(error.localizedDescription)"
}
} catch {
errorMessage = "Đăng nhập thất bại: \(error.localizedDescription)"
}

View File

@@ -35,6 +35,7 @@ enum ProfileMenuAction: String {
case language
case help
case about
case apiDebugger // Debug only
case logout
}
@@ -63,6 +64,10 @@ final class ProfileViewModel: ObservableObject {
/// Show logout confirmation alert
/// Hin th alert xác nhn đăng xut
@Published var showLogoutAlert: Bool = false
/// Show API debugger sheet
/// Hin th sheet API debugger
@Published var showAPIDebugger: Bool = false
/// Menu items for profile screen
/// Các item menu cho màn hình profile
@@ -147,6 +152,11 @@ final class ProfileViewModel: ObservableObject {
// Navigate to about page
// Điu hưng đến trang gii thiu
print("Navigate to about")
case .apiDebugger:
// Show API debugger sheet
// Hin th sheet API debugger
showAPIDebugger = true
case .logout:
// Show logout confirmation
@@ -209,13 +219,32 @@ final class ProfileViewModel: ObservableObject {
icon: "info.circle",
action: .about
),
]
// Add API Debugger in DEBUG mode only
// Ch thêm API Debugger trong chế đ DEBUG
#if DEBUG
menuItems.append(
ProfileMenuItem(
id: "api_debugger",
title: "🔧 API Debugger",
subtitle: "Debug only",
icon: "wrench.and.screwdriver",
action: .apiDebugger
)
)
#endif
// Add logout at the end
// Thêm logout cui
menuItems.append(
ProfileMenuItem(
id: "logout",
title: "profile_logout".localized,
subtitle: nil,
icon: "rectangle.portrait.and.arrow.right",
action: .logout
),
]
)
)
}
}

View File

@@ -39,6 +39,9 @@ struct ProfileView: View {
.refreshable {
await viewModel.refreshUser()
}
.sheet(isPresented: $viewModel.showAPIDebugger) {
APITestView()
}
.alert("profile_logout_title".localized, isPresented: $viewModel.showLogoutAlert) {
Button("common_cancel".localized, role: .cancel) {}
Button("profile_logout".localized, role: .destructive) {
@@ -157,7 +160,11 @@ struct ProfileView: View {
// Icon
Image(systemName: item.icon)
.font(.body)
.foregroundStyle(item.action == .logout ? .red : .accentColor)
.foregroundStyle(
item.action == .logout ? .red :
item.action == .apiDebugger ? .orange :
.accentColor
)
.frame(width: 24)
// Title

20
note.md
View File

@@ -23,6 +23,26 @@ curl -s -X POST "http://localhost/connect/token" \
-d "password=Velik@2026" \
-d "scope=openid profile email api offline_access" 2>&1 | jq .
curl -X 'GET' \
'http://localhost:5001/api/v1/users/me' \
-H 'accept: text/plain' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkY0NzI5RUQ2MDc2NzgwMjdBNEIzNUMxMDNFMzJBRERCIiwidHlwIjoiYXQrand0In0.eyJpc3MiOiJodHRwOi8vaWFtLXNlcnZpY2UiLCJuYmYiOjE3Njg1MzczOTYsImlhdCI6MTc2ODUzNzM5NiwiZXhwIjoxNzY4NTM4Mjk2LCJhdWQiOlsiaWFtLWFwaSIsImh0dHA6Ly9pYW0tc2VydmljZS9yZXNvdXJjZXMiXSwic2NvcGUiOlsiYXBpIiwiZW1haWwiLCJvcGVuaWQiLCJwcm9maWxlIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbInB3ZCJdLCJjbGllbnRfaWQiOiJwYXNzd29yZC1jbGllbnQiLCJzdWIiOiIyYTFjOTUzNC02YTU5LTQ5NGEtOGFlMy1kNWNjMTU4NDg2ODYiLCJhdXRoX3RpbWUiOjE3Njg1MzczOTUsImlkcCI6ImxvY2FsIiwiZW1haWwiOiJob25nb2NoYWkxMEBpY2xvdWQuY29tIiwibmFtZSI6ImhvbmdvY2hhaTEwQGljbG91ZC5jb20iLCJqdGkiOiJCQkMxRkY1MzY5QjlENkI1QTFBQzZFMzY1NENFQzcyMCJ9.BcGgy8EayZ3P4NCCtAkoyRcSnzXsLyQVn6RcGKFS5_rqPp7_jP1BSZ7OtKAHK-RQopTa4jJxP5wUoYh41n6nAmugki58w8UtjxU5v-IoKzPc4jWsEUt5yEKMP9TunqakQlYSPHfzCiLutVYCZVPqpvmYUoK4j1nGObbdC8NzIoJiHTlQthmaVPd8Loe3aan783P_ed0TzownB5uJ2d_EUBAa47VTW3NtcniZYem4U797hY7lgLGwcuJJ5ybFyisWnmu-7MHz-QiVUozfWaK1NQTJqKsytDbmHjCIFdsu2b7UBHQIdw2wIrXNktoHWT_5B590oXBwbYPTeDKCuJRTtg'
{
"success": true,
"data": {
"id": "2a1c9534-6a59-494a-8ae3-d5cc15848686",
"email": "hongochai10@icloud.com",
"name": "hongochai10@icloud.com",
"roles": []
},
"error": null,
"pagination": null
}
1. Kiểm tra hỗ trợ cho MSSQL, PSQL, MongoDB