cat swift/milestone-networking-persistenza-cache.md

Lezione 74 75 min

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:

  1. HTTPClient;
  2. RequestBuilder;
  3. DTO principali;
  4. mapping verso dominio;
  5. repository remoto;
  6. cache actor;
  7. test con JSON valido;
  8. test con status code non 2xx;
  9. test con payload invalido.

Obiettivo: il progetto deve recuperare dati reali in demo, ma i test devono funzionare offline.