|
1 | 1 | import Foundation |
| 2 | +import os.log |
| 3 | +import SwiftyJSON |
2 | 4 |
|
3 | 5 | /// Bern-Lötschberg-Simplon (CH) |
4 | | -public class BlsProvider: AbstractHafasClientInterfaceProvider { |
5 | | - |
6 | | - static let API_BASE = "https://bls.hafas.de/gate" |
7 | | - static let PRODUCTS_MAP: [Product?] = [.highSpeedTrain, .highSpeedTrain, .regionalTrain, .regionalTrain, .ferry, .suburbanTrain, .bus, .cablecar, nil, .tram] |
8 | | - |
| 6 | +/// |
| 7 | +/// BLS migrated away from the HAFAS mgate interface and now uses the standardized |
| 8 | +/// Open Journey Planner (OJP) 2.0 API. See ``AbstractOjpProvider``. |
| 9 | +/// |
| 10 | +/// The BLS OJP endpoint is secured with an OAuth2 bearer token. This is a BLS-specific quirk (OJP |
| 11 | +/// itself mandates no authentication), so the token handling lives here rather than in the generic |
| 12 | +/// base class: ``authorizeRequest(_:completion:)`` fetches a token via the client-credentials grant |
| 13 | +/// on first use and caches it in memory until shortly before it expires. |
| 14 | +public class BlsProvider: AbstractOjpProvider { |
| 15 | + |
| 16 | + static let API_ENDPOINT = "https://api.bls.ch/mmzd/rest/ojp/v2.0/servicerequest" |
| 17 | + static let TOKEN_ENDPOINT = "https://fahrplan.bls.ch/token" |
| 18 | + private let tokenGrantType = "client_credentials" |
| 19 | + |
9 | 20 | public override var supportedLanguages: Set<String> { ["de", "en", "fr", "it"] } |
10 | | - |
11 | | - public init(apiAuthorization: [String: Any]) { |
12 | | - super.init(networkId: .BLS, apiBase: BlsProvider.API_BASE, productsMap: BlsProvider.PRODUCTS_MAP) |
13 | | - self.mgateEndpoint = BlsProvider.API_BASE |
14 | | - self.apiAuthorization = apiAuthorization |
15 | | - apiVersion = "1.68" |
16 | | - apiClient = ["id": "HAFAS", "type": "WEB", "name": "webapp", "l": "vs_webapp"] |
17 | | - |
| 21 | + |
| 22 | + // MARK: Token cache (guarded by `tokenQueue`) |
| 23 | + private let tokenQueue = DispatchQueue(label: "TripKit.BlsProvider.token") |
| 24 | + private var cachedToken: String? |
| 25 | + private var cachedTokenExpiry: Date? |
| 26 | + /// Completions waiting for an in-flight token fetch, so concurrent requests share one fetch. |
| 27 | + private var pendingTokenCompletions: [(Result<String, Error>) -> Void] = [] |
| 28 | + private var isFetchingToken = false |
| 29 | + /// Refresh the token this many seconds before it actually expires, to avoid races near expiry. |
| 30 | + private let tokenExpiryLeeway: TimeInterval = 30 |
| 31 | + |
| 32 | + public init() { |
| 33 | + // Accept a ready-made bearer token for backwards compatibility / tests. |
| 34 | + super.init(networkId: .BLS, apiEndpoint: BlsProvider.API_ENDPOINT) |
| 35 | + requestorRef = "BLS_IOS_SDK_1.5.0" |
| 36 | + requestHeaders["Accept"] = "application/xml" |
| 37 | + |
18 | 38 | styles = [ |
19 | 39 | // Tram |
20 | 40 | "T3": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#98bbc5"), foregroundColor: LineStyle.white), |
21 | 41 | "T6": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#0090d3"), foregroundColor: LineStyle.white), |
22 | 42 | "T7": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#ff2e18"), foregroundColor: LineStyle.white), |
23 | 43 | "T8": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#fc72b6"), foregroundColor: LineStyle.white), |
24 | 44 | "T9": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#ffbe33"), foregroundColor: LineStyle.white), |
25 | | - |
| 45 | + |
26 | 46 | // Bus |
27 | 47 | "B10": LineStyle(shape: .rounded, backgroundColor: LineStyle.parseColor("#02ab4f"), foregroundColor: LineStyle.white), |
28 | 48 | "B11": LineStyle(shape: .rounded, backgroundColor: LineStyle.parseColor("#34ccf7"), foregroundColor: LineStyle.white), |
@@ -95,17 +115,86 @@ public class BlsProvider: AbstractHafasClientInterfaceProvider { |
95 | 115 | "RRE7": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#821041"), foregroundColor: LineStyle.white), |
96 | 116 | ] |
97 | 117 | } |
98 | | - |
99 | | - override func newLine(id: String?, network: String?, product: Product?, name: String?, shortName: String?, number: String?, vehicleNumber: String?) -> Line { |
| 118 | + |
| 119 | + // MARK: Authorization (OAuth2 client-credentials) |
| 120 | + |
| 121 | + override func authorizeRequest(_ httpRequest: HttpRequest, completion: @escaping (Result<HttpRequest, Error>) -> Void) { |
| 122 | + bearerToken { result in |
| 123 | + switch result { |
| 124 | + case .success(let token): |
| 125 | + var headers = httpRequest.headers ?? [:] |
| 126 | + headers["Authorization"] = "Bearer \(token)" |
| 127 | + httpRequest.setHeaders(headers) |
| 128 | + completion(.success(httpRequest)) |
| 129 | + case .failure(let error): |
| 130 | + completion(.failure(error)) |
| 131 | + } |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + /// Returns a valid bearer token, using the in-memory cache if still valid, otherwise fetching a |
| 136 | + /// new one. Concurrent callers during an in-flight fetch are coalesced onto the same request. |
| 137 | + private func bearerToken(completion: @escaping (Result<String, Error>) -> Void) { |
| 138 | + tokenQueue.async { |
| 139 | + if let token = self.cachedToken, let expiry = self.cachedTokenExpiry, expiry > Date() { |
| 140 | + completion(.success(token)) |
| 141 | + return |
| 142 | + } |
| 143 | + // A fetch is already running: just wait for its result. |
| 144 | + self.pendingTokenCompletions.append(completion) |
| 145 | + if self.isFetchingToken { return } |
| 146 | + self.isFetchingToken = true |
| 147 | + self.fetchToken() |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + /// Performs the actual token request and resolves all pending completions. Must be called on `tokenQueue`. |
| 152 | + private func fetchToken() { |
| 153 | + let urlBuilder = UrlBuilder(path: BlsProvider.TOKEN_ENDPOINT, encoding: .utf8) |
| 154 | + let httpRequest = HttpRequest(urlBuilder: urlBuilder) |
| 155 | + .setPostPayload("grant_type=\(tokenGrantType)") |
| 156 | + .setContentType("application/x-www-form-urlencoded") |
| 157 | + .setHeaders(["Accept": "application/json"]) |
| 158 | + |
| 159 | + _ = HttpClient.getJson(httpRequest: httpRequest) { [weak self] result in |
| 160 | + guard let self = self else { return } |
| 161 | + self.tokenQueue.async { |
| 162 | + let outcome: Result<String, Error> |
| 163 | + switch result { |
| 164 | + case .success(let json): |
| 165 | + if let token = json["access_token"].string { |
| 166 | + let expiresIn = json["expires_in"].double ?? 900 |
| 167 | + self.cachedToken = token |
| 168 | + self.cachedTokenExpiry = Date().addingTimeInterval(max(0, expiresIn - self.tokenExpiryLeeway)) |
| 169 | + outcome = .success(token) |
| 170 | + } else { |
| 171 | + os_log("BLS token response missing access_token", log: .requestLogger, type: .error) |
| 172 | + outcome = .failure(ParseError(reason: "missing access_token in token response")) |
| 173 | + } |
| 174 | + case .failure(let error): |
| 175 | + outcome = .failure(error) |
| 176 | + } |
| 177 | + |
| 178 | + let completions = self.pendingTokenCompletions |
| 179 | + self.pendingTokenCompletions.removeAll() |
| 180 | + self.isFetchingToken = false |
| 181 | + for completion in completions { |
| 182 | + completion(outcome) |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + override func newLine(id: String?, network: String?, product: Product?, name: String?, shortName: String?, number: String?, vehicleNumber: String?, direction: Line.Direction?, style: LineStyle) -> Line { |
100 | 189 | let newName: String? |
101 | 190 | if product == .suburbanTrain, let number = number { |
102 | | - newName = "S\(number)" |
| 191 | + newName = number.hasPrefix("S") ? number : "S\(number)" |
103 | 192 | } else if product == .regionalTrain, let number = number, let name = name, name.hasPrefix("RE") { |
104 | | - newName = "RE\(number)" |
| 193 | + newName = number.hasPrefix("RE") ? number : "RE\(number)" |
105 | 194 | } else { |
106 | 195 | newName = name |
107 | 196 | } |
108 | | - return super.newLine(id: id, network: network, product: product, name: newName, shortName: number, number: number, vehicleNumber: vehicleNumber) |
| 197 | + return super.newLine(id: id, network: network, product: product, name: newName, shortName: number, number: number, vehicleNumber: vehicleNumber, direction: direction, style: style) |
109 | 198 | } |
110 | 199 |
|
111 | 200 | static let PLACES = ["Bern"] |
|
0 commit comments