Project 3 · Milestone 1 — models and the API clientswift-6.4/ios-26
Lesson 2 / 5
Unit 23 · Skycast

Project 3 · Milestone 1 — models and the API client

Note

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))
    }
}
Note

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, and daily with no key-mismatch errors.
  • See time arrays as real Dates (the .secondsSince1970 strategy).
  • Turn a weatherCode into 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.