Skip to content

Commit e4c787a

Browse files
BLS: migrate to OJP (fixes #121)
1 parent 784baab commit e4c787a

43 files changed

Lines changed: 162 additions & 66 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Sources/TripKit/Provider/Implementations/BlsProvider.swift

Lines changed: 108 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,48 @@
11
import Foundation
2+
import os.log
3+
import SwiftyJSON
24

35
/// 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+
920
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+
1838
styles = [
1939
// Tram
2040
"T3": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#98bbc5"), foregroundColor: LineStyle.white),
2141
"T6": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#0090d3"), foregroundColor: LineStyle.white),
2242
"T7": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#ff2e18"), foregroundColor: LineStyle.white),
2343
"T8": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#fc72b6"), foregroundColor: LineStyle.white),
2444
"T9": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#ffbe33"), foregroundColor: LineStyle.white),
25-
45+
2646
// Bus
2747
"B10": LineStyle(shape: .rounded, backgroundColor: LineStyle.parseColor("#02ab4f"), foregroundColor: LineStyle.white),
2848
"B11": LineStyle(shape: .rounded, backgroundColor: LineStyle.parseColor("#34ccf7"), foregroundColor: LineStyle.white),
@@ -95,17 +115,86 @@ public class BlsProvider: AbstractHafasClientInterfaceProvider {
95115
"RRE7": LineStyle(shape: .rect, backgroundColor: LineStyle.parseColor("#821041"), foregroundColor: LineStyle.white),
96116
]
97117
}
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 {
100189
let newName: String?
101190
if product == .suburbanTrain, let number = number {
102-
newName = "S\(number)"
191+
newName = number.hasPrefix("S") ? number : "S\(number)"
103192
} 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)"
105194
} else {
106195
newName = name
107196
}
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)
109198
}
110199

111200
static let PLACES = ["Bern"]

Sources/TripKit/Resources/secrets.json.template

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,6 @@
192192
"password": ""
193193
}
194194
},
195-
{
196-
"id": "bls",
197-
"apiAuthorization": {
198-
"type": "AID",
199-
"aid": ""
200-
}
201-
},
202195
{
203196
"id": "zvv",
204197
"apiAuthorization": {

Tests/StaticTripKitTests/BlsProviderTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class BlsProviderTests: TripKitProviderTestCase, TripKitProviderTestsDelegate {
99
var networkId: NetworkId { return .BLS }
1010

1111
func initProvider(from authorizationData: AuthorizationData) -> NetworkProvider {
12-
return BlsProvider(apiAuthorization: authorizationData.hciAuthorization)
12+
return BlsProvider()
1313
}
1414

1515
}

Tests/TestsCommon/Resources/Fixtures/bls/queryArrivals-0.input

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
-462 Bytes
Binary file not shown.

Tests/TestsCommon/Resources/Fixtures/bls/queryArrivals-1.input

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
4 Bytes
Binary file not shown.

Tests/TestsCommon/Resources/Fixtures/bls/queryDepartures-0.input

Lines changed: 4 additions & 1 deletion
Large diffs are not rendered by default.
Binary file not shown.

Tests/TestsCommon/Resources/Fixtures/bls/queryDepartures-1.input

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)