Skip to content
This repository has been archived by the owner on Jul 19, 2023. It is now read-only.

Commit

Permalink
Merge pull request #3 from devmaximilian/publisher
Browse files Browse the repository at this point in the history
ForecastPublisher
  • Loading branch information
devmaximilian committed Jan 2, 2021
2 parents 9185a66 + b7d381e commit 50f7326
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 280 deletions.
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 14 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
25 changes: 0 additions & 25 deletions Sources/MetobsKit/Exports.swift

This file was deleted.

175 changes: 175 additions & 0 deletions Sources/MetobsKit/ForecastPublisher.swift
Original file line number Diff line number Diff line change
@@ -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<Output, Failure> = .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<S>(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<Output, Error> {
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
}
}
10 changes: 9 additions & 1 deletion Sources/MetobsKit/Models/Forecast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
}
}
35 changes: 31 additions & 4 deletions Sources/MetobsKit/Models/Observation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
4 changes: 3 additions & 1 deletion Sources/MetobsKit/Models/Value.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])
Expand Down
Loading

0 comments on commit 50f7326

Please sign in to comment.