Building a Pokédex on iOS — Part 2: Network layer
Part 2 of the PokeTracker series: we build the HTTP client, define endpoints with an enum, and create typed network errors.
In the previous post we left the project with its folder structure in place. Now we build the network layer: the code that talks to the PokéAPI.
There are projects where networking is scattered across ViewModels: a URLSession.shared.data(from: url) here, another one there. It works at first. When you want to add retries, timeouts, logging, or simply test without making real network calls, things get complicated.
Centralizing networking in a single client solves that. All code that talks to the outside world goes through one place.
HTTPMethod
Create Core/Network/NetworkError.swift. It’s a simple enum that avoids using magic strings for HTTP methods:
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
For now we only use GET, but having the enum makes it explicit which methods the app knows about.
Endpoint
Create Core/Network/Endpoint.swift. This enum represents every URL the app can call:
import Foundation
enum Endpoint {
case pokemonList(limit: Int, offset: Int)
case pokemonDetail(idOrName: String)
case pokemonSpecies(idOrName: String)
case type(name: String)
case generation(id: Int)
case evolutionChain(id: Int)
private var baseURL: String {
"https://pokeapi.co/api/v2"
}
var path: String {
switch self {
case .pokemonList:
return "/pokemon"
case .pokemonDetail(let idOrName):
return "/pokemon/\(idOrName)"
case .pokemonSpecies(let idOrName):
return "/pokemon-species/\(idOrName)"
case .type(let name):
return "/type/\(name)"
case .generation(let id):
return "/generation/\(id)"
case .evolutionChain(let id):
return "/evolution-chain/\(id)"
}
}
var queryItems: [URLQueryItem]? {
switch self {
case .pokemonList(let limit, let offset):
return [
URLQueryItem(name: "limit", value: String(limit)),
URLQueryItem(name: "offset", value: String(offset))
]
default:
return nil
}
}
var url: URL? {
var components = URLComponents(string: baseURL + path)
components?.queryItems = queryItems
return components?.url
}
var method: HTTPMethod {
.get
}
}
The advantage of the enum is that each case has typed parameters. You can’t call pokemonDetail without passing an idOrName. You can’t forget the limit in pokemonList. If the app tries to call an endpoint that isn’t here, the compiler rejects it.
NetworkError
Create Core/Network/NetworkError.swift. Typed errors are one of the places where Swift is more expressive than other languages:
import Foundation
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case invalidData
case decodingError(Error)
case serverError(statusCode: Int)
case noConnection
case timeout
case unknown(Error)
var errorDescription: String? {
switch self {
case .invalidURL:
return "The URL is invalid."
case .invalidResponse:
return "Invalid response from server."
case .invalidData:
return "The data received is invalid."
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .serverError(let statusCode):
return "Server error with status code: \(statusCode)"
case .noConnection:
return "No internet connection."
case .timeout:
return "Request timed out."
case .unknown(let error):
return "Unknown error: \(error.localizedDescription)"
}
}
}
Conforming to LocalizedError and providing errorDescription means any view can display error.localizedDescription directly.
APIClient
Create Core/Network/APIClient.swift. This is the HTTP client:
import Foundation
protocol APIClientProtocol: Sendable {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
func fetchData(from url: URL) async throws -> Data
}
final class APIClient: APIClientProtocol, Sendable {
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = decoder
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
guard let url = endpoint.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 30
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.serverError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(T.self, from: data)
} catch let error as NetworkError {
throw error
} catch let error as URLError {
switch error.code {
case .notConnectedToInternet, .networkConnectionLost:
throw NetworkError.noConnection
case .timedOut:
throw NetworkError.timeout
default:
throw NetworkError.unknown(error)
}
} catch {
throw NetworkError.unknown(error)
}
}
func fetchData(from url: URL) async throws -> Data {
do {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.serverError(statusCode: httpResponse.statusCode)
}
return data
} catch let error as NetworkError {
throw error
} catch let error as URLError {
switch error.code {
case .notConnectedToInternet, .networkConnectionLost:
throw NetworkError.noConnection
case .timedOut:
throw NetworkError.timeout
default:
throw NetworkError.unknown(error)
}
} catch {
throw NetworkError.unknown(error)
}
}
}
There are three decisions worth noting:
The APIClientProtocol protocol. APIClient conforms to a protocol rather than just defining the class. This is what allows creating a MockAPIClient in tests without making real network calls. We’ll see this in action in Post 9.
.convertFromSnakeCase. The PokéAPI returns fields in snake_case (base_experience, sprite_url). With this strategy the decoder automatically maps to camelCase in Swift. Without it you’d have to define CodingKeys in every DTO.
Layered error handling. The catch block has three branches: first it rethrows any NetworkError you already have (to preserve the type), then it converts the most common URLError cases into your own errors with useful context, and finally it wraps any unknown error. The result is that callers of request() always receive a NetworkError, not a generic error.
fetchData does the same thing but returns raw Data instead of decoding to a type. We use it for images.
What’s next
The network layer is ready, but it has no DTOs to deserialize yet. In Post 3 we build the data transfer objects and explain why they exist.