From 5893b9f99e559405fc975f08dda1ff1785ed50ea Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Wed, 5 Aug 2026 08:07:41 -0400 Subject: [PATCH] chore: initial domain and infrastructure layers Signed-off-by: Alan Brault --- .../Entities/DailyForecast.swift | 43 ++++++++++ .../TempestasDomain/Entities/Forecast.swift | 55 +++++++++++++ .../TempestasDomain/Enums/WeatherCode.swift | 63 +++++++++++++++ .../Repositories/ForecastRepository.swift | 9 +++ .../TempestasDomain/TempestasDomain.swift | 2 - .../TempestasDomain/Types/DateOnly.swift | 47 +++++++++++ .../TempestasDomainTests.swift | 8 ++ .../TempestasInfrastructure/Package.swift | 8 +- .../Clients/OpenMeteoApiClient.swift | 78 +++++++++++++++++++ .../Clients/WeatherApiClient.swift | 7 ++ .../Mappers/ForecastRecordMapper.swift | 62 +++++++++++++++ .../Mappers/WeatherResponseRecordMapper.swift | 55 +++++++++++++ .../Network/ForecastResponseContract.swift | 34 ++++++++ .../Network/OpenMeteoDateDecoding.swift | 15 ++++ .../Network/WeatherApiResponseContract.swift | 33 ++++++++ .../Persistence/DailyForecastRecord.swift | 59 ++++++++++++++ .../Persistence/WeatherResponseRecord.swift | 27 +++++++ .../DefaultForecastRepository.swift | 65 ++++++++++++++++ .../TempestasInfrastructure.swift | 2 - .../OpenMeteoDateDecodingTests.swift | 41 ++++++++++ .../WeatherResponseRecordMapperTests.swift | 66 ++++++++++++++++ 21 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 Packages/TempestasDomain/Sources/TempestasDomain/Entities/DailyForecast.swift create mode 100644 Packages/TempestasDomain/Sources/TempestasDomain/Entities/Forecast.swift create mode 100644 Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift create mode 100644 Packages/TempestasDomain/Sources/TempestasDomain/Repositories/ForecastRepository.swift delete mode 100644 Packages/TempestasDomain/Sources/TempestasDomain/TempestasDomain.swift create mode 100644 Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/ForecastRecordMapper.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/WeatherResponseRecordMapper.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/ForecastResponseContract.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/OpenMeteoDateDecoding.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/WeatherApiResponseContract.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/DailyForecastRecord.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/WeatherResponseRecord.swift create mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift delete mode 100644 Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/TempestasInfrastructure.swift create mode 100644 Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift create mode 100644 Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Entities/DailyForecast.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/DailyForecast.swift new file mode 100644 index 0000000..a85d95d --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/DailyForecast.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct DailyForecast: Sendable, Hashable { + public let dewPoint: Double + public let humidity: Int + public let sunrise: Date + public let sunset: Date + public let surfacePressure: Double + public let temperature: Double + public let uvIndex: Double + public let visibility: Double + public let weatherCode: WeatherCode + public let windDirection: Int + public let windGusts: Double + public let windSpeed: Double + + public init(dewPoint: Double, + humidity: Int, + sunrise: Date, + sunset: Date, + surfacePressure: Double, + temperature: Double, + uvIndex: Double, + visibility: Double, + weatherCode: WeatherCode, + windDirection: Int, + windGusts: Double, + windSpeed: Double + ) { + self.dewPoint = dewPoint + self.humidity = humidity + self.sunrise = sunrise + self.sunset = sunset + self.surfacePressure = surfacePressure + self.temperature = temperature + self.uvIndex = uvIndex + self.visibility = visibility + self.weatherCode = weatherCode + self.windDirection = windDirection + self.windGusts = windGusts + self.windSpeed = windSpeed + } +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Entities/Forecast.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/Forecast.swift new file mode 100644 index 0000000..ff43e7d --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/Forecast.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct Forecast: Sendable, Hashable { + public let dewPoint: Double + public let futureForecasts: [DateOnly: DailyForecast] + public let humidity: Int + public let latitude: Double + public let longitude: Double + public let sunrise: Date + public let sunset: Date + public let surfacePressure: Double + public let temperature: Double + public let timeZone: TimeZone + public let uvIndex: Double + public let visibility: Double + public let weatherCode: WeatherCode + public let windDirection: Int + public let windGusts: Double + public let windSpeed: Double + + public init(dewPoint: Double, + futureForecasts: [DateOnly: DailyForecast] = [:], + humidity: Int, + latitude: Double, + longitude: Double, + sunrise: Date, + sunset: Date, + surfacePressure: Double, + temperature: Double, + timeZone: TimeZone, + uvIndex: Double, + visibility: Double, + weatherCode: WeatherCode, + windDirection: Int, + windGusts: Double, + windSpeed: Double + ) { + self.dewPoint = dewPoint + self.futureForecasts = futureForecasts + self.humidity = humidity + self.latitude = latitude + self.longitude = longitude + self.sunrise = sunrise + self.sunset = sunset + self.surfacePressure = surfacePressure + self.temperature = temperature + self.timeZone = timeZone + self.uvIndex = uvIndex + self.visibility = visibility + self.weatherCode = weatherCode + self.windDirection = windDirection + self.windGusts = windGusts + self.windSpeed = windSpeed + } +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift new file mode 100644 index 0000000..3fc83e3 --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift @@ -0,0 +1,63 @@ +import Foundation + +public enum WeatherCode: Sendable, Hashable, Decodable { + case clearSky + case mainlyClear + case partlyCloudy + case overcast + case fog + case drizzle + case rain + case freezingRain + case rainShowers + case snow + case snowShowers + case thunderstorm + case thunderstormWithHail + case unknown(code: Int) + + public static func from(code: Int) -> WeatherCode { + switch code { + case 0: .clearSky + case 1: .mainlyClear + case 2: .partlyCloudy + case 3: .overcast + case 45, 48: .fog + case 51, 53, 55, 56, 57: .drizzle + case 61, 63, 65: .rain + case 66, 67: .freezingRain + case 71, 73, 75, 755: .snow + case 85, 86: .snowShowers + case 95: .thunderstorm + case 96, 99: .thunderstormWithHail + default: .unknown(code: code) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let code = try container.decode(Int.self) + self = .from(code: code) + } +} + +public extension WeatherCode { + var code: Int { + switch self { + case .clearSky: 0 + case .mainlyClear: 1 + case .partlyCloudy: 2 + case .overcast: 3 + case .fog: 45 + case .drizzle: 51 + case .rain: 61 + case .freezingRain: 66 + case .rainShowers: 80 + case .snow: 71 + case .snowShowers: 85 + case .thunderstorm: 95 + case .thunderstormWithHail: 96 + case .unknown(let code): code + } + } +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Repositories/ForecastRepository.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Repositories/ForecastRepository.swift new file mode 100644 index 0000000..62c35d8 --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Repositories/ForecastRepository.swift @@ -0,0 +1,9 @@ +public protocol ForecastRepository: Sendable { + func get(latitude: Double, longitude: Double, forceRefresh: Bool) async throws -> Forecast? +} + +public extension ForecastRepository { + func get(latitude: Double, longitude: Double, forceRefresh: Bool = false) async throws -> Forecast? { + try await get(latitude: latitude, longitude: longitude, forceRefresh: forceRefresh) + } +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/TempestasDomain.swift b/Packages/TempestasDomain/Sources/TempestasDomain/TempestasDomain.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Packages/TempestasDomain/Sources/TempestasDomain/TempestasDomain.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift new file mode 100644 index 0000000..bea87ec --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct DateOnly: Sendable, Hashable, Comparable, Codable { + public let year: Int + public let month: Int + public let day: Int + + public init(year: Int, month: Int, day: Int) { + self.year = year + self.month = month + self.day = day + } + + public init(date: Date, timeZone: TimeZone) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + + let components = calendar.dateComponents([.year, .month, .day], from: date) + self.init(year: components.year!, month: components.month!, day: components.day!) + } + + public static func < (lhs: DateOnly, rhs: DateOnly) -> Bool { + (lhs.year, lhs.month, lhs.day) < (rhs.year, rhs.month, rhs.day) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + let components = string.split(separator: "-") + + guard components.count == 3, + let year = Int(components[0]), + let month = Int(components[1]), + let day = Int(components[2]) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid date format: \(string)") + } + + self.init(year: year, month: month, day: day) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(String(format: "%04d-%02d-%02d", year, month, day)) + } +} diff --git a/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift b/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift index 7163f27..f7ed038 100644 --- a/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift +++ b/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift @@ -6,3 +6,11 @@ import Testing // Swift Testing Documentation // https://developer.apple.com/documentation/testing } + +@Test func weatherCodeRoundTripsKnownCode() async throws { + #expect(WeatherCode.from(code: 61).code == 61) +} + +@Test func weatherCodePreservesUnknownCode() async throws { + #expect(WeatherCode.unknown(code: 12345).code == 12345) +} diff --git a/Packages/TempestasInfrastructure/Package.swift b/Packages/TempestasInfrastructure/Package.swift index c565332..bf80d7d 100644 --- a/Packages/TempestasInfrastructure/Package.swift +++ b/Packages/TempestasInfrastructure/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "TempestasInfrastructure", + platforms: [ + .macOS(.v14) + ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( @@ -19,7 +22,10 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "TempestasInfrastructure" + name: "TempestasInfrastructure", + dependencies: [ + "TempestasDomain" + ] ), .testTarget( name: "TempestasInfrastructureTests", diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift new file mode 100644 index 0000000..66b82c1 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift @@ -0,0 +1,78 @@ +import Foundation + +struct OpenMeteoApiClient: WeatherApiClient { + private static let baseURL = URL(string: "https://api.open-meteo.com/v1/forecast")! + private static let dailyParameters = [ + "apparent_temperature_max", + "dew_point_2m_max", + "relative_humidity_2m_min", + "sunrise", + "sunset", + "surface_pressure_min", + "uv_index_max", + "visibility_mean", + "weather_code", + "wind_direction_10m_dominant", + "wind_gusts_10m_min", + "wind_speed_10m_min" + ].joined(separator: ",") + + private let locale: Locale + private let session: URLSession + private let timeZone: TimeZone + private let decoder: JSONDecoder + + private var temperatureUnit: String { + switch locale.measurementSystem { + case .metric: "celsius" + default: "fahrenheit" + } + } + + private var windSpeedUnit: String { + switch locale.measurementSystem { + case .metric: "kmh" + default: "mph" + } + } + + private var precipitationUnit: String { + switch locale.measurementSystem { + case .metric: "mm" + default: "inch" + } + } + + init(locale: Locale = .current, session: URLSession = .shared, timeZone: TimeZone = .current) { + self.locale = locale + self.session = session + self.timeZone = timeZone + self.decoder = OpenMeteoDateDecoding.decoder(timeZone: timeZone) + } + + func request(latitude: Double, longitude: Double) async throws -> WeatherApiResponseContract? { + var components = URLComponents(url: Self.baseURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "latitude", value: String(latitude)), + URLQueryItem(name: "longitude", value: String(longitude)), + URLQueryItem(name: "daily", value: Self.dailyParameters), + URLQueryItem(name: "timezone", value: timeZone.identifier), + URLQueryItem(name: "temperature_unit", value: temperatureUnit), + URLQueryItem(name: "wind_speed_unit", value: windSpeedUnit), + URLQueryItem(name: "precipitation_unit", value: precipitationUnit) + ] + + let (data, response) = try await session.data(from: components.url!) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + return nil + } + + do { + return try decoder.decode(WeatherApiResponseContract.self, from: data) + } catch let error as DecodingError { + throw WeatherApiClientError.decodingFailed(error) + } + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift new file mode 100644 index 0000000..70eef39 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift @@ -0,0 +1,7 @@ +protocol WeatherApiClient: Sendable { + func request(latitude: Double, longitude: Double) async throws -> WeatherApiResponseContract? +} + +enum WeatherApiClientError: Error { + case decodingFailed(DecodingError) +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/ForecastRecordMapper.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/ForecastRecordMapper.swift new file mode 100644 index 0000000..b58a968 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/ForecastRecordMapper.swift @@ -0,0 +1,62 @@ +import Foundation +import TempestasDomain + +final class ForecastRecordMapper { + private init() {} + + static func map(_ source: WeatherResponseRecord) -> Forecast { + let timeZone = TimeZone(identifier: source.timeZoneIdentifier) ?? .current + let todayDate = DateOnly(date: Date(), timeZone: timeZone) + let dailyForecasts = source.dailyForecasts + + let todayIndex = dailyForecasts.firstIndex { + DateOnly(year: $0.year, month: $0.month, day: $0.day) == todayDate + } ?? 0 + + var futureForecasts: [DateOnly: DailyForecast] = [:] + + for index in dailyForecasts.indices where index != todayIndex { + let record = dailyForecasts[index] + let date = DateOnly(year: record.year, month: record.month, day: record.day) + futureForecasts[date] = mapDailyForecast(record) + } + + let todayForecast = mapDailyForecast(dailyForecasts[todayIndex]) + + return Forecast( + dewPoint: todayForecast.dewPoint, + futureForecasts: futureForecasts, + humidity: todayForecast.humidity, + latitude: source.latitude, + longitude: source.longitude, + sunrise: todayForecast.sunrise, + sunset: todayForecast.sunset, + surfacePressure: todayForecast.surfacePressure, + temperature: todayForecast.temperature, + timeZone: timeZone, + uvIndex: todayForecast.uvIndex, + visibility: todayForecast.visibility, + weatherCode: todayForecast.weatherCode, + windDirection: todayForecast.windDirection, + windGusts: todayForecast.windGusts, + windSpeed: todayForecast.windSpeed + ) + } + + private static func mapDailyForecast(_ record: DailyForecastRecord) -> DailyForecast { + DailyForecast( + dewPoint: record.dewPoint.rounded(.toNearestOrAwayFromZero), + humidity: record.humidity, + sunrise: record.sunrise, + sunset: record.sunset, + surfacePressure: record.surfacePressure, + temperature: record.temperature.rounded(.toNearestOrAwayFromZero), + uvIndex: record.uvIndex, + visibility: record.visibility, + weatherCode: WeatherCode.from(code: record.weatherCode), + windDirection: record.windDirection, + windGusts: record.windGusts, + windSpeed: record.windSpeed + ) + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/WeatherResponseRecordMapper.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/WeatherResponseRecordMapper.swift new file mode 100644 index 0000000..7b93d32 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Mappers/WeatherResponseRecordMapper.swift @@ -0,0 +1,55 @@ +import Foundation +import TempestasDomain + +final class WeatherResponseRecordMapper { + private init() {} + + static func map( + _ source: WeatherApiResponseContract, + latitude: Double? = nil, + longitude: Double? = nil, + fetchedAt: Date = Date() + ) -> WeatherResponseRecord { + let items = source.items + + let record = WeatherResponseRecord( + latitude: latitude ?? source.latitude, + longitude: longitude ?? source.longitude, + timeZoneIdentifier: source.timeZone.identifier, + fetchedAt: fetchedAt + ) + + record.dailyForecasts = items.times.indices.map { index in + mapDailyForecast(items, at: index, response: record) + } + + return record + } + + private static func mapDailyForecast( + _ items: ForecastResponseContract, + at index: Int, + response: WeatherResponseRecord + ) -> DailyForecastRecord { + let date = items.times[index] + + return DailyForecastRecord( + year: date.year, + month: date.month, + day: date.day, + dewPoint: items.dewPoints[index], + humidity: Int(items.humidity[index].rounded()), + sunrise: items.sunriseTimes[index], + sunset: items.sunsetTimes[index], + surfacePressure: items.surfacePressures[index], + temperature: items.temperatures[index], + uvIndex: items.uvIndexes[index], + visibility: items.visibilities[index], + weatherCode: items.weatherCodes[index].code, + windDirection: items.windDirections[index], + windGusts: items.windGusts[index], + windSpeed: items.windSpeeds[index], + response: response + ) + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/ForecastResponseContract.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/ForecastResponseContract.swift new file mode 100644 index 0000000..494c114 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/ForecastResponseContract.swift @@ -0,0 +1,34 @@ +import Foundation +import TempestasDomain + +struct ForecastResponseContract: Decodable, Sendable { + let dewPoints: [Double] + let humidity: [Double] + let sunriseTimes: [Date] + let sunsetTimes: [Date] + let surfacePressures: [Double] + let temperatures: [Double] + let times: [DateOnly] + let uvIndexes: [Double] + let visibilities: [Double] + let weatherCodes: [WeatherCode] + let windDirections: [Int] + let windGusts: [Double] + let windSpeeds: [Double] + + private enum CodingKeys: String, CodingKey { + case dewPoints = "dew_point_2m_max" + case humidity = "relative_humidity_2m_min" + case sunriseTimes = "sunrise" + case sunsetTimes = "sunset" + case surfacePressures = "surface_pressure_min" + case temperatures = "apparent_temperature_max" + case times = "time" + case uvIndexes = "uv_index_max" + case visibilities = "visibility_mean" + case weatherCodes = "weather_code" + case windDirections = "wind_direction_10m_dominant" + case windGusts = "wind_gusts_10m_min" + case windSpeeds = "wind_speed_10m_min" + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/OpenMeteoDateDecoding.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/OpenMeteoDateDecoding.swift new file mode 100644 index 0000000..159fada --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/OpenMeteoDateDecoding.swift @@ -0,0 +1,15 @@ +import Foundation + +enum OpenMeteoDateDecoding { + static func decoder(timeZone: TimeZone) -> JSONDecoder { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm" + formatter.timeZone = timeZone + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(formatter) + return decoder + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/WeatherApiResponseContract.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/WeatherApiResponseContract.swift new file mode 100644 index 0000000..f91b6db --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Network/WeatherApiResponseContract.swift @@ -0,0 +1,33 @@ +import Foundation + +struct WeatherApiResponseContract: Sendable, Decodable { + let items: ForecastResponseContract + let latitude: Double + let longitude: Double + let timeZone: TimeZone + + private enum CodingKeys: String, CodingKey { + case items = "daily" + case latitude + case longitude + case timeZone = "timezone" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + items = try container.decode(ForecastResponseContract.self, forKey: .items) + latitude = try container.decode(Double.self, forKey: .latitude) + longitude = try container.decode(Double.self, forKey: .longitude) + + let tzIdentifier = try container.decode(String.self, forKey: .timeZone) + guard let tz = TimeZone(identifier: tzIdentifier) else { + throw DecodingError.dataCorruptedError( + forKey: .timeZone, + in: container, + debugDescription: "Invalid time zone identifier: \(tzIdentifier)") + } + + timeZone = tz + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/DailyForecastRecord.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/DailyForecastRecord.swift new file mode 100644 index 0000000..e7b9529 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/DailyForecastRecord.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftData + +@Model +public final class DailyForecastRecord { + public var year: Int + public var month: Int + public var day: Int + public var dewPoint: Double + public var humidity: Int + public var sunrise: Date + public var sunset: Date + public var surfacePressure: Double + public var temperature: Double + public var uvIndex: Double + public var visibility: Double + public var weatherCode: Int + public var windDirection: Int + public var windGusts: Double + public var windSpeed: Double + + public var response: WeatherResponseRecord? + + public init( + year: Int, + month: Int, + day: Int, + dewPoint: Double, + humidity: Int, + sunrise: Date, + sunset: Date, + surfacePressure: Double, + temperature: Double, + uvIndex: Double, + visibility: Double, + weatherCode: Int, + windDirection: Int, + windGusts: Double, + windSpeed: Double, + response: WeatherResponseRecord? = nil + ) { + self.year = year + self.month = month + self.day = day + self.dewPoint = dewPoint + self.humidity = humidity + self.sunrise = sunrise + self.sunset = sunset + self.surfacePressure = surfacePressure + self.temperature = temperature + self.uvIndex = uvIndex + self.visibility = visibility + self.weatherCode = weatherCode + self.windDirection = windDirection + self.windGusts = windGusts + self.windSpeed = windSpeed + self.response = response + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/WeatherResponseRecord.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/WeatherResponseRecord.swift new file mode 100644 index 0000000..381c219 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Persistence/WeatherResponseRecord.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftData + +@Model +public final class WeatherResponseRecord { + public var latitude: Double + public var longitude: Double + public var timeZoneIdentifier: String + public var fetchedAt: Date + + @Relationship(deleteRule: .cascade, inverse: \DailyForecastRecord.response) + public var dailyForecasts: [DailyForecastRecord] + + public init( + latitude: Double, + longitude: Double, + timeZoneIdentifier: String, + fetchedAt: Date = Date(), + dailyForecasts: [DailyForecastRecord] = [] + ) { + self.latitude = latitude + self.longitude = longitude + self.timeZoneIdentifier = timeZoneIdentifier + self.fetchedAt = fetchedAt + self.dailyForecasts = dailyForecasts + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift new file mode 100644 index 0000000..77152a4 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift @@ -0,0 +1,65 @@ +import Foundation +import SwiftData +import TempestasDomain + +public actor DefaultForecastRepository: ForecastRepository { + private static let cacheDuration: TimeInterval = 15 * 60 + + private let client: WeatherApiClient + private let context: ModelContext + + public init(modelContainer: ModelContainer) { + self.context = ModelContext(modelContainer) + self.client = OpenMeteoApiClient() + } + + public func get(latitude: Double, longitude: Double, forceRefresh: Bool) async throws -> Forecast? { + let records = try existingRecords(latitude: latitude, longitude: longitude) + + if !forceRefresh, let cachedRecord = freshRecord(among: records) { + return ForecastRecordMapper.map(cachedRecord) + } + + guard let response = try await client.request(latitude: latitude, longitude: longitude) else { + return nil + } + + let record = try replaceCache(records, with: response, latitude: latitude, longitude: longitude) + + return ForecastRecordMapper.map(record) + } + + private func existingRecords(latitude: Double, longitude: Double) throws -> [WeatherResponseRecord] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.latitude == latitude && $0.longitude == longitude } + ) + + return try context.fetch(descriptor) + } + + private func freshRecord(among records: [WeatherResponseRecord]) -> WeatherResponseRecord? { + let cutoff = Date().addingTimeInterval(-Self.cacheDuration) + + return records + .max(by: { $0.fetchedAt < $1.fetchedAt }) + .flatMap { $0.fetchedAt >= cutoff ? $0 : nil } + } + + private func replaceCache( + _ staleRecords: [WeatherResponseRecord], + with response: WeatherApiResponseContract, + latitude: Double, + longitude: Double + ) throws -> WeatherResponseRecord { + for staleRecord in staleRecords { + context.delete(staleRecord) + } + + let record = WeatherResponseRecordMapper.map(response, latitude: latitude, longitude: longitude) + context.insert(record) + + try context.save() + + return record + } +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/TempestasInfrastructure.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/TempestasInfrastructure.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/TempestasInfrastructure.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift new file mode 100644 index 0000000..e6de459 --- /dev/null +++ b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import TempestasInfrastructure + +@Suite struct OpenMeteoDateDecodingTests { + private static let json = """ + { + "latitude": 35.7, + "longitude": 139.7, + "timezone": "Asia/Tokyo", + "daily": { + "time": ["2024-06-01"], + "dew_point_2m_max": [15.0], + "relative_humidity_2m_min": [50.0], + "sunrise": ["2024-06-01T04:25"], + "sunset": ["2024-06-01T18:52"], + "surface_pressure_min": [1008.0], + "apparent_temperature_max": [26.0], + "uv_index_max": [7.0], + "visibility_mean": [12000.0], + "weather_code": [0], + "wind_direction_10m_dominant": [90], + "wind_gusts_10m_min": [10.0], + "wind_speed_10m_min": [8.0] + } + } + """ + + @Test func decodesTruncatedIso8601SunriseInRequestedTimeZone() throws { + let timeZone = TimeZone(identifier: "Asia/Tokyo")! + let decoder = OpenMeteoDateDecoding.decoder(timeZone: timeZone) + let contract = try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8)) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + + let components = calendar.dateComponents([.hour, .minute], from: contract.items.sunriseTimes[0]) + #expect(components.hour == 4) + #expect(components.minute == 25) + } +} diff --git a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift new file mode 100644 index 0000000..26ac8de --- /dev/null +++ b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData +import Testing +@testable import TempestasInfrastructure + +@Suite struct WeatherResponseRecordMapperTests { + private static let json = """ + { + "latitude": 45.5, + "longitude": -73.6, + "timezone": "America/Toronto", + "daily": { + "time": ["2026-08-05", "2026-08-06"], + "dew_point_2m_max": [12.3, 13.1], + "relative_humidity_2m_min": [55.4, 60.2], + "sunrise": ["2026-08-05T05:52", "2026-08-06T05:53"], + "sunset": ["2026-08-05T20:31", "2026-08-06T20:30"], + "surface_pressure_min": [1012.5, 1010.1], + "apparent_temperature_max": [24.5, 23.1], + "uv_index_max": [6.0, 5.5], + "visibility_mean": [10000.0, 9500.0], + "weather_code": [1, 61], + "wind_direction_10m_dominant": [180, 200], + "wind_gusts_10m_min": [15.0, 18.0], + "wind_speed_10m_min": [10.0, 12.0] + } + } + """ + + private func makeContract() throws -> WeatherApiResponseContract { + let decoder = OpenMeteoDateDecoding.decoder(timeZone: TimeZone(identifier: "America/Toronto")!) + return try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8)) + } + + @Test func mapperProducesOneChildPerDay() throws { + let contract = try makeContract() + let record = WeatherResponseRecordMapper.map(contract) + + #expect(record.dailyForecasts.count == 2) + #expect(record.latitude == 45.5) + #expect(record.timeZoneIdentifier == "America/Toronto") + #expect(record.dailyForecasts.allSatisfy { $0.response === record }) + } + + @Test func roundTripsThroughInMemoryModelContainer() throws { + let schema = Schema([WeatherResponseRecord.self, DailyForecastRecord.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let context = ModelContext(container) + + let contract = try makeContract() + let record = WeatherResponseRecordMapper.map(contract) + context.insert(record) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + #expect(fetched.count == 1) + #expect(fetched.first?.dailyForecasts.count == 2) + + let firstDay = fetched.first?.dailyForecasts.sorted { $0.day < $1.day }.first + #expect(firstDay?.year == 2026) + #expect(firstDay?.month == 8) + #expect(firstDay?.day == 5) + #expect(firstDay?.weatherCode == 1) + } +}