🎯 Overview: Implemented complete VPS-based storage system allowing the iOS app to download and store manga chapters on the VPS for ad-free offline reading. 📦 Backend Changes: - Added storage.js service for managing chapter downloads (270 lines) - Updated server.js with 6 new storage endpoints: - POST /api/download - Download chapters to VPS - GET /api/storage/chapters/:mangaSlug - List downloaded chapters - GET /api/storage/chapter/:mangaSlug/:chapterNumber - Check download status - GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex - Serve images - DELETE /api/storage/chapter/:mangaSlug/:chapterNumber - Delete chapters - GET /api/storage/stats - Get storage statistics - Fixed scraper.js Puppeteer compatibility issues (waitForTimeout, networkidle0) - Added comprehensive test suite: - test-vps-flow.js (13 tests - 100% pass rate) - test-concurrent-downloads.js (10 tests for parallel operations) - run-tests.sh automation script 📱 iOS App Changes: - Created APIConfig.swift with VPS connection settings - Created VPSAPIClient.swift service (727 lines) for backend communication - Updated MangaDetailView.swift with VPS download integration: - Cloud icon for VPS-available chapters - Upload button to download chapters to VPS - Progress indicators for active downloads - Bulk download options (last 10 or all chapters) - Updated ReaderView.swift to load images from VPS first - Progressive enhancement: app works without VPS, enhances when available ✅ Tests: - All 13 VPS flow tests passing (100%) - Tests verify: scraping, downloading, storage, serving, deletion, stats - Chapter 789 download test: 21 images, 4.68 MB - Concurrent download tests verify no race conditions 🔧 Configuration: - VPS URL: https://gitea.cbcren.online:3001 - Storage location: /home/ren/ios/MangaReader/storage/ - Static file serving: /storage path 📚 Documentation: - Added VPS_INTEGRATION_SUMMARY.md - Complete feature overview - Added CHANGES.md - Detailed code changes reference - Added TEST_README.md, TEST_QUICK_START.md, TEST_SUMMARY.md - Added APIConfig README with usage examples 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
291 lines
9.6 KiB
Swift
291 lines
9.6 KiB
Swift
import Foundation
|
|
|
|
/// Ejemplos de uso de APIConfig
|
|
///
|
|
/// Este archivo demuestra cómo utilizar la configuración de la API
|
|
/// en diferentes escenarios de la aplicación.
|
|
class APIConfigExample {
|
|
|
|
/// Ejemplo 1: Configurar URLSession con timeouts de APIConfig
|
|
func configureURLSession() -> URLSession {
|
|
let configuration = URLSessionConfiguration.default
|
|
configuration.timeoutIntervalForRequest = APIConfig.defaultTimeout
|
|
configuration.timeoutIntervalForResource = APIConfig.downloadTimeout
|
|
return URLSession(configuration: configuration)
|
|
}
|
|
|
|
/// Ejemplo 2: Construir una URL completa para un endpoint
|
|
func buildEndpointURL() {
|
|
// Método 1: Usar la función helper
|
|
let url1 = APIConfig.url(for: "manga/popular")
|
|
print("URL completa: \(url1)")
|
|
|
|
// Método 2: Usar urlObject para obtener un objeto URL
|
|
if let url2 = APIConfig.urlObject(for: "manga/popular") {
|
|
print("URL object: \(url2)")
|
|
}
|
|
|
|
// Método 3: Usar directamente baseURL
|
|
let url3 = "\(APIConfig.basePath)/manga/popular"
|
|
print("URL manual: \(url3)")
|
|
}
|
|
|
|
/// Ejemplo 3: Usar los endpoints predefinidos
|
|
func usePredefinedEndpoints() {
|
|
// Endpoint de descarga
|
|
let downloadURL = APIConfig.Endpoints.download(
|
|
mangaSlug: "one-piece",
|
|
chapterNumber: 1089
|
|
)
|
|
print("Download endpoint: \(downloadURL)")
|
|
|
|
// Endpoint de verificación
|
|
let checkURL = APIConfig.Endpoints.checkDownloaded(
|
|
mangaSlug: "one-piece",
|
|
chapterNumber: 1089
|
|
)
|
|
print("Check endpoint: \(checkURL)")
|
|
|
|
// Endpoint de imagen
|
|
let imageURL = APIConfig.Endpoints.getImage(
|
|
mangaSlug: "one-piece",
|
|
chapterNumber: 1089,
|
|
pageIndex: 0
|
|
)
|
|
print("Image endpoint: \(imageURL)")
|
|
|
|
// Endpoint de health check
|
|
let healthURL = APIConfig.Endpoints.health()
|
|
print("Health endpoint: \(healthURL)")
|
|
|
|
// Endpoint de estadísticas de almacenamiento
|
|
let statsURL = APIConfig.Endpoints.storageStats()
|
|
print("Storage stats endpoint: \(statsURL)")
|
|
}
|
|
|
|
/// Ejemplo 4: Crear una URLRequest con headers comunes
|
|
func createRequest() -> URLRequest? {
|
|
let endpoint = "manga/popular"
|
|
|
|
guard let url = APIConfig.urlObject(for: endpoint) else {
|
|
return nil
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = APIConfig.defaultTimeout
|
|
|
|
// Añadir headers comunes
|
|
for (key, value) in APIConfig.commonHeaders {
|
|
request.setValue(value, forHTTPHeaderField: key)
|
|
}
|
|
|
|
// Si se requiere autenticación
|
|
// let token = "your-auth-token"
|
|
// let authHeaders = APIConfig.authHeader(token: token)
|
|
// for (key, value) in authHeaders {
|
|
// request.setValue(value, forHTTPHeaderField: key)
|
|
// }
|
|
|
|
return request
|
|
}
|
|
|
|
/// Ejemplo 5: Validar la configuración al iniciar la app
|
|
func validateConfiguration() {
|
|
#if DEBUG
|
|
// Imprimir configuración en debug
|
|
APIConfig.printConfiguration()
|
|
#endif
|
|
|
|
// Validar que la configuración sea correcta
|
|
guard APIConfig.isValid else {
|
|
print("ERROR: Configuración de API inválida")
|
|
return
|
|
}
|
|
|
|
print("Configuración válida: \(APIConfig.baseURL)")
|
|
}
|
|
|
|
/// Ejemplo 6: Hacer una request simple
|
|
func makeSimpleRequest() async throws {
|
|
let endpoint = "manga/popular"
|
|
guard let url = APIConfig.urlObject(for: endpoint) else {
|
|
print("URL inválida")
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = APIConfig.timeoutFor(isResourceRequest: false)
|
|
|
|
for (key, value) in APIConfig.commonHeaders {
|
|
request.setValue(value, forHTTPHeaderField: key)
|
|
}
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
print("Status code: \(httpResponse.statusCode)")
|
|
}
|
|
|
|
// Procesar data...
|
|
print("Recibidos \(data.count) bytes")
|
|
}
|
|
|
|
/// Ejemplo 7: Usar timeouts apropiados según el tipo de request
|
|
func demonstrateTimeouts() {
|
|
// Request normal (usar defaultTimeout)
|
|
let normalTimeout = APIConfig.timeoutFor(isResourceRequest: false)
|
|
print("Normal timeout: \(normalTimeout)s") // 30.0s
|
|
|
|
// Request de descarga de imagen (usar downloadTimeout)
|
|
let resourceTimeout = APIConfig.timeoutFor(isResourceRequest: true)
|
|
print("Resource timeout: \(resourceTimeout)s") // 300.0s
|
|
}
|
|
|
|
/// Ejemplo 8: Cambiar configuración según el entorno
|
|
func configureForEnvironment() {
|
|
#if DEBUG
|
|
// En desarrollo, usar configuración local
|
|
print("Modo desarrollo")
|
|
// Nota: Para cambiar realmente la configuración, modificar las propiedades
|
|
// estáticas en APIConfig usando compilación condicional
|
|
#else
|
|
// En producción, usar configuración de producción
|
|
print("Modo producción")
|
|
#endif
|
|
}
|
|
|
|
/// Ejemplo 9: Manejar errores específicos de la API
|
|
func handleAPIError(errorCode: Int) {
|
|
switch errorCode {
|
|
case APIConfig.ErrorCodes.chapterNotFound:
|
|
print("Capítulo no encontrado")
|
|
case APIConfig.ErrorCodes.chapterAlreadyDownloaded:
|
|
print("Capítulo ya descargado")
|
|
case APIConfig.ErrorCodes.storageLimitExceeded:
|
|
print("Límite de almacenamiento excedido")
|
|
case APIConfig.ErrorCodes.invalidImageFormat:
|
|
print("Formato de imagen inválido")
|
|
case APIConfig.ErrorCodes.downloadFailed:
|
|
print("Descarga fallida")
|
|
default:
|
|
print("Error desconocido: \(errorCode)")
|
|
}
|
|
}
|
|
|
|
/// Ejemplo 10: Implementar retry con backoff exponencial
|
|
func fetchWithRetry(endpoint: String, retryCount: Int = 0) async throws -> Data {
|
|
guard let url = APIConfig.urlObject(for: endpoint) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = APIConfig.defaultTimeout
|
|
|
|
do {
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse,
|
|
httpResponse.statusCode == 200 {
|
|
return data
|
|
} else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
} catch {
|
|
// Verificar si debemos reintentar
|
|
if retryCount < APIConfig.maxRetries {
|
|
// Calcular delay con backoff exponencial
|
|
let delay = APIConfig.baseRetryDelay * pow(2.0, Double(retryCount))
|
|
print("Retry \(retryCount + 1) después de \(delay)s")
|
|
|
|
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
|
|
return try await fetchWithRetry(endpoint: endpoint, retryCount: retryCount + 1)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Usage Examples
|
|
|
|
// Ejemplo de uso en una ViewModel o Service:
|
|
class MangaServiceExample {
|
|
|
|
func fetchPopularManga() async throws {
|
|
// Usar endpoint predefinido
|
|
let endpoint = "manga/popular"
|
|
guard let url = APIConfig.urlObject(for: endpoint) else {
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = APIConfig.defaultTimeout
|
|
|
|
// Añadir headers
|
|
for (key, value) in APIConfig.commonHeaders {
|
|
request.setValue(value, forHTTPHeaderField: key)
|
|
}
|
|
|
|
// Hacer request
|
|
let (data, _) = try await URLSession.shared.data(for: request)
|
|
|
|
// Parsear respuesta...
|
|
print("Datos recibidos: \(data.count) bytes")
|
|
}
|
|
|
|
func downloadChapter(mangaSlug: String, chapterNumber: Int) async throws {
|
|
// Usar endpoint predefinido
|
|
let endpoint = APIConfig.Endpoints.download(
|
|
mangaSlug: mangaSlug,
|
|
chapterNumber: chapterNumber
|
|
)
|
|
|
|
guard let url = URL(string: endpoint) else {
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
// Usar timeout más largo para descargas
|
|
request.timeoutInterval = APIConfig.downloadTimeout
|
|
|
|
for (key, value) in APIConfig.commonHeaders {
|
|
request.setValue(value, forHTTPHeaderField: key)
|
|
}
|
|
|
|
// Hacer request
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
print("Status: \(httpResponse.statusCode)")
|
|
|
|
// Manejar errores específicos
|
|
if httpResponse.statusCode != 200 {
|
|
// Aquí podrías usar APIConfig.ErrorCodes si el backend
|
|
// retorna códigos de error personalizados
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
}
|
|
|
|
print("Descarga completada: \(data.count) bytes")
|
|
}
|
|
|
|
func checkServerHealth() async throws {
|
|
// Usar endpoint de health check
|
|
let endpoint = APIConfig.Endpoints.health()
|
|
|
|
guard let url = URL(string: endpoint) else {
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = APIConfig.defaultTimeout
|
|
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
print("Server health status: \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
}
|