feat: Introduce comprehensive API debugging tools, enhance decoding error handling, and simplify API model definitions.

This commit is contained in:
Ho Ngoc Hai
2026-01-16 18:44:08 +07:00
parent 462e1d0861
commit 420d98e140
7 changed files with 1731 additions and 0 deletions

View File

@@ -0,0 +1,389 @@
//
// APIDebugView.swift
// AppClientBaseSwift
//
// Comprehensive API debugging view
// View debug API toàn din
//
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()
}

View File

@@ -0,0 +1,291 @@
//
// APIResponseDebugger.swift
// AppClientBaseSwift
//
// Helper for debugging API response issues
// Helper đ debug các vn đ 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 liu 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 ca model vi key thc tế trong JSON
/// - Parameters:
/// - data: Raw JSON data / D liu 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
/// To code Swift model t JSON data
/// - Parameters:
/// - data: Raw JSON data / D liu 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 chuyn đi case
extension String {
/// Convert string to snake_case
/// Chuyn 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
/// Chuyn 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 dng
#if DEBUG
extension APIResponseDebugger {
/// Example of how to use the debugger
/// Ví d cách s dng 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<User>")
// 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

View File

@@ -0,0 +1,94 @@
//
// SimulatorWarningsSuppressor.swift
// AppClientBaseSwift
//
// Suppress common iOS Simulator warnings
// Tt các warnings ph biến ca iOS Simulator
//
import Foundation
import UIKit
// MARK: - Simulator Warnings Suppressor
// Suppressor cho Simulator Warnings
/// Helper to suppress simulator-specific warnings
/// Helper đ tt warnings đc thù ca simulator
struct SimulatorWarningsSuppressor {
/// Suppress common iOS Simulator warnings
/// Tt các warnings ph biến ca iOS Simulator
static func suppressSimulatorWarnings() {
#if DEBUG && targetEnvironment(simulator)
// Suppress Auto Layout constraint warnings
// Tt 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 tt bng code
// Đây là system logs ni b ca 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
/// Kim tra có đang chy 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
/// Gi hàm này trong AppDelegate hoc App init
static func configure() {
suppressSimulatorWarnings()
printSimulatorInfo()
}
}
#endif