Monday, March 4, 2024

Swift Rule Engine using Javascript for your iOS/MacOS application


This kind of workarounds are required when we want dynamic validation or to run some algorithm dynamically at runtime. We can download the business logic [as javascript text or file] from the server or can add to the bundle and can load and execute when it's needed.

In this way, we can keep the core business logic must be the same over multiple platform and can control without updating the application.

Form your app, you can pass values as parameters and the script can evaluate it and can return the result as plain text or JSON text if needed. 

First load the script using scripRunner.loadWebViewWithScript(script), and we can invoke a function using scripRunner.evaluateFunction(fnName: "function")


See the example code of passing a contact object to a script function and that function validate the contact object and returning a json object with true or false value.



The following  JavaScript handler file will help us to do the tasks easier.


As we are using async/await instead of completion handlers, we can evaluate a js function immediately after loading the html content. 
In this example, the loading of script content is done with onAppear and evaluating on the button click. In this way, we can do the load once and evaluate any times.

Tuesday, February 27, 2024

Swift async/await - Sample API module with Unit test






In this article, we are adopting Swift concurrency in URLSession using async/await and trying to make a sample API module with Unit tests. We have tried to keep the code as minimal. 

As we keep minimal code, we considered only data task with url request. [we have not considered download/upload task, incremental-download, and task delegates] 

To create this module, we have two options, either can choose framework or using package. As this is project specific, framework is also fine. But here we are making new swift package for the API module.


Create Swift Package

In Xcode Menu, choose New -> Package from file menu.

In this example, given package name is 'APIModule'. It will automatically create a test target APIModuleTests for testing. 

Create new class HttpClient for URLSession data task

As it need to access outside from the package, we set public access. 

It shows some warning:
We have to specify the supported platform and version in the Package.swift file. Select the same file and add 'Platform' script.
platforms: [
     .iOS(.v13),
     .macOS(.v12)
]
To consume the session tasks, we need url requests, for example assume our app have a feature to fetch the contacts from server. 
Create new folder 'Contacts', under this create fie 'ContactAPI'. 

We need url request for each endpoint, so create an enum 'ContactEndPoint' to make urlrequest for each endpoint.      
Set public access because we need to access this features from outside the package when integrated to our iOS/MacOS project. 

Next we have to parse the API response. So let's create a DataMapper to convert API response to desired array of Contacts. We also need a Contact Model, And custom error to catch the parse errors.
 Also modify the getMyContacts() func.
  

public func getMyContacts() async throws -> [Contact] {

    let contactRequest = ContactEndPoint.myContacts.url(baseURL: baseURL)

    let (data, httpResponse)  = try await client.performRequest(contactRequest)

    return try ContactDataMapper.map(data, from: httpResponse)

}

Create New Unit Test Case


Create new Unit Test Case class file for testing ContactAPIs 

Select 'APIModuleTests' folder, and create new 'Unit Test Case' file named 'ContactAPITests' 
Add "import APIModule" statement. we don't need @testable here as we don't need level up the access.  

For the scheme 'APIModule, choose Mac instead of iOS device to fasten the test execution. 




To run a single test, click on the gutter button next to the function declaration.

To run all tests in a specific test case class, click on the gutter button next to the test case class declaration.  

To run all the tests within the targets in the selected scheme, we can use the default keyboard shortcut set by Xcode: CMD+U. 

To run the tests with xcodebuild (CLI), through the command line, using the following format. 

xcodebuild test -project [project name].xcodeproj -scheme “[scheme name]” 

 For example: $ xcodebuild test -scheme "APIModule" -destination 'platform=OS X,arch=x86_64' 

If it shows Command not found error, check the command line tools are installed or not. You can check the installed path in Xcode Settings, Locations tab.

If it installed, try the full path,
  /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -scheme "APIModule" -destination 'platform=OS X,arch=x86_64' 

Create protocol HTTPURLSession


Before starting test cases, we need protocol version of session datatask, otherwise we will endup using subclass of HttpClient. 

Create a protocol

public protocol HTTPURLSession {

    func data(for request: URLRequestasync throws -> (data: Data, response: HTTPURLResponse)

}


And in the HttpClient class, change the type of session variable as HTTPURLSession.  

----------------------------

We can start testing by spying our ContactAPIs. 

Create a class (HTTPURLSessionSpy) by implementing the HttpURLSession protocol, and we can use this for our unit testing.

 1. test_init_doesNotExecuteURLRequest() 

 We are testing the making of SUT instance doesn't request any urls. 

func test_init_doesNotExecuteURLRequest() async {

        let (_, session) = makeSUT()

        let executedUrls = session.executedURLs

        XCTAssertTrue(executedUrls.isEmpty)

    }


2. test_deliverConnectivityErrorOnClientError

Make sure it returns connectivity error if no response.

    func test_deliverConnectivityErrorOnClientError() async throws {

        let (sut, _) = makeSUT()

        do {

            _ = try await sut.getMyContacts()

        } catch let error {

            XCTAssertEqual((error as? APIError), APIError.connectivity)

            return

        }

        XCTFail()

    }


3. func test_deliverErrorOnInvalidJSONWith200Status()

Make sure the function return InvalidData error if the response data is unable to parse.

To validate the status code from URLResponse, we have to cast the URLResponse to HTTPURLResponse.

So changing the URLResponse type in all sections to HTTPURLResponse. And check Status Code is 200 or not.

    func test_deliverErrorOnInvalidJSONWith200Status() async throws {

        let url = URL(string: "https://aurl.com")!

        let (sut, session) = makeSUT(url: url)

        

        let myContactURL = ContactEndPoint.myContacts.url(baseURL: url).url!

        let invalidJSON = """

[

{"contactsssss_ID" : 2 }

]

"""

        session.setResponse((invalidJSON.data(using: .utf8)!, responseWithStatusCode(200, url: myContactURL)), for: myContactURL)

        

        do {

            _ = try await sut.getMyContacts()

        } catch let error {

            XCTAssertEqual((error as? APIError), APIError.invalidData)

            return

        }

        XCTFail()

    }


4. test_load_DeliverErroFor400Status

Make sure the function fails if the response status code is other than 200..300

    func test_load_DeliverErroFor400Status() async throws {

        let url = URL(string: "https://aurl.com")!

        let (sut, session) = makeSUT(url: url)


        let myContactURL = ContactEndPoint.myContacts.url(baseURL: url).url!

        session.setResponse(("".data(using: .utf8)!, responseWithStatusCode(400, url: url)), for: myContactURL)

        

        do {

            _ = try await sut.getMyContacts()

        } catch let error {

            XCTAssertEqual((error as? APIError), APIError.serverDefined("400"))

            return

        }

        XCTFail()

    }


5. test_load_deliversSuccessWith200HTTPResponseWithJSONItems

    func test_load_deliversSuccessWith200HTTPResponseWithJSONItems() async throws {

        let url = URL(string: "https://aurl.com")!

        let (sut, session) = makeSUT(url: url)


        let validJSON = """

[

{"contact_ID" : 2 }

]

"""

        let myContactURL = ContactEndPoint.myContacts.url(baseURL: url).url!

        session.setResponse((validJSON.data(using: .utf8)!, responseWithStatusCode(200, url: url)), for: myContactURL)

        

        do {

            let contacts = try await sut.getMyContacts()

            XCTAssertEqual(contacts.count, 1)

        } catch {

            XCTFail()

        }

    }


6. test_load_DeliverConnectivityErrorIfTaskIsCancelled

    @MainActor func test_load_DeliverConnectivityErrorIfTaskIsCancelled() async throws {

        let url = URL(string: "https://aurl.com")!

        

        let (sut, session) = makeSUT(url: url)


        let dataResponse = """

[

{"contact_ID" : 2 }

]

"""

        let myContactURL = ContactEndPoint.myContacts.url(baseURL: url).url!

        session.setResponse((dataResponse.data(using: .utf8)!, responseWithStatusCode(200, url: url)), for: myContactURL)

        

        let exp = expectation(description: "Wait for load completion")

        let task = Task {

            do {

                let contacts = try await sut.getMyContacts()

                exp.fulfill()

                XCTAssertEqual(contacts.count, 0)

            } catch let error {

                exp.fulfill()

                XCTAssertEqual((error as? APIError), APIError.connectivity)

            }

        }

        task.cancel()

        await fulfillment(of: [exp])

    }