cat swift/laboratorio-sdk-swift-api-reale.md

Lezione 55 90 min

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 HTTPTransport protocol per test;
  • aggiungere ErrorResponseDTO;
  • separare ProductRepository;
  • supportare paginazione;
  • aggiungere cache con TTL;
  • aggiungere persistenza file JSON;
  • documentare API pubbliche con DocC.