diff --git a/Package.swift b/Package.swift index decf9cb..6c47e5d 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,12 @@ import PackageDescription let package = Package( name: "MetobsKit", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6) + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( diff --git a/README.md b/README.md index 65a9e8e..eba7a65 100644 --- a/README.md +++ b/README.md @@ -25,27 +25,22 @@ targets: [ Note that this is just a simple example demonstrating how the library can be used. ```swift -/// Get an instance of the weather forecast service -let service = ForecastService.shared +var cancellables: [String: AnyCancellable] = [:] /// Request the current weather forecast for Stockholm -service.get(latitude: 59.3258414, longitude: 17.7018733) - .observe { result in - switch result { - case let .value(observation): - /// Use the current forecast - guard let forecast = observation.current else { - return - } - - /// Get the air temperature - let temperature = forecast.get(parameter: .airTemperature) - - /// Use the air temperature in some way - case let .error(error): - /// This error should be handled in a real use-case - fatalError("Failed to get forecast!") - } +let forecastPublisher = ForecastPublisher(latitude: 59.3258414, longitude: 17.7018733) + +cancellables["forecast-request"] = forecastPublisher + .assertNoFailure() + .sink { observation in + /// Use the current forecast + guard let forecast = observation.timeSeries.current else { return } + + /// Get the air temperature + let temperature = forecast[.airTemperature] + + /// Use the air temperature in some way + print("It is currently \(temperature)°C") } ``` diff --git a/Sources/MetobsKit/Exports.swift b/Sources/MetobsKit/Exports.swift deleted file mode 100644 index 4e250d2..0000000 --- a/Sources/MetobsKit/Exports.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Exports.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. -// - -@_exported import Foundation diff --git a/Sources/MetobsKit/ForecastPublisher.swift b/Sources/MetobsKit/ForecastPublisher.swift new file mode 100644 index 0000000..390dbbc --- /dev/null +++ b/Sources/MetobsKit/ForecastPublisher.swift @@ -0,0 +1,175 @@ +// +// Service.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 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 + } +} diff --git a/Sources/MetobsKit/Models/Forecast.swift b/Sources/MetobsKit/Models/Forecast.swift index 1b7296e..88b35bc 100644 --- a/Sources/MetobsKit/Models/Forecast.swift +++ b/Sources/MetobsKit/Models/Forecast.swift @@ -22,10 +22,12 @@ // SOFTWARE. // +import struct Foundation.Date + /// A `Forecast` is a collection of `Value`s for a set of `Parameter`s public struct Forecast: Codable { /// A timestamp for when the `Forecast` is valid - public let validTime: String + public let validTime: Date /// An array of `Value` instances public let parameters: [Value] @@ -37,4 +39,10 @@ public struct Forecast: Codable { value.name == parameter } ?? .unknown } + + public subscript(parameter: Parameter) -> Value { + return self.parameters.first { (value) -> Bool in + value.name == parameter + } ?? .unknown + } } diff --git a/Sources/MetobsKit/Models/Observation.swift b/Sources/MetobsKit/Models/Observation.swift index 87d5283..b241d13 100644 --- a/Sources/MetobsKit/Models/Observation.swift +++ b/Sources/MetobsKit/Models/Observation.swift @@ -22,22 +22,49 @@ // SOFTWARE. // +import struct Foundation.Date + /// An `Observation` is a collection of `Forecast` instances public struct Observation: Codable { /// A timestamp for when the `Forecast` was approved - public let approvedTime: String + public let approvedTime: Date /// A timestamp for when the `Forecast` was created or updated - public let referenceTime: String + public let referenceTime: Date /// A `Geometry` represenation for where the `Forecast` is valid public let geometry: Geometry /// An array of `Forecast` instances that reflect the overall `Forecast` over time public let timeSeries: [Forecast] +} + +/// An extension to house convenience attributes +extension Observation { + /// - Returns: Whether or not the forecast is valid for the current date + public var relevant: Bool { + let now = Date() + return self.timeSeries.contains { forecast -> Bool in + forecast.validTime > now + } + } +} + +// MARK: Extension to get current forecast - /// The current `Forecast` +extension Array where Element == Forecast { + /// - Returns: The current `Forecast` public var current: Forecast? { - return self.timeSeries.first + let now = Date() + return self.sorted(by: { $0.validTime < $1.validTime }) + .first { forecast -> Bool in + forecast.validTime >= now + } + } + + /// - Returns: The current `Forecast` + /// - Warning: This attribute is unsafe, use `current` instead. + public var unsafeCurrent: Forecast { + return self.current.unsafelyUnwrapped } } diff --git a/Sources/MetobsKit/Models/Value.swift b/Sources/MetobsKit/Models/Value.swift index 5bb00bd..19cac2e 100644 --- a/Sources/MetobsKit/Models/Value.swift +++ b/Sources/MetobsKit/Models/Value.swift @@ -38,12 +38,14 @@ public struct Value: Codable { /// An array of raw parameter values public let values: [Double] +} +extension Value { /// The first value of the raw parameter values public var value: Double { return self.values.first ?? 0 } - + /// Unknown `Value` public static var unknown: Value { return .init(name: .unknown, levelType: .unknownLevel, level: 0, unit: "", values: []) diff --git a/Sources/MetobsKit/Service.swift b/Sources/MetobsKit/Service.swift deleted file mode 100644 index e0dbb32..0000000 --- a/Sources/MetobsKit/Service.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// Service.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. -// - -/// A service that provides weather forecasts -public class ForecastService: NSObject { - // MARK: Private properties - - /// The service endpoint to send requests to - private var serviceEndpoint: String { - return "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/LONGITUDE/lat/LATITUDE/data.json" - } - - /// A shared instance of forecast service - private static var sharedInstance: ForecastService? - - // MARK: Private functions - - /// Wrapps a network request as a `Promise` - private func request(url: URL) -> Promise { - let promise = Promise() - - URLSession.shared.dataTask(with: url) { data, response, error in - // Make sure request did not result in an error - if let error = error { - return promise.reject(with: error) - } - - // Make sure an acceptable response code is present on the response - if let response = response as? HTTPURLResponse { - if 400 ... 599 ~= response.statusCode { - let error = URLError(.init(rawValue: response.statusCode)) - return promise.reject(with: error) - } - } - - // Make sure the request returned data (and not an empty response) - guard let data = data else { - return promise.reject(with: URLError(.zeroByteResource)) - } - - // Attempt to decode JSON data from the response - do { - let value: Value = try data.decoded() - return promise.resolve(with: value) - } catch { - return promise.reject(with: error) - } - }.resume() - - return promise - } - - /// Constructs a `URL` to use when requesting forecasts - /// - Parameters: - /// - latitude: The latitude to use for request - /// - longitude: The longitude to use for request - private func buildURL(latitude: Double, longitude: Double) -> Promise { - // Remove decimals exceeding six positions as it will cause a 404 response - let values: (lat: Double, lon: Double) = ( - lat: latitude.rounded(toPrecision: 6), - lon: longitude.rounded(toPrecision: 6) - ) - - let constructedURL = self.serviceEndpoint - .replacingOccurrences(of: "LONGITUDE", with: "\(values.lon)") - .replacingOccurrences(of: "LATITUDE", with: "\(values.lat)") - - // Construct the URL - guard let url = URL(string: constructedURL) else { - return Promise(value: nil, error: URLError(.badURL)) - } - - return Promise(value: url) - } - - // MARK: Public functions - - /// 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 - /// - Returns: A `Promise` with the value `Observation` - public func get(latitude: Double, longitude: Double) -> Promise { - let observationPromise: Promise = Promise() - - // Request the forecast for the provided coordinates - let urlPromise = self.buildURL(latitude: latitude, longitude: longitude) - - // Observe the request and forward the result - urlPromise.observe { urlResult in - switch urlResult { - case let .value(value): - let networkPromise: Promise = self.request(url: value) - networkPromise.observe { networkResult in - switch networkResult { - case let .value(value): - observationPromise.resolve(with: value) - case let .error(error): - observationPromise.reject(with: error) - } - } - case let .error(error): - observationPromise.reject(with: error) - } - } - - return observationPromise - } - - // MARK: Shared instance - - /// A shared forecast-service instance - public static var shared: ForecastService { - guard let existingInstance = ForecastService.sharedInstance else { - let instance = ForecastService() - ForecastService.sharedInstance = instance - return instance - } - return existingInstance - } -} diff --git a/Sources/MetobsKit/Utils.swift b/Sources/MetobsKit/Utils.swift index 7b6a90f..98aaa3f 100644 --- a/Sources/MetobsKit/Utils.swift +++ b/Sources/MetobsKit/Utils.swift @@ -22,92 +22,7 @@ // SOFTWARE. // -/// A `Future` wrapper for a `Value` -open class Future { - internal var result: Result? { - // Observe whenever a result is assigned, and report it - didSet { self.result.map(self.report) } - } - - private lazy var callbacks = [(Result) -> ()]() - - /// Subscribe to `Result` updates - /// - Parameter callback: A closure providing an updated `Result` - public func observe(with callback: @escaping (Result) -> ()) { - self.callbacks.append(callback) - - // If a result has already been set, call the callback directly - self.result.map(callback) - } - - /// Notify subscribers that the `Result` was updated - private func report(result: Result) { - for callback in self.callbacks { - callback(result) - } - } -} - -/// A `Promise` that will either be honored or broken -public final class Promise: Future { - // MARK: Initializer - - /// Initializes a new `Promise` for `Value` - /// - Parameter value: The promised `Value` - public init(value: Value? = nil, error: Error? = nil) { - super.init() - - // If the value was already known at the time the promise - // was constructed, we can report the value directly - result = value.map(Result.value) - - // If an error was already known at the time the promise - // was constructed, we can report it directly - guard let error = error else { - return - } - result = .error(error) - } - - // MARK: Public methods - - /// Resolve the `Promise`, equivalent to honoring it - /// - Parameter value: The promised `Value` - public func resolve(with value: Value) { - result = .value(value) - } - - /// Reject the `Promise`, equivalent to breaking it - /// - Parameter error: The reason the `Promise` was rejected - public func reject(with error: Error) { - result = .error(error) - } -} - -/// A `Result` for a `Future` -public enum Result { - /// Provides a `Value` - case value(Value) - - /// Provides an `Error` - case error(Error) -} - -/// An extension to the encodable protocol -public extension Encodable { - /// A method for encodable types that encodes itself - func encoded() throws -> Data { - return try JSONEncoder().encode(self) - } -} - -/// An extension to data to decode types -public extension Data { - /// A property for data that attempts to decode into the provided type - func decoded() throws -> T { - return try JSONDecoder().decode(T.self, from: self) - } -} +import func Foundation.pow /// An extension to add the rounded method extension Double { diff --git a/Tests/MetobsKitTests/ForecastPublisherTests.swift b/Tests/MetobsKitTests/ForecastPublisherTests.swift new file mode 100644 index 0000000..754041b --- /dev/null +++ b/Tests/MetobsKitTests/ForecastPublisherTests.swift @@ -0,0 +1,27 @@ +import Foundation +import MetobsKit +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) + } + +}