Project 3 · Milestone 1 — models and the API client
Unit 23 · Project 1 · Milestone 1. You build the API layer: Codable models for current
conditions, an hourly array, and a 10-day daily forecast; a value-type Endpoint; a typed
APIError; and an async APIClient. By the end you can fetch and decode a real forecast.
Everything the app shows comes from one JSON response, so the model is the foundation. Open-Meteo
returns three sections — current, hourly, daily — and your Forecast mirrors them.
Step 1 — the Forecast model
Each section is a nested Codable struct. The API uses snake_case keys, so CodingKeys maps
them to Swift names. Arrays in hourly/daily are parallel — index i of time,
temperature, and weatherCode describe the same hour:
import Foundation struct Forecast: Codable, Sendable { let current: Current let hourly: Hourly let daily: Daily struct Current: Codable, Sendable { let temperature: Double let apparentTemperature: Double let weatherCode: Int let humidity: Int let windSpeed: Double let uvIndex: Double let isDay: Int enum CodingKeys: String, CodingKey { case temperature = "temperature_2m" case apparentTemperature = "apparent_temperature" case weatherCode = "weather_code" case humidity = "relative_humidity_2m" case windSpeed = "wind_speed_10m" case uvIndex = "uv_index" case isDay = "is_day" } } struct Hourly: Codable, Sendable { let time: [Date] let temperature: [Double] let weatherCode: [Int] let precipProbability: [Int] enum CodingKeys: String, CodingKey { case time case temperature = "temperature_2m" case weatherCode = "weather_code" case precipProbability = "precipitation_probability" } } struct Daily: Codable, Sendable { let time: [Date] let tempMax: [Double] let tempMin: [Double] let weatherCode: [Int] let sunrise: [Date] let sunset: [Date] enum CodingKeys: String, CodingKey { case time case tempMax = "temperature_2m_max" case tempMin = "temperature_2m_min" case weatherCode = "weather_code" case sunrise, sunset } } }
Sendable matters because the value crosses from the networking task to the main-actor view
model. Value types made of Sendable parts are Sendable for free.
Step 2 — the weather-code map
The API reports conditions as a WMO integer code. Turn it into an SF Symbol (with day/night variants for clear and partly-cloudy) and a human label — a pure function, easy to test:
enum WeatherCode { static func symbol(_ code: Int, isDay: Bool = true) -> String { switch code { case 0: isDay ? "sun.max.fill" : "moon.stars.fill" case 1, 2: isDay ? "cloud.sun.fill" : "cloud.moon.fill" case 3: "cloud.fill" case 45, 48: "cloud.fog.fill" case 51...57: "cloud.drizzle.fill" case 61...67: "cloud.rain.fill" case 71...77: "cloud.snow.fill" case 80...82: "cloud.heavyrain.fill" case 95...99: "cloud.bolt.rain.fill" default: "cloud.fill" } } static func label(_ code: Int) -> String { switch code { case 0: "Clear" case 1: "Mainly Clear" case 2: "Partly Cloudy" case 3: "Overcast" case 45, 48: "Fog" case 51...57: "Drizzle" case 61...67: "Rain" case 71...77: "Snow" case 80...82: "Rain Showers" case 95...99: "Thunderstorm" default: "Cloudy" } } }
Step 3 — the endpoint
Build the URL in one value type, not with string interpolation at the call site. It asks for the exact fields the model decodes, in unix time, ten days out:
struct Endpoint { var path: String var queryItems: [URLQueryItem] var url: URL { var components = URLComponents() components.scheme = "https" components.host = "api.open-meteo.com" components.path = path components.queryItems = queryItems return components.url! } static func forecast(latitude: Double, longitude: Double) -> Endpoint { Endpoint( path: "/v1/forecast", queryItems: [ .init(name: "latitude", value: String(latitude)), .init(name: "longitude", value: String(longitude)), .init(name: "current", value: "temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m,uv_index,is_day"), .init(name: "hourly", value: "temperature_2m,weather_code,precipitation_probability"), .init(name: "daily", value: "temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset"), .init(name: "timeformat", value: "unixtime"), .init(name: "forecast_days", value: "10"), ] ) } }
Step 4 — the typed client
Errors get a type so the view model can turn each into a specific message. The client wraps a
transport failure, a non-2xx status, and a decode failure distinctly, and sets the date
strategy to match timeformat=unixtime:
enum APIError: Error { case invalidResponse(Int) case decoding(Error) case transport(Error) } struct APIClient { var session: URLSession = .shared func fetch<T: Decodable>(_ type: T.Type, from endpoint: Endpoint) async throws -> T { let data: Data let response: URLResponse do { (data, response) = try await session.data(from: endpoint.url) } catch { throw APIError.transport(error) } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let code = (response as? HTTPURLResponse)?.statusCode ?? -1 throw APIError.invalidResponse(code) } do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 return try decoder.decode(T.self, from: data) } catch { throw APIError.decoding(error) } } func forecast(latitude: Double, longitude: Double) async throws -> Forecast { try await fetch(Forecast.self, from: .forecast(latitude: latitude, longitude: longitude)) } }
Injecting session: URLSession = .shared is what makes this testable and what lets the
reference build serve canned JSON through a mock URLProtocol with zero changes to APIClient.
The client shouldn't know whether it's talking to Open-Meteo or a stub — it just runs the
request it's handed.
Checkpoint
Call APIClient().forecast(latitude:longitude:) from a throwaway .task and print the result.
You should:
- Decode
current,hourly, anddailywith no key-mismatch errors. - See
timearrays as realDates (the.secondsSince1970strategy). - Turn a
weatherCodeinto a symbol and a label.
Knowledge check
Q: Why do the hourly/daily sections decode into parallel arrays instead of an array of
per-hour structs?
That's the shape the API returns — column-major arrays keyed by field. You zip them by index in
the UI (time[i], temperature[i], weatherCode[i]) rather than fighting the wire format;
decoding to match the payload is simpler and less brittle than reshaping it mid-decode.
Q: What does the injected session buy you?
Testability and offline rendering. A real URLSession hits the network; a session configured
with a mock URLProtocol returns canned bytes — and APIClient can't tell the difference, so
your tests and previews run without a live API.