chore: initial domain and infrastructure layers
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// The Swift Programming Language
|
|
||||||
// https://docs.swift.org/swift-book
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,11 @@ import Testing
|
|||||||
// Swift Testing Documentation
|
// Swift Testing Documentation
|
||||||
// https://developer.apple.com/documentation/testing
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TempestasInfrastructure",
|
name: "TempestasInfrastructure",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
.library(
|
.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 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.
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
.target(
|
.target(
|
||||||
name: "TempestasInfrastructure"
|
name: "TempestasInfrastructure",
|
||||||
|
dependencies: [
|
||||||
|
"TempestasDomain"
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "TempestasInfrastructureTests",
|
name: "TempestasInfrastructureTests",
|
||||||
|
|||||||
+78
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
protocol WeatherApiClient: Sendable {
|
||||||
|
func request(latitude: Double, longitude: Double) async throws -> WeatherApiResponseContract?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WeatherApiClientError: Error {
|
||||||
|
case decodingFailed(DecodingError)
|
||||||
|
}
|
||||||
+62
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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<WeatherResponseRecord>(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
// The Swift Programming Language
|
|
||||||
// https://docs.swift.org/swift-book
|
|
||||||
+41
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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<WeatherResponseRecord>())
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user