diff --git a/Package.swift b/Package.swift index b5454b5..321b352 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( - name: "Forecast", + name: "Weather", platforms: [ .macOS(.v10_15), .iOS(.v13), @@ -14,8 +14,8 @@ let package = Package( products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( - name: "Forecast", - targets: ["Forecast"]), + name: "Weather", + targets: ["Weather"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -25,10 +25,10 @@ let package = Package( // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( - name: "Forecast", + name: "Weather", dependencies: []), .testTarget( - name: "ForecastTests", - dependencies: ["Forecast"]), + name: "WeatherTests", + dependencies: ["Weather"]), ] ) diff --git a/README.md b/README.md index 687b00a..a4bf886 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ -# Forecast +# Weather This package is a wrapper for the PMP3g API provided by [SMHI](https://smhi.se). ### Usage -Add Forecast to your `Package.swift` manifest. +Add Weather o your `Package.swift` manifest. ```swift ... /// Append the package to the list of dependencies dependencies: [ - .package(url: "https://github.com/devmaximilian/Forecast.git", from: "1.0.0") + .package(url: "https://github.com/devmaximilian/Weather.git", from: "1.0.0") ], /// Append the library to the list of target dependencies targets: [ .target( name: "MyProject", - dependencies: ["Forecast"]) + dependencies: ["Weather"]) ] ... ``` @@ -28,16 +28,13 @@ Note that this is just a simple example demonstrating how the library can be use var cancellables: [String: AnyCancellable] = [:] /// Request the current weather forecast for Stockholm -let forecastPublisher = ForecastPublisher(latitude: 59.3258414, longitude: 17.7018733) +let weatherPublisher = Weather.publisher(latitude: 59.3258414, longitude: 17.7018733) -cancellables["forecast-request"] = forecastPublisher +cancellables["weather"] = weatherPublisher .assertNoFailure() - .sink { observation in - /// Use the current forecast - guard let forecast = observation.timeSeries.current else { return } - - /// Get the air temperature - let temperature = forecast[.airTemperature] + .sink { weather in + /// Get the current air temperature + let temperature = observation.get(\.value, for: .t) /// Use the air temperature in some way print("It is currently \(temperature)°C") diff --git a/Sources/Forecast/ForecastPublisher.swift b/Sources/Forecast/ForecastPublisher.swift deleted file mode 100644 index c03822b..0000000 --- a/Sources/Forecast/ForecastPublisher.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// ForecastPublisher.swift -// -// Copyright (c) 2019 Maximilian Wendel -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the Software), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import class Foundation.HTTPURLResponse -import class Foundation.URLResponse -import class Foundation.JSONDecoder -import class Foundation.URLSession -import struct Foundation.URLError -import struct Foundation.Data -import struct Foundation.URL -import func Foundation.pow -import protocol Combine.Publisher -import protocol Combine.Subscriber -import class Combine.PassthroughSubject -import class Combine.AnyCancellable -import struct Combine.AnyPublisher -import enum Combine.Subscribers -#if canImport(CoreLocation) -import struct CoreLocation.CLLocationCoordinate2D -import class CoreLocation.CLLocation -#endif - - -/// The service endpoint to send requests to -@inline(__always) -fileprivate let endpoint: String = "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/{LONGITUDE}/lat/{LATITUDE}/data.json" - -/// A service that provides weather forecasts -public class ForecastPublisher: Publisher { - public typealias Output = Observation - public typealias Failure = Error - - private let subject: PassthroughSubject = .init() - private var cancellables: [String: AnyCancellable] = [:] - private let latitude: Double - private let longitude: Double - private var configured: Bool = false - - /// Get the weather forecast `Observation` for a specific set of coordinates - /// - Note: See https://www.smhi.se/data/utforskaren-oppna-data/meteorologisk-prognosmodell-pmp3g-2-8-km-upplosning-api - /// for information about limitations (such as coordinate limitations) - /// - Parameters: - /// - latitude: The coordinate latitude - /// - longitude: The coordinate longitude - public init(latitude: Double, longitude: Double) { - self.latitude = latitude - self.longitude = longitude - } - - #if canImport(CoreLocation) - /// Get the weather forecast `Observation` for a specific set of coordinates - /// - Note: See https://www.smhi.se/data/utforskaren-oppna-data/meteorologisk-prognosmodell-pmp3g-2-8-km-upplosning-api - /// for information about limitations (such as coordinate limitations) - /// - Parameters: - /// - coordinate: An instance of `CLLocationCoordinate2D` - public convenience init(coordinate: CLLocationCoordinate2D) { - self.init(latitude: coordinate.latitude, longitude: coordinate.longitude) - } - - /// Get the weather forecast `Observation` for a specific set of coordinates - /// - Note: See https://www.smhi.se/data/utforskaren-oppna-data/meteorologisk-prognosmodell-pmp3g-2-8-km-upplosning-api - /// for information about limitations (such as coordinate limitations) - /// - Parameters: - /// - location: An instance of `CLLocation` - public convenience init(location: CLLocation) { - self.init(coordinate: location.coordinate) - } - #endif - - /// Attaches the specified subscriber to this publisher. - /// - /// Implementations of ``Publisher`` must implement this method. - /// - /// The provided implementation of ``Publisher/subscribe(_:)``calls this method. - /// - /// - Parameter subscriber: The subscriber to attach to this ``Publisher``, after which it can receive values. - public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { - self.subject.receive(subscriber: subscriber) - - guard self.configured == false else { - return - } - - self.configured = true - self.configure() - } -} - -// MARK: - Private methods - -extension ForecastPublisher { - // Called upon first subscription - private func configure() { - let url = self.makeURL(latitude: latitude, longitude: longitude) - - self.cancellables["request"] = self.request(url) - .sink(receiveCompletion: { [weak self] completion in - guard let self = self, case .failure = completion else { return } - - self.subject.send(completion: completion) - }, receiveValue: { [weak self] input in - guard let self = self else { return } - - self.subject.send(input) - }) - } - - private func request(_ url: URL) -> AnyPublisher { - return URLSession.shared.dataTaskPublisher(for: url) - .tryMap { (data: Data, response: URLResponse) -> (data: Data, response: HTTPURLResponse) in - guard let response = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - // Check status code - guard 200...299 ~= response.statusCode else { - throw URLError(.init(rawValue: response.statusCode)) - } - return (data, response) - } - .map { (data: Data, response: URLResponse) -> Data in - return data - } - .decode(type: Output.self, decoder: JSONDecoder(dateDecodingStrategy: .iso8601)) - .eraseToAnyPublisher() - } - - private func makeURL(latitude: Double, longitude: Double) -> URL { - // Remove decimals exceeding six positions as it will cause a 404 response - let latitude = latitude.rounded(toPrecision: 6) - let longitude = longitude.rounded(toPrecision: 6) - - let stringURL = endpoint - .replacingOccurrences( - of: "{LONGITUDE}", - with: longitude.description - ) - .replacingOccurrences( - of: "{LATITUDE}", - with: latitude.description - ) - - return URL(string: stringURL) - .unsafelyUnwrapped - } -} - -// MARK: Extensions - -extension JSONDecoder { - fileprivate convenience init(dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) { - self.init() - - self.dateDecodingStrategy = dateDecodingStrategy - } -} - -/// An extension to add the rounded method -extension Double { - /// Rounds the `Double` to a specified precision-level (number of decimals) - /// - Note: This method is present as the forecast service only accepts a maximum of six decimals - /// - Parameter precision: The precision-level to use - fileprivate func rounded(toPrecision precision: Int) -> Double { - let multiplier: Double = pow(10, Double(precision)) - return (self * multiplier).rounded() / multiplier - } -} diff --git a/Sources/Forecast/Models/Forecast.swift b/Sources/Weather/Models/Forecast.swift similarity index 100% rename from Sources/Forecast/Models/Forecast.swift rename to Sources/Weather/Models/Forecast.swift diff --git a/Sources/Forecast/Models/Geometry.swift b/Sources/Weather/Models/Geometry.swift similarity index 100% rename from Sources/Forecast/Models/Geometry.swift rename to Sources/Weather/Models/Geometry.swift diff --git a/Sources/Forecast/Models/Level.swift b/Sources/Weather/Models/Level.swift similarity index 100% rename from Sources/Forecast/Models/Level.swift rename to Sources/Weather/Models/Level.swift diff --git a/Sources/Forecast/Models/Parameter+Name.swift b/Sources/Weather/Models/Parameter+Name.swift similarity index 100% rename from Sources/Forecast/Models/Parameter+Name.swift rename to Sources/Weather/Models/Parameter+Name.swift diff --git a/Sources/Forecast/Models/Parameter.swift b/Sources/Weather/Models/Parameter.swift similarity index 98% rename from Sources/Forecast/Models/Parameter.swift rename to Sources/Weather/Models/Parameter.swift index 898c08f..4f14f70 100644 --- a/Sources/Forecast/Models/Parameter.swift +++ b/Sources/Weather/Models/Parameter.swift @@ -1,5 +1,5 @@ // -// Value.swift +// Parameter.swift // // Copyright (c) 2019 Maximilian Wendel // diff --git a/Sources/Forecast/Models/Observation.swift b/Sources/Weather/Models/Weather.swift similarity index 92% rename from Sources/Forecast/Models/Observation.swift rename to Sources/Weather/Models/Weather.swift index b08d88e..38d7f7b 100644 --- a/Sources/Forecast/Models/Observation.swift +++ b/Sources/Weather/Models/Weather.swift @@ -22,11 +22,11 @@ // SOFTWARE. // -import struct Foundation.Date +import Foundation /// An `Observation` is a collection of `Forecast` instances -public struct Observation: Decodable { +public struct Weather: Decodable { /// A timestamp for when the `Forecast` was approved private let approvedTime: Date @@ -41,7 +41,7 @@ public struct Observation: Decodable { } /// An extension to house convenience attributes -extension Observation { +extension Weather { /// - Returns: Whether or not the forecast is valid for the current date public var isRelevant: Bool { let now = Date() @@ -91,7 +91,7 @@ extension Forecast { } } -extension Observation { +extension Weather { fileprivate var forecast: Forecast { return self.get() ?? Forecast(validTime: .distantPast, parameters: []) } @@ -122,3 +122,12 @@ extension Observation { } } } + +extension Weather { + public static func publisher(latitude: Double, longitude: Double) -> WeatherPublisher { + return WeatherPublisher( + latitude: latitude, + longitude: longitude + ) + } +} diff --git a/Sources/Weather/WeatherPublisher.swift b/Sources/Weather/WeatherPublisher.swift new file mode 100644 index 0000000..f78d8fb --- /dev/null +++ b/Sources/Weather/WeatherPublisher.swift @@ -0,0 +1,154 @@ +// +// WeatherPublisher.swift +// +// Copyright (c) 2019 Maximilian Wendel +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Combine +#if canImport(CoreLocation) +import CoreLocation +#endif + +extension Weather { + public typealias WeatherPublisher = _WeatherPublisher +} + +public struct _WeatherPublisher: Publisher { + public typealias Output = Weather + public typealias Failure = Error + + typealias Upstream = AnyPublisher<(data: Data, response: URLResponse), Error> + + private let latitude: Double + private let longitude: Double + + private var request: URLRequest { + return URLRequest( + url: URL( + string: "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/\(longitude)/lat/\(latitude)/data.json" + )! + ) + } + + init(latitude: Double, longitude: Double) { + self.latitude = latitude.rounded(toPrecision: 6) + self.longitude = longitude.rounded(toPrecision: 6) + } + + public func receive(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input { + let subscription = WeatherSubscription(subscriber: subscriber) + subscription.configure( + request: request + ) + subscriber.receive(subscription: subscription) + } +} + +// MARK: Subscription +extension _WeatherPublisher { + fileprivate final class WeatherSubscription: Subscription, Subscriber where S.Input == Output, S.Failure == Failure { + typealias Input = Upstream.Output + + private var upstreamSubscription: Subscription? + private var downstreamSubscriber: S? + + init(subscriber: S) { + self.downstreamSubscriber = subscriber + } + + func request(_ demand: Subscribers.Demand) { + guard let upstream = self.upstreamSubscription else { return } + + upstream.request(demand) + } + + func cancel() { + self.downstreamSubscriber = nil + self.upstreamSubscription = nil + } + + func receive(subscription: Subscription) { + self.upstreamSubscription = subscription + } + + func receive(_ input: Upstream.Output) -> Subscribers.Demand { + guard let downstream = self.downstreamSubscriber else { return .none } + + do { + let decoder = JSONDecoder(dateDecodingStrategy: .iso8601) + let output = try decoder.decode(Output.self, from: input.data) + return downstream.receive(output) + } catch { + downstream.receive(completion: .failure(error)) + } + + return .none + } + + func receive(completion: Subscribers.Completion) { + self.upstreamSubscription = nil + + guard let downstream = self.downstreamSubscriber else { + return + } + downstream.receive(completion: completion) + } + + func configure(request: URLRequest) { + URLSession.shared.dataTaskPublisher( + for: request + ) + .tryMap { (data: Data, response: URLResponse) -> (data: Data, response: HTTPURLResponse) in + guard let response = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + // Check status code + guard 200...299 ~= response.statusCode else { + throw URLError(.init(rawValue: response.statusCode)) + } + return (data, response) + } + .subscribe(self) + } + } +} + +// MARK: Extensions + +extension JSONDecoder { + fileprivate convenience init(dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) { + self.init() + + self.dateDecodingStrategy = dateDecodingStrategy + } +} + +/// An extension to add the rounded method +extension Double { + /// Rounds the `Double` to a specified precision-level (number of decimals) + /// - Note: This method is present as the forecast service only accepts a maximum of six decimals + /// - Parameter precision: The precision-level to use + fileprivate func rounded(toPrecision precision: Int) -> Double { + let multiplier: Double = pow(10, Double(precision)) + return (self * multiplier).rounded() / multiplier + } +} diff --git a/Tests/ForecastTests/ForecastPublisherTests.swift b/Tests/ForecastTests/ForecastPublisherTests.swift deleted file mode 100644 index a4fd4bd..0000000 --- a/Tests/ForecastTests/ForecastPublisherTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import Forecast -import XCTest -import Combine - -class ForecastPublisherTests: XCTestCase { - let forecastPublisher: ForecastPublisher = ForecastPublisher(latitude: 59.3258414, longitude: 17.7018733) - var cancellables: [String: AnyCancellable] = [:] - - func testForecastPublisher() { - let returnForecastObservation = expectation(description: "") - - cancellables["request"] = forecastPublisher - .sink(receiveCompletion: { completion in - guard case .failure(let error) = completion else { - return - } - XCTFail(error.localizedDescription) - returnForecastObservation.fulfill() - }, receiveValue: { observation in - returnForecastObservation.fulfill() - }) - - wait(for: [returnForecastObservation], timeout: 10) - } - -} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index dda336e..003a8b0 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,7 +1,7 @@ import XCTest -import ForecastTests +import WeatherTests var tests = [XCTestCaseEntry]() -tests += ForecastTests.allTests() +tests += WeatherTests.allTests() XCTMain(tests) diff --git a/Tests/WeatherTests/WeatherPublisherTests.swift b/Tests/WeatherTests/WeatherPublisherTests.swift new file mode 100644 index 0000000..b392ee8 --- /dev/null +++ b/Tests/WeatherTests/WeatherPublisherTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Weather +import XCTest +import Combine + +class WeatherPublisherTests: XCTestCase { + let weatherPublisher: Weather.WeatherPublisher = Weather.publisher(latitude: 59.3258414, longitude: 17.7018733) + var cancellables: [String: AnyCancellable] = [:] + + func testWeatherPublisher() { + let returnWeather = expectation(description: "") + + cancellables["request"] = weatherPublisher + .sink(receiveCompletion: { completion in + guard case .failure(let error) = completion else { + return + } + XCTFail(error.localizedDescription) + returnWeather.fulfill() + }, receiveValue: { observation in + returnWeather.fulfill() + }) + + wait(for: [returnWeather], timeout: 10) + } +} diff --git a/Tests/ForecastTests/XCTestManifests.swift b/Tests/WeatherTests/XCTestManifests.swift similarity index 100% rename from Tests/ForecastTests/XCTestManifests.swift rename to Tests/WeatherTests/XCTestManifests.swift