chore: initial domain and infrastructure layers

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-08-05 08:07:41 -04:00
parent 89b52e2286
commit 5893b9f99e
21 changed files with 774 additions and 5 deletions
@@ -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",
@@ -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)
}
}
}
@@ -0,0 +1,7 @@
protocol WeatherApiClient: Sendable {
func request(latitude: Double, longitude: Double) async throws -> WeatherApiResponseContract?
}
enum WeatherApiClientError: Error {
case decodingFailed(DecodingError)
}
@@ -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
)
}
}
@@ -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
)
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -1,2 +0,0 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
@@ -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)
}
}
@@ -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)
}
}