LooLocator: Defining the networking layer
In the post about defining our Data class, we took a look at the Overpass Turbo API. We’ll now create a simple networking layer to interact with the API. We’ll use the NSURLSession to keep things simple.
The networking layer in our case has 3 parts:
- API Resource: We will send the Overpass turbo XML query, and the required headers.
- Response: We receive the JSON output from OSM after the successful completion of network operation. We defined this is the previous post
- API Client: We’ll use a NSURLSession to perform the network operation.
API Resource
The API Resource should be a protocol that helps us create the actual network request. In order to achieve that, we need the XML as data, and the headers. We can define it as:
protocol ApiResourceProviding {
var data: Data { get }
var headers:Dictionary<String, String> { get }
}
We can now implement this protocol for our app, and we’ll need the coordinates, amenity type (toilets in our example), and the radius of the search. As we need amenities from OSM, we’ll call this class as AmenityResource.
class AmenityResource: ApiResourceProviding {
var latitude: String
var longitude: String
var amenityType: String
var radius: String
init(latitude: String, longitude: String, amenity: String, radius: String) {
self.longitude = longitude
self.latitude = latitude
self.amenityType = amenity
self.radius = radius
}
// OSM Needs the data in XML format
var data: Data {
if let _data = """
<osm-script output="json">
<query into="_" type="node">
<has-kv k="amenity" modv="" v="\(amenityType)"/>
<around from="_" into="_" lat="\(latitude)" lon="\(longitude)" radius="\(radius)"/>
</query>
<print e="" from="_" geometry="skeleton" limit="" mode="body" n="" order="id" s="" w=""/>
</osm-script>
""".data(using: String.Encoding.utf8) {
return _data
} else {
return Data()
}
}
var headers: Dictionary<String, String> {
var _headers = Dictionary<String, String>()
_headers["Content-Type"] = "application/xml"
_headers["Access-Control-Allow-Origin"] = "*"
_headers["Access-Control-Allow-Origin"] = "*/*"
return _headers
}
}
API Client
The API client protocol needs to have three components:
- A CRUD interface to emulate GET/PUT/POST/DELETE calls.
- A method to create
URLRequest
based on theApiResourceProviding
object. - An internal method to do the actual network operation.
With this information in hand, we can now define the protocol NetworkRequestProviding
as:
typealias CompletionBlock = (_ success: Bool, _ object: AnyObject?) -> ()
protocol NetworkRequestProviding {
// The model that the request deals with
associatedtype SerializedType : Codable
// CRUD interface
func get(request: NSMutableURLRequest, completion: @escaping CompletionBlock)
func post(request: NSMutableURLRequest, completion: @escaping CompletionBlock)
func put(request: NSMutableURLRequest, completion: @escaping CompletionBlock)
func delete(request: NSMutableURLRequest, completion: @escaping CompletionBlock)
// Internal workhorse function: implemented in default extension
func dataTask(request: NSMutableURLRequest, completion: @escaping CompletionBlock)
// The implementor needs to implement this to provide the ApiResource that the request needs
func createURLRequest<T: ApiResourceProviding>(from resource: T) -> NSMutableURLRequest?
}
The method dataTask(...)
can be extracted and implemented through a default extention. The method implementation gets a URLrequst as parameter, and performs a URLSession.dataTask(...)
operation. If successful, it parses and deserialized the response to our associatedType
object, and notifies the caller of completion. The CRUD methods will simply forward the calls to the dataTask(...)
method.
extension NetworkRequestProviding {
internal var baseUrl: String {
let _baseUrl = "https://overpass-api.de/api/interpreter"
return _baseUrl
}
func dataTask(request: NSMutableURLRequest,
completion: @escaping (Bool, AnyObject?) -> ()) {
let session = URLSession(configuration: URLSessionConfiguration.default)
session.dataTask(with: request as URLRequest) { (data, response, error) -> Void in
let decoder = JSONDecoder()
if let data = data,
let serverResponse = try? decoder.decode(SerializedType.self, from: data),
let response = response as? HTTPURLResponse, 200...299 ~= response.statusCode {
completion(true, serverResponse as AnyObject)
} else {
completion(false, nil)
}
}.resume()
}
}
We are now ready to implement the NetworkRequestProviding
protocol specialized for Amenities. We’ll name the class AmenityRequest
, and it needs to do two specialized tasks:
- Implement the method
createURLRequest(...)
- Provide a public method to get amenities by type, coordinates, and radius.
Method createURLRequest(...)
func createURLRequest<T>(from resource: T) -> NSMutableURLRequest? where T : ApiResourceProviding {
guard let baseUrl = URL(string:self.baseUrl) else {
return nil
}
let locationRequest = NSMutableURLRequest(url:baseUrl,
cachePolicy: .reloadIgnoringCacheData,
timeoutInterval: 1.0)
locationRequest.httpMethod = "POST"
locationRequest.httpBody = resource.data
resource.headers.forEach { (arg) in
let (key, value) = arg
locationRequest.addValue(value, forHTTPHeaderField: key)
}
return locationRequest
}
Method getAmenities
func getAmeneties(of type: AmenityType,
latitude: Double,
longitude: Double,
radius: Double,
completionBlock: @escaping CompletionBlock) {
let amenityResource = AmenityResource(latitude: String(latitude),
longitude: String(longitude),
amenity: type.rawValue,
radius: String(radius))
guard let amenityUrlRequest = createURLRequest(from: amenityResource) else {
return
}
post(request: amenityUrlRequest) { (success, result) in
if success {
guard let result = result as? RawOSMData,
let elements = result.elements else {
completionBlock(false, nil)
return
}
var jsonElements: [Location] = []
for element in elements {
jsonElements.append(Location(jsonElement: element))
}
completionBlock(success, jsonElements as AnyObject)
}
}
}
The enum AmenityType
can be defined as:
enum AmenityType: String {
case Toilets = "toilets"
}
Unit Tests
Hitting the network during unit tests is considered a bad proactice. To unit test the AmenityRequest
class we’ll have to stub the network response. We’ll use OHHttpStubs to do stub the sample json data from a file stubbedRepsonse.json
in the Unit Test Bundle. The BDD test can be written as:
class ApiClientTests: QuickSpec {
override func spec() {
describe("Amenity Request tests") {
let amenityReqeust = AmenityRequest()
beforeEach {
let testHost = "overpass-api.de"
stub(condition: isHost(testHost), response: { _ in
guard let path = OHPathForFile("stubbedRepsonse.json", type(of: self)) else {
preconditionFailure("Could not find expected file in test bundle")
}
return fixture(filePath: path, status: 200, headers: ["Content-Type":"application/json"])
})
}
afterEach {
OHHTTPStubs.removeAllStubs()
}
it("should fetch amenities", closure: {
// Arrange
var successFlag = false
var locations: [Location] = []
// Act
amenityReqeust.getAmeneties(of: AmenityType.Toilets,
latitude: 52.51631,
longitude: 13.37777,
radius: 1000,
completionBlock: { (success, results) in
successFlag = success
if successFlag, let results = results as? [Location] {
locations = results
}
})
//Assert
expect(successFlag).toEventuallyNot(beFalse())
expect(locations).toEventuallyNot(beEmpty())
expect(locations.first?.id).toEventuallyNot(beEmpty())
expect(locations.first?.coordinates.0).toEventuallyNot(equal(0))
expect(locations.first?.coordinates.1).toEventuallyNot(equal(0))
})
}
}
}
We can run the tests, and see that the tests are green.
Test Suite 'LooLocatorTests.xctest' started at 2018-03-06 13:30:35.531
Test Suite 'ApiClientTests' started at 2018-03-06 13:30:35.531
Test Case '-[LooLocatorTests.ApiClientTests Amenity_Request_tests__should_fetch_amenities]' started.
Test Case '-[LooLocatorTests.ApiClientTests Amenity_Request_tests__should_fetch_amenities]' passed (0.037 seconds).
Test Suite 'ApiClientTests' passed at 2018-03-06 13:30:35.571.
Executed 1 test, with 0 failures (0 unexpected) in 0.037 (0.040) seconds