cat swift/milestone-networking-persistenza-cache.md
Milestone 2: networking, persistenza e cache
Aggiungere infrastruttura al progetto finale: DTO, URLSession, mapping, persistenza locale, cache e repository composabili.
La seconda milestone porta il progetto fuori dal mondo puro.
Ora entrano:
- API remote;
- JSON;
- file o storage locale;
- cache;
- DTO;
- mapping;
- errori tecnici.
La regola resta la stessa: l’infrastruttura serve il dominio, non lo comanda.
Output della milestone
Alla fine devi avere:
Sources/
ProjectInfrastructure/
HTTPClient.swift
RequestBuilder.swift
RemoteRepository.swift
DTO/
Mapping/
Cache/
Persistence/
E almeno:
- un repository remoto;
- un repository cached o persisted;
- mapping DTO -> dominio;
- gestione status code;
- test con stub HTTP;
- test cache.
HTTPClient
public protocol HTTPClient: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
Adattatore live:
public struct URLSessionHTTPClient: HTTPClient {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
return (data, http)
}
}
Il repository dipendera da HTTPClient, non da URLSession.shared.
RequestBuilder
public struct RequestBuilder: Sendable {
private let baseURL: URL
public init(baseURL: URL) {
self.baseURL = baseURL
}
public func get(path: String, queryItems: [URLQueryItem] = []) throws -> URLRequest {
let url = baseURL.appending(path: path)
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
throw NetworkError.invalidURL
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let finalURL = components.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: finalURL)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
}
La costruzione URL è una responsabilità testabile.
DTO
Esempio per Hacker News o API simile:
struct StoryDTO: Decodable, Sendable {
let objectID: String
let title: String?
let url: URL?
let points: Int?
enum CodingKeys: String, CodingKey {
case objectID
case title
case url
case points
}
}
Il DTO accetta il mondo esterno: campi mancanti, tipi strani, nomi non ideali.
Mapping
extension StoryDTO {
func toDomain() throws -> Story {
guard let rawID = Int(objectID) else {
throw MappingError.invalidID
}
guard let title, title.isEmpty == false else {
throw MappingError.missingTitle
}
return Story(
id: Story.ID(rawValue: rawID),
title: title,
url: url,
score: points ?? 0
)
}
}
Il dominio non deve diventare optional ovunque solo perché il JSON è fragile.
Remote repository
public struct RemoteStoryRepository: StoryRepository {
private let client: HTTPClient
private let requestBuilder: RequestBuilder
private let decoder: JSONDecoder
public init(
client: HTTPClient,
requestBuilder: RequestBuilder,
decoder: JSONDecoder = JSONDecoder()
) {
self.client = client
self.requestBuilder = requestBuilder
self.decoder = decoder
}
public func search(query: SearchQuery) async throws -> [Story] {
let request = try requestBuilder.get(
path: "/api/v1/search",
queryItems: [URLQueryItem(name: "query", value: query.value)]
)
let (data, response) = try await client.data(for: request)
guard (200..<300).contains(response.statusCode) else {
throw NetworkError.statusCode(response.statusCode)
}
let dto = try decoder.decode(SearchResponseDTO.self, from: data)
return try dto.hits.map { try $0.toDomain() }
}
}
Cache actor
public actor StoryCache {
private var storage: [SearchQuery: [Story]] = [:]
public func value(for query: SearchQuery) -> [Story]? {
storage[query]
}
public func store(_ stories: [Story], for query: SearchQuery) {
storage[query] = stories
}
}
Per usare SearchQuery come chiave, rendilo Hashable.
Cached repository
public struct CachedStoryRepository: StoryRepository {
private let remote: StoryRepository
private let cache: StoryCache
public init(remote: StoryRepository, cache: StoryCache) {
self.remote = remote
self.cache = cache
}
public func search(query: SearchQuery) async throws -> [Story] {
if let cached = await cache.value(for: query) {
return cached
}
let stories = try await remote.search(query: query)
await cache.store(stories, for: query)
return stories
}
}
La cache è composizione, non un dettaglio nascosto nel remote repository.
Persistenza locale
Per un progetto finale basta una persistenza leggera:
- JSON file;
- SQLite se vuoi andare oltre;
- UserDefaults solo per preferenze semplici;
- cache su disco con scadenza.
Esempio file store:
public protocol DataStore: Sendable {
func load() async throws -> Data?
func save(_ data: Data) async throws
}
Questo permette test con store in memoria.
Test HTTP
struct StubHTTPClient: HTTPClient {
let data: Data
let statusCode: Int
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
(
data,
HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
)
}
}
Test:
@Test
func remoteRepositoryMappaRispostaValida() async throws {
let repository = RemoteStoryRepository(
client: StubHTTPClient(data: validJSON, statusCode: 200),
requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!)
)
let stories = try await repository.search(query: try SearchQuery("swift"))
#expect(stories.isEmpty == false)
}
Checklist
La milestone 2 è completa quando:
- il dominio non importa DTO;
URLSessionè dietro un protocollo;- gli status code vengono mappati;
- il mapping rifiuta dati invalidi;
- la cache è testabile;
- la persistenza è sostituibile;
- nessun test unitario chiama rete reale;
- la configurazione base URL è iniettata.
Esercizio
Implementa l’infrastruttura del tuo progetto:
HTTPClient;RequestBuilder;- DTO principali;
- mapping verso dominio;
- repository remoto;
- cache actor;
- test con JSON valido;
- test con status code non 2xx;
- test con payload invalido.
Obiettivo: il progetto deve recuperare dati reali in demo, ma i test devono funzionare offline.