chore: initial domain and infrastructure layers
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -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",
|
||||
|
||||
+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