diff --git a/apps/app-client-base-swift/AppClientBaseSwift/APIResponse.swift b/apps/app-client-base-swift/AppClientBaseSwift/APIResponse.swift new file mode 100644 index 00000000..3459d108 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/APIResponse.swift @@ -0,0 +1,74 @@ +// +// APIResponse.swift +// AppClientBaseSwift +// +// API response wrapper models +// Các model wrapper cho API response +// + +import Foundation + +// MARK: - API Response Wrapper +// Wrapper Response API + +/// Generic API response wrapper +/// Wrapper response API chung +struct APIResponse: Decodable { + /// Success status + /// Trạng thái thành công + let success: Bool + + /// Response data + /// Dữ liệu response + let data: T? + + /// Error message if any + /// Thông báo lỗi nếu có + let error: String? + + /// Pagination info if any + /// Thông tin phân trang nếu có + let pagination: Pagination? +} + +// MARK: - Pagination +// Phân trang + +/// Pagination information +/// Thông tin phân trang +struct Pagination: Decodable { + let currentPage: Int? + let pageSize: Int? + let totalPages: Int? + let totalItems: Int? + + enum CodingKeys: String, CodingKey { + case currentPage + case pageSize + case totalPages + case totalItems + } +} + +// MARK: - Simple Response +// Response đơn giản + +/// Simple success/error response +/// Response thành công/lỗi đơn giản +struct SimpleResponse: Decodable { + let success: Bool + let message: String? + let error: String? +} + +// MARK: - List Response +// Response danh sách + +/// Generic list response wrapper +/// Wrapper response danh sách +struct ListResponse: Decodable { + let success: Bool + let data: [T]? + let error: String? + let pagination: Pagination? +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/APITestView.swift b/apps/app-client-base-swift/AppClientBaseSwift/APITestView.swift new file mode 100644 index 00000000..eee3c2ae --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/APITestView.swift @@ -0,0 +1,306 @@ +// +// APITestView.swift +// AppClientBaseSwift +// +// Debug view for testing API responses +// View debug để test API responses +// + +import SwiftUI + +// MARK: - API Test View +// View test API + +/// Debug view to test API endpoints +/// View debug để test các API endpoints +struct APITestView: View { + + @State private var testResult: String = "Chưa có kết quả" + @State private var isLoading: Bool = false + @State private var rawJSON: String = "" + @State private var accessToken: String = "" + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 20) { + + // Access Token Input + VStack(alignment: .leading, spacing: 8) { + Text("Access Token") + .font(.headline) + + TextEditor(text: $accessToken) + .frame(height: 100) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .font(.system(.caption, design: .monospaced)) + + Text("Paste access token từ Keychain hoặc sau khi login thành công") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + + // Test Buttons + VStack(spacing: 12) { + Button("Test /users/me Endpoint") { + Task { + await testUsersMeEndpoint() + } + } + .buttonStyle(TestButtonStyle(color: .blue)) + + Button("Test với Mock Token") { + Task { + await testWithMockToken() + } + } + .buttonStyle(TestButtonStyle(color: .orange)) + + Button("Clear Results") { + testResult = "Đã xóa kết quả" + rawJSON = "" + } + .buttonStyle(TestButtonStyle(color: .red)) + } + .padding() + .disabled(isLoading) + + // Loading Indicator + if isLoading { + ProgressView("Đang test API...") + .padding() + } + + // Test Result + VStack(alignment: .leading, spacing: 8) { + Text("Kết quả:") + .font(.headline) + + ScrollView { + Text(testResult) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .frame(maxHeight: 200) + } + .padding() + + // Raw JSON + if !rawJSON.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Raw JSON Response:") + .font(.headline) + + ScrollView { + Text(rawJSON) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + } + .frame(maxHeight: 300) + + Button("Copy JSON") { + UIPasteboard.general.string = rawJSON + testResult = "✅ Đã copy JSON vào clipboard!" + } + .buttonStyle(TestButtonStyle(color: .green)) + } + .padding() + } + + // Instructions + VStack(alignment: .leading, spacing: 8) { + Text("Hướng dẫn:") + .font(.headline) + + VStack(alignment: .leading, spacing: 4) { + Text("1. Login thành công trước") + Text("2. Access token sẽ tự động được lấy từ Keychain") + Text("3. Nhấn 'Test /users/me Endpoint'") + Text("4. Xem Raw JSON để biết server trả về gì") + Text("5. So sánh với User model trong code") + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + .padding() + } + } + .navigationTitle("API Debugger") + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + // Load current access token + if let token = AuthManager.shared.accessToken { + accessToken = token + } + } + } + + // MARK: - Test Methods + + /// Test /users/me endpoint + private func testUsersMeEndpoint() async { + isLoading = true + testResult = "🔄 Đang gọi API..." + + do { + // Build URL + guard let url = URL(string: APIConfig.baseURL + APIConfig.apiVersion + "/users/me") else { + testResult = "❌ Invalid URL" + isLoading = false + return + } + + // Create request + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + // Use token from input or Keychain + let token = accessToken.isEmpty ? AuthManager.shared.accessToken : accessToken + if let token = token, !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + testResult = """ + 📤 Request Details: + URL: \(url.absoluteString) + Method: GET + Token: \(token?.prefix(20) ?? "None")... + + ⏳ Waiting for response... + """ + + // Perform request + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + testResult = "❌ Invalid response" + isLoading = false + return + } + + // Get raw JSON + if let jsonString = String(data: data, encoding: .utf8) { + rawJSON = jsonString.prettyPrintedJSON ?? jsonString + } + + // Parse result + testResult = """ + 📥 Response Details: + Status Code: \(httpResponse.statusCode) + + """ + + if httpResponse.statusCode == 200 { + testResult += "✅ Status: SUCCESS\n\n" + + // Try to decode as User + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + 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) + + 💡 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" + } else { + testResult += "❌ Status: ERROR\n" + if let errorMessage = String(data: data, encoding: .utf8) { + testResult += "Error: \(errorMessage)\n" + } + } + + } catch { + testResult = """ + ❌ Request Failed: + \(error.localizedDescription) + """ + rawJSON = "" + } + + isLoading = false + } + + /// Test with mock token + private func testWithMockToken() async { + testResult = """ + ⚠️ Testing với mock token... + + Lưu ý: Request này sẽ fail với 401 Unauthorized + Mục đích: Kiểm tra server response format khi unauthorized + """ + + accessToken = "mock_token_for_testing" + + await testUsersMeEndpoint() + } +} + +// MARK: - Test Button Style + +struct TestButtonStyle: ButtonStyle { + let color: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background( + color.opacity(configuration.isPressed ? 0.7 : 1.0) + ) + .cornerRadius(10) + } +} + +// MARK: - String Extension for Pretty JSON + +extension 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: - Preview + +#Preview { + APITestView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/APIUsageExamples.swift b/apps/app-client-base-swift/AppClientBaseSwift/APIUsageExamples.swift new file mode 100644 index 00000000..267cd2b8 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/APIUsageExamples.swift @@ -0,0 +1,404 @@ +// +// APIUsageExamples.swift +// AppClientBaseSwift +// +// Examples of using APIResponse wrapper +// Ví dụ sử dụng APIResponse wrapper +// + +import Foundation + +/* + + HƯỚNG DẪN SỬ DỤNG API RESPONSE WRAPPER + ====================================== + + Server của bạn trả về response với format: + { + "success": true, + "data": { ... } hoặc [ ... ], + "error": null, + "pagination": null + } + + Tất cả API calls phải sử dụng wrapper tương ứng. + + */ + +// MARK: - Example 1: Single Object Response +// Ví dụ 1: Response trả về 1 object + +/* + GET /api/v1/users/me + Response: + { + "success": true, + "data": { + "id": "...", + "email": "...", + "name": "..." + }, + "error": null, + "pagination": null + } + */ + +func exampleFetchUser() async throws -> User { + // Cách 1: Dùng unwrap() helper (recommended) + let response: APIResponse = try await APIService.shared.get( + endpoint: "/users/me" + ) + let user = try response.unwrap() + return user + + // Cách 2: Manual check + // let response: APIResponse = try await APIService.shared.get(endpoint: "/users/me") + // guard response.success, let user = response.data else { + // throw APIError.serverError(statusCode: 400, message: response.error ?? "Failed") + // } + // return user +} + +// MARK: - Example 2: List/Array Response +// Ví dụ 2: Response trả về danh sách + +/* + GET /api/v1/users?page=1 + Response: + { + "success": true, + "data": [ + { "id": "1", "email": "...", "name": "..." }, + { "id": "2", "email": "...", "name": "..." } + ], + "error": null, + "pagination": { + "currentPage": 1, + "pageSize": 20, + "totalPages": 5, + "totalItems": 100 + } + } + */ + +func exampleFetchUsers() async throws -> [User] { + // Dùng ListResponse wrapper + let response: ListResponse = try await APIService.shared.get( + endpoint: "/users?page=1" + ) + + // Unwrap để lấy array + let users = try response.unwrap() + + // Có thể access pagination info + if let pagination = response.pagination { + print("Page \(pagination.currentPage ?? 0) of \(pagination.totalPages ?? 0)") + print("Total items: \(pagination.totalItems ?? 0)") + } + + return users +} + +// MARK: - Example 3: POST Request with Response Data +// Ví dụ 3: POST request có response data + +/* + POST /api/v1/posts + Body: { "title": "...", "content": "..." } + Response: + { + "success": true, + "data": { + "id": "post_123", + "title": "...", + "content": "...", + "createdAt": "..." + }, + "error": null, + "pagination": null + } + */ + +struct Post: Codable { + let id: String + let title: String + let content: String + let createdAt: Date? +} + +struct CreatePostRequest: Encodable { + let title: String + let content: String +} + +func exampleCreatePost(title: String, content: String) async throws -> Post { + let request = CreatePostRequest(title: title, content: content) + + let response: APIResponse = try await APIService.shared.post( + endpoint: "/posts", + body: request + ) + + return try response.unwrap() +} + +// MARK: - Example 4: DELETE/UPDATE Requests +// Ví dụ 4: DELETE/UPDATE requests + +/* + DELETE /api/v1/posts/123 + Response: + { + "success": true, + "data": null, + "error": null, + "pagination": null + } + */ + +func exampleDeletePost(id: String) async throws { + // Dùng SimpleResponse khi không cần data + let response: SimpleResponse = try await APIService.shared.delete( + endpoint: "/posts/\(id)" + ) + + if !response.success { + throw APIError.serverError( + statusCode: 400, + message: response.error ?? response.message ?? "Delete failed" + ) + } +} + +// MARK: - Example 5: PUT/PATCH Request +// Ví dụ 5: PUT/PATCH request + +/* + PUT /api/v1/users/profile + Body: { "name": "New Name", "phoneNumber": "..." } + Response: + { + "success": true, + "data": { + "id": "...", + "email": "...", + "name": "New Name", + ... + }, + "error": null, + "pagination": null + } + */ + +struct UpdateProfileRequest: Encodable { + let name: String? + let phoneNumber: String? +} + +func exampleUpdateProfile(name: String?, phoneNumber: String?) async throws -> User { + let request = UpdateProfileRequest(name: name, phoneNumber: phoneNumber) + + let response: APIResponse = try await APIService.shared.put( + endpoint: "/users/profile", + body: request + ) + + return try response.unwrap() +} + +// MARK: - Example 6: Error Handling +// Ví dụ 6: Xử lý lỗi + +func exampleWithErrorHandling() async { + do { + let response: APIResponse = try await APIService.shared.get( + endpoint: "/users/me" + ) + let user = try response.unwrap() + print("Success: \(user.email)") + + } catch let error as APIError { + switch error { + case .decodingError(let decodingError): + print("❌ Decoding error: \(decodingError)") + // Server response structure doesn't match expected model + + case .unauthorized: + print("❌ Unauthorized - token invalid or expired") + // Need to login again + + case .networkError(let networkError): + print("❌ Network error: \(networkError)") + // No internet connection or server unreachable + + case .serverError(let statusCode, let message): + print("❌ Server error \(statusCode): \(message ?? "Unknown")") + // Server returned error response + + case .noData: + print("❌ No data received") + + default: + print("❌ Unknown error: \(error)") + } + + } catch { + print("❌ Unexpected error: \(error)") + } +} + +// MARK: - Example 7: Nested Objects +// Ví dụ 7: Objects lồng nhau + +/* + GET /api/v1/posts/123/comments + Response: + { + "success": true, + "data": [ + { + "id": "comment_1", + "content": "Great post!", + "author": { + "id": "user_1", + "name": "John Doe", + "avatarUrl": "..." + }, + "createdAt": "..." + } + ], + "error": null, + "pagination": { ... } + } + */ + +struct Comment: Codable { + let id: String + let content: String + let author: CommentAuthor + let createdAt: Date? +} + +struct CommentAuthor: Codable { + let id: String + let name: String + let avatarUrl: String? +} + +func exampleFetchComments(postId: String) async throws -> [Comment] { + let response: ListResponse = try await APIService.shared.get( + endpoint: "/posts/\(postId)/comments" + ) + return try response.unwrap() +} + +// MARK: - Example 8: Search/Filter with Query Parameters +// Ví dụ 8: Search/Filter với query parameters + +/* + GET /api/v1/posts?search=swift&category=tutorial&page=1&pageSize=20 + */ + +func exampleSearchPosts( + search: String?, + category: String?, + page: Int = 1, + pageSize: Int = 20 +) async throws -> [Post] { + var queryItems: [String] = [] + + if let search = search { + queryItems.append("search=\(search)") + } + if let category = category { + queryItems.append("category=\(category)") + } + queryItems.append("page=\(page)") + queryItems.append("pageSize=\(pageSize)") + + let queryString = queryItems.joined(separator: "&") + let endpoint = "/posts?\(queryString)" + + let response: ListResponse = try await APIService.shared.get( + endpoint: endpoint + ) + + return try response.unwrap() +} + +// MARK: - Example 9: Upload File (if needed) +// Ví dụ 9: Upload file (nếu cần) + +/* + POST /api/v1/users/avatar + Content-Type: multipart/form-data + + Note: Cần implement multipart form data nếu cần upload file + Hiện tại APIService chỉ hỗ trợ JSON + */ + +// MARK: - Example 10: Custom Headers +// Ví dụ 10: Custom headers + +func exampleWithCustomHeaders() async throws -> User { + // Nếu cần thêm headers đặc biệt + let response: APIResponse = try await APIService.shared.request( + endpoint: "/users/me", + method: .get, + body: nil as String?, + headers: [ + "X-Custom-Header": "custom-value", + "X-Request-ID": UUID().uuidString + ] + ) + + return try response.unwrap() +} + +// MARK: - Best Practices +// Các best practices + +/* + + ✅ DO - Nên làm: + + 1. Luôn dùng APIResponse hoặc ListResponse + 2. Dùng unwrap() helper để code ngắn gọn + 3. Handle errors properly với do-catch + 4. Log errors trong development + 5. Validate request data trước khi gọi API + 6. Cache data khi cần (như User data) + + ❌ DON'T - Không nên làm: + + 1. Không decode trực tiếp thành Model mà không wrapper + 2. Không ignore errors + 3. Không hardcode URLs, dùng Constants + 4. Không lưu sensitive data vào UserDefaults (dùng Keychain) + 5. Không block main thread với synchronous calls + + */ + +// MARK: - Testing Tips +// Tips test API + +/* + + Test APIs trong development: + + 1. Dùng APITestView để test từng endpoint + 2. Check Console logs với DebugLogger + 3. Verify JSON response structure + 4. Test error cases (401, 404, 500, etc.) + 5. Test với slow network / offline + + Mock data cho UI testing: + + #if DEBUG + func mockFetchUser() async -> User { + return User.sample + } + #endif + + */ + diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/APIDebugView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/APIDebugView.swift new file mode 100644 index 00000000..26fa8355 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/APIDebugView.swift @@ -0,0 +1,389 @@ +// +// APIDebugView.swift +// AppClientBaseSwift +// +// Comprehensive API debugging view +// View debug API toàn diện +// + +import SwiftUI + +// MARK: - API Debug View +// View Debug API + +struct APIDebugView: View { + @State private var endpoint: String = "/users/me" + @State private var method: HTTPMethod = .get + @State private var requestBody: String = "" + @State private var customHeaders: String = "" + @State private var useAccessToken: Bool = true + + @State private var responseStatus: String = "Chưa gửi request" + @State private var responseJSON: String = "" + @State private var decodingResult: String = "" + @State private var isLoading: Bool = false + + var body: some View { + NavigationView { + Form { + // Endpoint Section + Section(header: Text("Request Configuration")) { + TextField("Endpoint (vd: /users/me)", text: $endpoint) + .autocapitalization(.none) + + Picker("Method", selection: $method) { + Text("GET").tag(HTTPMethod.get) + Text("POST").tag(HTTPMethod.post) + Text("PUT").tag(HTTPMethod.put) + Text("DELETE").tag(HTTPMethod.delete) + } + + Toggle("Use Access Token", isOn: $useAccessToken) + } + + // Request Body Section + if method == .post || method == .put { + Section(header: Text("Request Body (JSON)")) { + TextEditor(text: $requestBody) + .frame(height: 150) + .font(.system(.body, design: .monospaced)) + + Button("Load Sample Body") { + requestBody = sampleRequestBody + } + .font(.caption) + } + } + + // Custom Headers Section + Section(header: Text("Custom Headers (optional)")) { + TextEditor(text: $customHeaders) + .frame(height: 100) + .font(.system(.caption, design: .monospaced)) + + Text("Format: Key: Value (mỗi header trên một dòng)") + .font(.caption2) + .foregroundColor(.secondary) + } + + // Action Button + Section { + Button(action: { + Task { + await sendRequest() + } + }) { + HStack { + if isLoading { + ProgressView() + } + Text(isLoading ? "Đang gửi..." : "Gửi Request") + .frame(maxWidth: .infinity) + } + } + .disabled(isLoading || endpoint.isEmpty) + + Button("Clear Results") { + clearResults() + } + .foregroundColor(.red) + } + + // Response Status Section + Section(header: Text("Response Status")) { + Text(responseStatus) + .font(.system(.body, design: .monospaced)) + .foregroundColor(responseStatusColor) + } + + // Raw JSON Section + if !responseJSON.isEmpty { + Section(header: HStack { + Text("Raw JSON Response") + Spacer() + Button("Copy") { + UIPasteboard.general.string = responseJSON + } + .font(.caption) + }) { + ScrollView { + Text(responseJSON) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(height: 200) + } + } + + // Decoding Result Section + if !decodingResult.isEmpty { + Section(header: Text("Decoding Analysis")) { + ScrollView { + Text(decodingResult) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(height: 200) + } + } + + // Quick Actions + Section(header: Text("Quick Actions")) { + Button("Test /users/me") { + endpoint = "/users/me" + method = .get + useAccessToken = true + } + + Button("Test /auth/register") { + endpoint = "/auth/register" + method = .post + useAccessToken = false + requestBody = """ + { + "firstName": "Test", + "lastName": "User", + "email": "test@example.com", + "password": "Test123!" + } + """ + } + } + + // Debugging Tips + Section(header: Text("Debugging Tips")) { + VStack(alignment: .leading, spacing: 8) { + tipText("1. Kiểm tra Raw JSON để xem server trả về gì") + tipText("2. Xem Decoding Analysis để biết field nào bị sai") + tipText("3. So sánh tên field trong JSON với model") + tipText("4. Kiểm tra có wrapper {success, data} không") + tipText("5. Verify snake_case/camelCase conversion") + } + .font(.caption) + } + } + .navigationTitle("API Debugger") + .navigationBarTitleDisplayMode(.inline) + } + } + + // MARK: - Helpers + + private var responseStatusColor: Color { + if responseStatus.contains("✅") || responseStatus.contains("200") { + return .green + } else if responseStatus.contains("❌") || responseStatus.contains("Error") { + return .red + } else { + return .primary + } + } + + private func tipText(_ text: String) -> some View { + HStack(alignment: .top, spacing: 4) { + Text("💡") + Text(text) + } + } + + private var sampleRequestBody: String { + """ + { + "name": "Sample Value", + "email": "test@example.com" + } + """ + } + + private func clearResults() { + responseStatus = "Đã xóa kết quả" + responseJSON = "" + decodingResult = "" + } + + // MARK: - Network Request + + private func sendRequest() async { + isLoading = true + responseStatus = "🔄 Đang gửi request..." + responseJSON = "" + decodingResult = "" + + do { + // Build URL + guard let url = URL(string: APIConfig.baseURL + APIConfig.apiVersion + endpoint) else { + responseStatus = "❌ Invalid URL" + isLoading = false + return + } + + // Create request + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.timeoutInterval = APIConfig.timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + // Add access token if needed + if useAccessToken, let token = AuthManager.shared.accessToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + // Parse and add custom headers + let headerLines = customHeaders.components(separatedBy: "\n") + for line in headerLines { + let parts = line.components(separatedBy: ":") + if parts.count == 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts[1].trimmingCharacters(in: .whitespaces) + request.setValue(value, forHTTPHeaderField: key) + } + } + + // Add request body + if !requestBody.isEmpty, let bodyData = requestBody.data(using: .utf8) { + request.httpBody = bodyData + } + + // Send request + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + responseStatus = "❌ Invalid response" + isLoading = false + return + } + + // Store raw JSON + if let jsonString = String(data: data, encoding: .utf8) { + responseJSON = jsonString.prettyPrintedJSON ?? jsonString + } + + // Update status + let statusEmoji = httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 ? "✅" : "❌" + responseStatus = """ + \(statusEmoji) Status Code: \(httpResponse.statusCode) + URL: \(url.absoluteString) + Method: \(method.rawValue) + """ + + // Analyze decoding + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + analyzeResponse(data) + } + + } catch { + responseStatus = "❌ Error: \(error.localizedDescription)" + } + + isLoading = false + } + + private func analyzeResponse(_ data: Data) { + var analysis = "🔍 Response Analysis:\n\n" + + // Try to parse as JSON + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else { + analysis += "❌ Cannot parse as JSON" + decodingResult = analysis + return + } + + // Check if it's a dictionary + if let dict = jsonObject as? [String: Any] { + analysis += "✅ Response is a Dictionary\n" + analysis += "📊 Keys: \(dict.keys.count)\n\n" + + // List all keys with types + analysis += "🔑 Available Fields:\n" + for (key, value) in dict.sorted(by: { $0.key < $1.key }) { + let valueType = type(of: value) + let preview = previewValue(value) + analysis += " • \(key): \(valueType)\(preview)\n" + } + + // Check for wrapper format + let hasSuccess = dict["success"] != nil + let hasData = dict["data"] != nil + let hasError = dict["error"] != nil + + analysis += "\n📦 Wrapper Detection:\n" + analysis += " • Has 'success': \(hasSuccess ? "✅" : "❌")\n" + analysis += " • Has 'data': \(hasData ? "✅" : "❌")\n" + analysis += " • Has 'error': \(hasError ? "✅" : "❌")\n" + + if hasData, let dataValue = dict["data"] { + analysis += "\n📊 Data Content:\n" + if let dataDict = dataValue as? [String: Any] { + analysis += " Type: Dictionary\n" + analysis += " Keys: \(dataDict.keys.joined(separator: ", "))\n" + } else if let dataArray = dataValue as? [Any] { + analysis += " Type: Array\n" + analysis += " Count: \(dataArray.count)\n" + } else { + analysis += " Type: \(type(of: dataValue))\n" + } + } + + // Suggestions for User model + if endpoint.contains("/users") { + analysis += "\n💡 User Model Mapping:\n" + let userFields = ["id", "email", "name", "firstName", "lastName", + "avatarUrl", "phoneNumber", "isEmailVerified", + "emailConfirmed", "createdAt", "updatedAt", "roles"] + + for field in userFields { + if dict[field] != nil { + analysis += " ✅ \(field)\n" + } else { + // Try snake_case version + let snakeCase = field.toSnakeCase() + if dict[snakeCase] != nil { + analysis += " ⚠️ \(field) → found as '\(snakeCase)'\n" + } else { + analysis += " ❌ \(field) → not found\n" + } + } + } + } + + } else if let array = jsonObject as? [[String: Any]] { + analysis += "✅ Response is an Array\n" + analysis += "📊 Count: \(array.count)\n" + if let firstItem = array.first { + analysis += "🔑 First Item Keys:\n" + for key in firstItem.keys.sorted() { + analysis += " • \(key)\n" + } + } + } else { + analysis += "⚠️ Unknown response format\n" + } + + decodingResult = analysis + } + + private func previewValue(_ value: Any) -> String { + switch value { + case let string as String: + let preview = string.prefix(30) + return preview.count < string.count ? " = \"\(preview)...\"" : " = \"\(preview)\"" + case let number as NSNumber: + return " = \(number)" + case let bool as Bool: + return " = \(bool)" + case is NSNull: + return " = null" + default: + return "" + } + } +} + +// MARK: - Preview + +#Preview { + APIDebugView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/APIResponseDebugger.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/APIResponseDebugger.swift new file mode 100644 index 00000000..0845718e --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/APIResponseDebugger.swift @@ -0,0 +1,291 @@ +// +// APIResponseDebugger.swift +// AppClientBaseSwift +// +// Helper for debugging API response issues +// Helper để debug các vấn đề API response +// + +import Foundation + +// MARK: - API Response Debugger +// Debugger cho API Response + +/// Helper class for debugging API responses +/// Class helper để debug API responses +struct APIResponseDebugger { + + /// Print detailed information about raw JSON data + /// In thông tin chi tiết về raw JSON data + /// - Parameters: + /// - data: Raw JSON data / Dữ liệu JSON thô + /// - expectedType: Expected type name / Tên type mong đợi + static func debugJSONData(_ data: Data, expectedType: String) { + #if DEBUG + print("\n" + String(repeating: "=", count: 60)) + print("🔍 API RESPONSE DEBUG") + print(String(repeating: "=", count: 60)) + + // Print raw JSON + if let jsonString = String(data: data, encoding: .utf8) { + print("\n📄 Raw JSON Response:") + print(jsonString.prettyPrintedJSON ?? jsonString) + } + + // Try to parse as generic JSON object + if let jsonObject = try? JSONSerialization.jsonObject(with: data), + let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted), + let prettyJSON = String(data: jsonData, encoding: .utf8) { + + print("\n🎨 Pretty Printed JSON:") + print(prettyJSON) + + // Analyze structure + if let dict = jsonObject as? [String: Any] { + print("\n🔑 Available Keys:") + for (key, value) in dict { + let valueType = type(of: value) + print(" - \(key): \(valueType)") + } + + // Check for wrapper format + let hasSuccess = dict["success"] != nil + let hasData = dict["data"] != nil + let hasError = dict["error"] != nil + + if hasSuccess || hasData { + print("\n📦 Detected Wrapper Format:") + print(" - Has 'success': \(hasSuccess)") + print(" - Has 'data': \(hasData)") + print(" - Has 'error': \(hasError)") + + if hasData, let dataValue = dict["data"] { + print("\n📊 Data Type: \(type(of: dataValue))") + if let dataDict = dataValue as? [String: Any] { + print(" Data Keys: \(dataDict.keys.joined(separator: ", "))") + } + } + } else { + print("\n⚠️ No wrapper detected - direct object response") + } + } else if let array = jsonObject as? [[String: Any]] { + print("\n📊 Array Response with \(array.count) items") + if let firstItem = array.first { + print(" First item keys: \(firstItem.keys.joined(separator: ", "))") + } + } + } + + print("\n🎯 Expected Type: \(expectedType)") + print(String(repeating: "=", count: 60) + "\n") + #endif + } + + /// Compare expected model fields with actual JSON keys + /// So sánh các field mong đợi của model với key thực tế trong JSON + /// - Parameters: + /// - data: Raw JSON data / Dữ liệu JSON thô + /// - expectedKeys: Expected keys / Các key mong đợi + static func compareFields(data: Data, expectedKeys: [String]) { + #if DEBUG + guard let jsonObject = try? JSONSerialization.jsonObject(with: data), + let dict = jsonObject as? [String: Any] else { + print("❌ Cannot parse JSON as dictionary") + return + } + + print("\n🔍 Field Comparison:") + print("Expected vs Actual\n") + + let actualKeys = Set(dict.keys) + let expectedSet = Set(expectedKeys) + + // Missing keys + let missing = expectedSet.subtracting(actualKeys) + if !missing.isEmpty { + print("❌ Missing Keys (in model but not in JSON):") + missing.forEach { print(" - \($0)") } + } + + // Extra keys + let extra = actualKeys.subtracting(expectedSet) + if !extra.isEmpty { + print("\n➕ Extra Keys (in JSON but not in model):") + extra.forEach { print(" - \($0)") } + } + + // Matching keys + let matching = actualKeys.intersection(expectedSet) + if !matching.isEmpty { + print("\n✅ Matching Keys:") + matching.forEach { print(" - \($0)") } + } + + // Suggest CodingKeys mapping + if !extra.isEmpty || !missing.isEmpty { + print("\n💡 Suggestion: Add custom CodingKeys mapping:") + print("enum CodingKeys: String, CodingKey {") + for key in expectedKeys { + if missing.contains(key) { + // Try to find similar key in actual + let snakeCase = key.toSnakeCase() + if actualKeys.contains(snakeCase) { + print(" case \(key) = \"\(snakeCase)\"") + } else { + print(" case \(key) // ⚠️ Not found in response") + } + } else { + print(" case \(key)") + } + } + print("}") + } + #endif + } + + /// Generate Swift model code from JSON data + /// Tạo code Swift model từ JSON data + /// - Parameters: + /// - data: Raw JSON data / Dữ liệu JSON thô + /// - modelName: Name for the model / Tên cho model + static func generateModelCode(from data: Data, modelName: String = "GeneratedModel") { + #if DEBUG + guard let jsonObject = try? JSONSerialization.jsonObject(with: data), + let dict = jsonObject as? [String: Any] else { + print("❌ Cannot parse JSON as dictionary") + return + } + + print("\n🏗️ Generated Model Code:\n") + print("struct \(modelName): Codable {") + + for (key, value) in dict.sorted(by: { $0.key < $1.key }) { + let camelKey = key.toCamelCase() + let swiftType = swiftType(for: value) + let isOptional = value is NSNull + + print(" let \(camelKey): \(swiftType)\(isOptional ? "?" : "")") + + if key != camelKey { + print(" // API key: \"\(key)\"") + } + } + + print("}") + print("\n") + #endif + } + + /// Determine Swift type from JSON value + /// Xác định Swift type từ giá trị JSON + private static func swiftType(for value: Any) -> String { + switch value { + case is String: + return "String" + case is Int: + return "Int" + case is Double, is Float: + return "Double" + case is Bool: + return "Bool" + case is [Any]: + return "[Any]" // Could be more specific + case is [String: Any]: + return "[String: Any]" // Could be more specific + case is NSNull: + return "String" // Default to String for null values + default: + return "Any" + } + } +} + +// MARK: - String Extensions for Case Conversion +// Extensions String cho chuyển đổi case + +extension String { + /// Convert string to snake_case + /// Chuyển string sang snake_case + func toSnakeCase() -> String { + let pattern = "([a-z0-9])([A-Z])" + + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(location: 0, length: count) + let modString = regex?.stringByReplacingMatches( + in: self, + options: [], + range: range, + withTemplate: "$1_$2" + ) + + return (modString ?? self).lowercased() + } + + /// Convert string to camelCase + /// Chuyển string sang camelCase + func toCamelCase() -> String { + let components = self.components(separatedBy: "_") + guard components.count > 1 else { return self } + + let first = components[0].lowercased() + let rest = components.dropFirst().map { $0.capitalized }.joined() + + 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 +// Ví dụ sử dụng + +#if DEBUG +extension APIResponseDebugger { + /// Example of how to use the debugger + /// Ví dụ cách sử dụng debugger + static func exampleUsage() { + let sampleJSON = """ + { + "success": true, + "data": { + "id": "123", + "email": "test@example.com", + "first_name": "John", + "last_name": "Doe", + "email_confirmed": true, + "created_at": "2024-01-01T00:00:00Z" + } + } + """ + + guard let data = sampleJSON.data(using: .utf8) else { return } + + // Debug full response + debugJSONData(data, expectedType: "APIResponse") + + // Compare fields + let expectedUserFields = [ + "id", "email", "name", "firstName", "lastName", + "avatarUrl", "phoneNumber", "isEmailVerified", + "createdAt", "updatedAt", "roles" + ] + compareFields(data: data, expectedKeys: expectedUserFields) + + // Generate model + generateModelCode(from: data, modelName: "User") + } +} +#endif diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/SimulatorWarningsSuppressor.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/SimulatorWarningsSuppressor.swift new file mode 100644 index 00000000..935d6cbc --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/SimulatorWarningsSuppressor.swift @@ -0,0 +1,94 @@ +// +// SimulatorWarningsSuppressor.swift +// AppClientBaseSwift +// +// Suppress common iOS Simulator warnings +// Tắt các warnings phổ biến của iOS Simulator +// + +import Foundation +import UIKit + +// MARK: - Simulator Warnings Suppressor +// Suppressor cho Simulator Warnings + +/// Helper to suppress simulator-specific warnings +/// Helper để tắt warnings đặc thù của simulator +struct SimulatorWarningsSuppressor { + + /// Suppress common iOS Simulator warnings + /// Tắt các warnings phổ biến của iOS Simulator + static func suppressSimulatorWarnings() { + #if DEBUG && targetEnvironment(simulator) + + // Suppress Auto Layout constraint warnings + // Tắt warnings về Auto Layout constraints + UserDefaults.standard.setValue(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable") + + // Note: Haptic and keyboard warnings cannot be suppressed programmatically + // These are internal iOS system logs + // Lưu ý: Haptic và keyboard warnings không thể tắt bằng code + // Đây là system logs nội bộ của iOS + + print("ℹ️ Simulator warnings suppression enabled") + print(" - Auto Layout constraint warnings: suppressed") + print(" - Haptic feedback warnings: cannot suppress (simulator limitation)") + print(" - Keyboard warnings: cannot suppress (system logs)") + + #endif + } + + /// Check if running on simulator + /// Kiểm tra có đang chạy trên simulator không + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + /// Print simulator-friendly message about known issues + /// In thông báo về các issues đã biết trên simulator + static func printSimulatorInfo() { + #if DEBUG && targetEnvironment(simulator) + print(""" + + ⚠️ Running on iOS Simulator - Known Issues: + + 1. Haptic Feedback Warnings: + - hapticpatternlibrary.plist not found + - This is normal on simulator + - Will work fine on real devices + + 2. Auto Layout Warnings: + - Keyboard constraint conflicts + - iOS automatically resolves these + - No visual impact on app + + 3. Keyboard Cache Warnings: + - Candidate multiplexer warnings + - Simulator-specific issue + - Does not affect functionality + + 💡 These warnings do NOT indicate bugs in your code. + + """) + #endif + } +} + +// MARK: - App Delegate Extension +// Extension cho App Delegate + +#if DEBUG && targetEnvironment(simulator) +extension SimulatorWarningsSuppressor { + + /// Call this in AppDelegate or App init + /// Gọi hàm này trong AppDelegate hoặc App init + static func configure() { + suppressSimulatorWarnings() + printSimulatorInfo() + } +} +#endif diff --git a/apps/app-client-base-swift/AppClientBaseSwift/DebugLogger.swift b/apps/app-client-base-swift/AppClientBaseSwift/DebugLogger.swift new file mode 100644 index 00000000..851e2eee --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/DebugLogger.swift @@ -0,0 +1,173 @@ +// +// DebugLogger.swift +// AppClientBaseSwift +// +// Debug logging utilities +// Tiện ích ghi log debug +// + +import Foundation + +// MARK: - Debug Logger +// Logger Debug + +/// Debug logging utility +/// Tiện ích ghi log debug +enum DebugLogger { + + /// Log level + /// Mức độ log + enum Level: String { + case verbose = "📝" + case info = "ℹ️" + case warning = "⚠️" + case error = "❌" + case success = "✅" + } + + /// Log message with level + /// Ghi log message với level + /// - Parameters: + /// - level: Log level / Mức độ log + /// - message: Log message / Thông điệp log + /// - file: Source file / File nguồn + /// - function: Function name / Tên function + /// - line: Line number / Số dòng + static func log( + _ level: Level, + _ message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + #if DEBUG + let fileName = (file as NSString).lastPathComponent + let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + print("\(level.rawValue) [\(timestamp)] \(fileName):\(line) - \(function)") + print(" \(message)") + #endif + } + + /// Log verbose message + /// Ghi log verbose + static func verbose(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.verbose, message, file: file, function: function, line: line) + } + + /// Log info message + /// Ghi log info + static func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.info, message, file: file, function: function, line: line) + } + + /// Log warning message + /// Ghi log warning + static func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.warning, message, file: file, function: function, line: line) + } + + /// Log error message + /// Ghi log error + static func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.error, message, file: file, function: function, line: line) + } + + /// Log success message + /// Ghi log success + static func success(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.success, message, file: file, function: function, line: line) + } + + /// Log JSON data + /// Ghi log dữ liệu JSON + /// - Parameters: + /// - data: JSON data / Dữ liệu JSON + /// - title: Optional title / Tiêu đề tùy chọn + static func logJSON(_ data: Data, title: String = "JSON Response") { + #if DEBUG + guard let jsonString = String(data: data, encoding: .utf8) else { + error("Unable to convert data to string") + return + } + + // Try to pretty print JSON + // Thử format JSON đẹp + if let jsonData = jsonString.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: jsonData), + let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted), + let prettyString = String(data: prettyData, encoding: .utf8) { + print("📄 \(title):") + print(prettyString) + } else { + print("📄 \(title):") + print(jsonString) + } + #endif + } + + /// Log API request + /// Ghi log API request + /// - Parameters: + /// - method: HTTP method / Phương thức HTTP + /// - url: Request URL / URL request + /// - headers: Request headers / Headers request + /// - body: Request body / Body request + static func logRequest( + method: String, + url: String, + headers: [String: String]? = nil, + body: Data? = nil + ) { + #if DEBUG + print("🚀 API Request:") + print(" Method: \(method)") + print(" URL: \(url)") + + if let headers = headers, !headers.isEmpty { + print(" Headers:") + headers.forEach { key, value in + // Hide sensitive data + // Ẩn dữ liệu nhạy cảm + if key.lowercased() == "authorization" { + print(" \(key): Bearer ***") + } else { + print(" \(key): \(value)") + } + } + } + + if let body = body { + print(" Body:") + logJSON(body, title: "Request Body") + } + #endif + } + + /// Log API response + /// Ghi log API response + /// - Parameters: + /// - statusCode: HTTP status code / Mã trạng thái HTTP + /// - data: Response data / Dữ liệu response + /// - error: Error if any / Lỗi nếu có + static func logResponse( + statusCode: Int, + data: Data?, + error: Error? = nil + ) { + #if DEBUG + if let error = error { + print("❌ API Response Error:") + print(" Status Code: \(statusCode)") + print(" Error: \(error.localizedDescription)") + } else { + let emoji = statusCode >= 200 && statusCode < 300 ? "✅" : "⚠️" + print("\(emoji) API Response:") + print(" Status Code: \(statusCode)") + } + + if let data = data { + logJSON(data, title: "Response Data") + } + #endif + } +}