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:

  1. A CRUD interface to emulate GET/PUT/POST/DELETE calls.
  2. A method to create URLRequest based on the ApiResourceProviding object.
  3. 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:

  1. Implement the method createURLRequest(...)
  2. 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