cat swift/laboratorio-sdk-swift-api-reale.md
Laboratorio: SDK Swift per una API reale
Laboratorio del Modulo 7: costruire un piccolo SDK Swift con DTO, dominio, URLSession, errori, retry, cancellazione e cache.
Questo laboratorio chiude il modulo networking. Costruiremo uno SDK Swift per consumare una API prodotti.
L’obiettivo è avere una superficie pulita:
let sdk = ProductSDK(...)
let product = try await sdk.product(id: "p-1")
Chi usa lo SDK non deve sapere:
- path HTTP;
- DTO;
- status code;
- decoder;
- cache;
- retry.
Struttura del package
Sources/
ProductSDK/
APIClient.swift
Endpoint.swift
ProductSDK.swift
DTO/
ProductDTO.swift
Domain/
Product.swift
Price.swift
ProductCategory.swift
Errors/
APIError.swift
MappingError.swift
Cache/
ExpiringCache.swift
Separare cartelle non crea architettura da solo, ma aiuta a navigare.
Dominio
public struct Product: Equatable, Sendable, Identifiable {
public let id: String
public let name: String
public let price: Price
public let category: ProductCategory
}
public struct Price: Equatable, Comparable, Sendable {
public let cents: Int
public init?(_ cents: Int) {
guard cents >= 0 else {
return nil
}
self.cents = cents
}
}
public enum ProductCategory: String, Sendable {
case books
case electronics
case accessories
case home
}
DTO
struct ProductDTO: Decodable, Sendable {
let id: String
let name: String
let priceCents: Int
let category: String
enum CodingKeys: String, CodingKey {
case id
case name
case priceCents = "price_cents"
case category
}
}
Mapping
enum MappingError: Error, Sendable, Equatable {
case invalidPrice
case invalidCategory(String)
}
extension ProductDTO {
func toDomain() throws -> Product {
guard let price = Price(priceCents) else {
throw MappingError.invalidPrice
}
guard let category = ProductCategory(rawValue: category) else {
throw MappingError.invalidCategory(category)
}
return Product(
id: id,
name: name,
price: price,
category: category
)
}
}
Endpoint
struct Endpoint: Sendable {
let path: String
let queryItems: [URLQueryItem]
init(path: String, queryItems: [URLQueryItem] = []) {
self.path = path
self.queryItems = queryItems
}
}
APIError
public enum APIError: Error, Sendable {
case invalidURL
case invalidResponse
case statusCode(Int)
case decoding
case mapping
case transport
}
Per una libreria reale, potresti conservare errori sottostanti. Qui manteniamo la superficie compatta.
APIClient
struct APIClient: Sendable {
let baseURL: URL
let session: URLSession
let decoder: JSONDecoder
func send<T: Decodable & Sendable>(_ endpoint: Endpoint) async throws -> T {
let request = try request(for: endpoint)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.statusCode(http.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch is CancellationError {
throw CancellationError()
} catch {
throw APIError.decoding
}
}
private func request(for endpoint: Endpoint) throws -> URLRequest {
let url = baseURL.appending(path: endpoint.path)
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
throw APIError.invalidURL
}
components.queryItems = endpoint.queryItems.isEmpty ? nil : endpoint.queryItems
guard let finalURL = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: finalURL)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 15
return request
}
}
Retry
func retry<T: Sendable>(
attempts: Int,
operation: () async throws -> T
) async throws -> T {
var lastError: Error?
for attempt in 0..<attempts {
try Task.checkCancellation()
do {
return try await operation()
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if attempt < attempts - 1 {
try await Task.sleep(for: .milliseconds(250))
}
}
}
throw lastError ?? APIError.invalidResponse
}
Cache
actor ProductCache {
private var storage: [String: Product] = [:]
func get(_ id: String) -> Product? {
storage[id]
}
func set(_ product: Product, for id: String) {
storage[id] = product
}
}
SDK pubblico
public struct ProductSDK: Sendable {
private let client: APIClient
private let cache: ProductCache
public init(baseURL: URL, session: URLSession = .shared) {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.client = APIClient(baseURL: baseURL, session: session, decoder: decoder)
self.cache = ProductCache()
}
public func product(id: String) async throws -> Product {
if let cached = await cache.get(id) {
return cached
}
let dto: ProductDTO = try await retry(attempts: 3) {
try await client.send(Endpoint(path: "products/\(id)"))
}
do {
let product = try dto.toDomain()
await cache.set(product, for: id)
return product
} catch is CancellationError {
throw CancellationError()
} catch {
throw APIError.mapping
}
}
}
Test consigliati
Da aggiungere nel prossimo modulo architettura/testabilità:
- decoding DTO valido;
- mapping prezzo invalido;
- mapping categoria sconosciuta;
- status code 404;
- retry su errore temporaneo;
- cancellazione non ritentata;
- cache hit evita chiamata API.
Checklist finale
Lo SDK è accettabile se:
- espone dominio, non DTO;
- distingue decoding e mapping;
- valida status code;
- rispetta cancellazione;
- usa retry solo dove sensato;
- ha cache isolata;
- non obbliga chi usa lo SDK a conoscere HTTP;
- è pronto per test con session finta o transport astratto.
Estensioni
Per renderlo più robusto:
- introdurre
HTTPTransportprotocol per test; - aggiungere
ErrorResponseDTO; - separare
ProductRepository; - supportare paginazione;
- aggiungere cache con TTL;
- aggiungere persistenza file JSON;
- documentare API pubbliche con DocC.