Subclass MKTileOverlay to load tiles from Authenticated WMTS Server
| Platform | Difficulty | Skills Required |
|---|---|---|
| iOS | Beginner | Swift, UIKit |
MKTileOverlay we will handle requests and token acquisition. We will also cache tiles as a bonus!
Problem explained
At some point in my iOS application i had to customize MapKit, since it does not natively support WMTS (Web Map Tile Service), in order to load map tiles from a third-party provider. To keep things simple, since WMTS tiles is out of this scope of this tutorial, we will assume only three parameters need to be passed in url:
TILEMATRIX, TILEROW, TILECOL
The resulting implementation is similar to below, where path.z, path.y, path.x correspond to above parameters respectively.
class ThirdPartServiceMapOverlay : MKTileOverlay {
override init(urlTemplate URLTemplate: String?) {
super.init(urlTemplate: URLTemplate)
// ......
}
override func url(forTilePath path: MKTileOverlayPath) -> URL {
return URL(string: String(format: "[WMTS_SERVICE_URL]/tiles/%d/%d/%d.png", path.z, path.y, path.x))!
}
}
Then adding the overlay in your map
let overlay = ThirdPartServiceMapOverlay()
mapView.addOverlay(overlay, level: .aboveLabels)
and finally implement the renderer in your MKMapViewDelegateMap
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let tileOverlay = overlay as? MKTileOverlay {
return MKTileOverlayRenderer(tileOverlay: tileOverlay)
}
return MKOverlayRenderer(overlay: overlay)
}
However, third party tile services use some kind of authentication mechanism, usually with the form of a token (Token Based Authentication).
If your provider just issues "static" tokens which give you access to the service while your subscription is active you only have to append it in url overload.
return URL(string: String(format: "[WMTS_SERVICE_URL]/tiles/%d/%d/%d.png?access_token=%s", path.z, path.y, path.x, token_provided))!
For non static token authentication services the provider issues tokens with a brief lifetime (usually 1h). In order to obtain them you need credentials like username / password (also from the provider).
The above makes sense for a web application for example, where you don't want to keep and send plaintext credentials in your client-side code (browser). Instead you implement the token request mechanism in your backend (which can safely encrypt, store and transmit credentials to provider endpoint) and call YOUR backend, to acquire the token.
This 1h duration token can be easily intercepted (since it is transmitted from your browser), but will only expire soon. In order to obtain the new one, access is needed to the credentials mentioned earlier which only reside in the backend and are invisible to the end user of your web application.Above is a very common practice for service providers in general, not only tile services. Now, for our TileOverlay, the steps we are going to follow are:
- Start loading tiles (through overriding func loadTile) with a stored token if available. If none use a dummy one.
- If response to tile request is 401 (Unauthorized) - Request new token from back end and replace current one.
The problem is that the TileRenderer, obviously, requests many tiles simultaneously (asynchronous requests - different threads). We don't want to acquire a new token for each failed request. When token expires all requests will fail, thus we need to somehow synchronize and allow only one of them threads to handle the token renewal.
For this reason we will use the standard synchronization mechanism for these cases: a semaphore with a value of 1 (mutex). Each tile request thread that fails with unauthorized response will raise/wait the semaphore, and signal/release when done. Also a flag will be used (tokenUpdated) that indicates if the token is indeed refreshed, in order to notify other threads to exit.
override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) {
let url1 = self.url(forTilePath: path)
if let cachedData = cache.object(forKey: url1 as AnyObject) as? Data {
//print("Found in cache: [\(url1.absoluteString)]")
result(cachedData, nil)
} else {
//print("Requesting: [\(url1.absoluteString)]")
AF.request("\(url1.absoluteString)?access_token=\(access_token)").responseData(queue: DispatchQueue(label: "tile-request-queue")) {
response in
switch response.result {
case .success(let data):
if(response.response?.statusCode == 200) {
//print("Adding to cache: [\(url1.absoluteString)]")
self.cache.setObject(data as AnyObject, forKey: url1 as AnyObject)
result(data, nil)
} else if (response.response?.statusCode == 401 || response.response?.statusCode == 403) {
self.semaphore.wait()
if(!self.tokenUpdated) {
print("Thread(\(Thread.current)): Token Expired!")
AF.request("\([YOUR_BACKEND_TOKEN_REQUEST_METHOD])", method: .get, parameters: nil, encoding: JSONEncoding.default)
.responseJSON(queue: DispatchQueue(label: "token-request-queue")) { response in
switch response.result {
case .success(let JSON):
if(response.response?.statusCode == 200) {
let response = JSON as! NSDictionary
let token = response.object(forKey: "token")!
print("\(Thread.current): Got \(token)")
self.access_token = token as! String
self.tokenUpdated = true
// store in keychain
do {
... keychain.set(self.access_token, key: "token")
} catch let error {
// log error
}
self.semaphore.signal()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.tokenUpdated = false
}
self.loadTile(at: path, result: result)
} else if (response.response?.statusCode == 401 || response.response?.statusCode == 403) {
let err: [String: Any] = [
"Backend API": "Token Expired!"
]
let errorTemp = NSError(domain: "", code: -1, userInfo: err)
result(nil, errorTemp)
self.semaphore.signal() // propably necessary for all stuck tile threads
}
case .failure(let error):
print(error)
result(nil, error)
self.semaphore.signal() // propably necessary for all stuck tile threads
}
}
} else {
self.semaphore.signal()
self.loadTile(at: path, result: result)
}
} else {
print(response.response?.statusCode)
}
case .failure(let error):
print(error)
result(nil, error)
}
}
}
}