Swiftpack.co - Package - SvenTiigi/PerfectAPIClient

Swift 3.2 Platform TravisBuild Coverage codebeat badge Docs @SvenTiigi

PerfectAPIClient is a network abstraction layer to perform network requests via Perfect-CURL from your Perfect Server Side Swift application. It's heavily inspired by Moya and it's easy and fun to use.

Installation

To integrate using Apple's Swift Package Manager, add the following as a dependency to your Package.swift:

.package(url: "https://github.com/SvenTiigi/PerfectAPIClient.git", from: "1.0.0")

Here's an example PackageDescription:

// swift-tools-version:4.0

import PackageDescription

let package = Package(
    name: "MyPackage",
    products: [
        .library(
            name: "MyPackage",
            targets: ["MyPackage"]
        )
    ],
    dependencies: [
        .package(url: "https://github.com/SvenTiigi/PerfectAPIClient.git", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "MyPackage",
            dependencies: ["PerfectAPIClient"]
        ),
        .testTarget(
            name: "MyPackageTests",
            dependencies: ["MyPackage", "PerfectAPIClient"]
        )
    ]
)

Setup

In order to define the network abstraction layer with PerfectAPIClient, an enumeration will be declared to access the API endpoints. In this example we declare a GithubAPIClient to retrieve some Github zen and user information.

import PerfectAPIClient
import PerfectHTTP
import PerfectCURL
import ObjectMapper

/// Github API Client in order to access Github API Endpoints
enum GithubAPIClient {
    /// Retrieve zen
    case zen
    /// Retrieve user info for given username
    case user(name: String)
    /// Retrieve repositories for user name
    case repositories(userName: String)
}

Next up we implement the APIClient protocol to define the request information like base url, endpoint path, HTTP header, etc...

// MARK: APIClient

extension GithubAPIClient: APIClient {
    
    /// The base url
    var baseURL: String {
        return "https://api.github.com/"
    }
    
    /// The path for a specific endpoint
    var path: String {
        switch self {
        case .zen:
            return "zen"
        case .user(name: let name):
            return "users/\(name)"
        case .repositories(userName: let name):
            return "users/\(name)/repos"
        }
    }
    
    /// The http method
    var method: HTTPMethod {
        switch self {
        case .zen:
            return .get
        case .user:
            return .get
        case .repositories:
            return .get
        }
    }
    
    /// The HTTP headers
    var headers: [HTTPRequestHeader.Name: String]? {
        return [.userAgent: "PerfectAPIClient"]
    }
    
    /// The request payload for a POST or PUT request
    var payload: BaseMappable? {
        return nil
    }
    
    /// Advanced CURLRequest options like SSL or Proxy settings
    var options: [CURLRequest.Option]? {
        return nil
    }
    
    /// The mocked result for tests environment
    var mockedResult: APIClientResult<APIClientResponse>? {
        switch self {
        case .zen:
            let request = APIClientRequest(apiClient: self)
            let response = APIClientResponse(
	    	url: self.getRequestURL(), 
		status: .ok, 
		payload: "Some zen for you my friend", 
		request: request
            )
            return .success(response)
        default:
            return nil
        }
    }
    
}

There is also an JSONPlaceholderAPIClient example available.

Usage

PerfectAPIClient enables an easy way to access an API like this:

GithubAPIClient.zen.request { (result: APIClientResult<APIClientResponse>) in
    result.analysis(success: { (response: APIClientResponse) in
        // Do awesome stuff with the response
        print(response.url) // The request url
        print(response.status) // The response HTTP status
        print(response.payload) // The response payload
        print(response.getHTTPHeader(name: .contentType)) // HTTP header field
        print(response.getPayloadJSON) // The payload as JSON/Dictionary
        print(response.getMappablePayload(type: SomethingMappable.self)) // Map payload into an object
        print(response.getMappablePayloadArray(SomethingMappable.self)) // JSON Array
    }, failure: { (error: APIClientError) in
        // Oh boy you are in trouble 😨
    }
}

Or even retrieve an JSON response as an automatically Mappable object.

GithubAPIClient.user(name: "sventiigi").request(mappable: User.self) { (result: APIClientResult<User>) in
    result.analysis(success: { (user: User) in
        // Do awesome stuff with the user
        print(user.name) // Sven Tiigi
    }, failure: { (error: APIClientError) in
        // Oh boy you are in trouble again 😱
    }
}

If your response contains an JSON Array:

GithubAPIClient.repositories(username: "sventiigi").request(mappable: Repository.self) { (result: APIClientResult<[Repository]>) in
    result.analysis(success: { (repositories: [Repository]) in
        // Do awesome stuff with the repositories
        print(repositories.count)
    }, failure: { (error: APIClientError) in
        // 🙈
    }
}

The user object in this example implements the Mappable protocol based on the ObjectMapper library to perform the mapping between the struct/class and JSON.

import ObjectMapper

struct User {
    /// The users full name
    var name: String?
    /// The user type
    var type: String?
}

// MARK: Mappable

extension User: Mappable {
    /// ObjectMapper initializer
    init?(map: Map) {}
    
    /// Mapping
    mutating func mapping(map: Map) {
        self.name   <- map["name"]
        self.type   <- map["type"]
    }
}

Error Handling

When you perform the analysis function on the APIClientResult or you do a simple switch or if case on the APIClientResult you will retrieve an APIClientError via the failure case if an error occured. The following example shows what types of error cases are available on the APIClientError.

GithubAPIClient.zen.request { (result: APIClientResult<APIClientResponse>) in
    result.analysis(success: { (response: APIClientResponse) in
        // Do awesome stuff with the response
    }, failure: { (error: APIClientError) in
        // Oh boy you are in trouble 😨
	// Analysis the APIClientError
        error.analysis(mappingFailed: { (reason: String, response: APIClientResponse) in
	    // Mapping failed
        }, badResponseStatus: { (response: APIClientResponse) in
            // Bad response status
        }, connectionFailed: { (error: Error, request: APIClientRequest) in
            // Connection failure
        })
    }
}
  • MappingFailed: Indicates that the Mapping between your mappable type and the response JSON doesn't match.
  • BadResponseStatus: Indicates that the APIClient has received a bad response status >= 300 or < 200
  • ConnectionFailed: Indicates that an error occurred during the CURL request to the given url.

The analysis function on the APIClientError is just a convenience way to check which error type has been retrieved. Of course you can perform a switch or an if case on the APIClientError enumeration.

Advanced Usage

Modify Request URL

By overriding the modify(requestURL ...) function you can update the constructed request URL from baseURL and path. It's handy when you want to add a Token query parameter to your request url everytime instead of adding it to every path.

public func modify(requestURL: inout String) {
    requestURL += "?token=42"
}

Modify JSON before Mapping

By overriding the modify(responseJSON ...) function you can update the response JSON before it's being mapped from JSON to your mappable type. It's handy when the response JSON is wrapped inside a result property.

public func modify(responseJSON: inout [String: Any], mappable: BaseMappable.Type) {
    // Try to retrieve JSON from result property
    responseJSON = responseJSON["result"] as? [String: Any] ?? responseJSON
}

Modify JSON Array before Mapping

By overriding the modify(responseJSONArray ...) function you can update the response JSON Array before it's being mapped to an mappable array.

public func modify(responseJSONArray: inout [[String: Any]], mappable: BaseMappable.Type) {
    // Manipulate the responseJSONArray if you need so
}

Should fail on bad response status

By overriding the shouldFailOnBadResponseStatus() function you can decide if the APIClient should evaluate the result as a failure if the response status code is>= 300 or < 200. The default implementation returns true which results that an response with an bad response status code will lead to an APIClientResult of type failure.

public func shouldFailOnBadResponseStatus() -> Bool {
    // Default implementation
    return true
}

Logging

By overrding the following two functions you can add logging to your request before the request started and when a response is retrieved or something else you might want to do.

Will Perform Request

By overriding the willPerformRequest function you can perform logging operation or something else your might want to do, before the request of an APIClient will be executed.

func willPerformRequest(request: APIClientRequest) {
    print("Will perform request \(request)")
}

Did Retrieve Response

By overriding the didRetrieveResponse function you can perform logging operation or something else your might want to do, after the response of an request for an APIClient is being retrieved.

func didRetrieveResponse(request: APIClientRequest, result: APIClientResult<APIClientResponse>) {
    print("Did retrieve response for request: \(request) and result: \(result)")
}

Mocking

In order to define that your APIClient is under Unit or Integration Tests condition, you need to set the environment to tests. The recommended way is to override setUp and tearDown and update the environment as seen in the following example.

import XCTest
import PerfectAPIClient

class MyAPIClientTestClass: XCTestCase {

    override func setUp() {
        super.setUp()
        // Set to tests environment
        // mockedResult is used if available
        MyAPIClient.environment = .tests
    }
    
    override func tearDown() {
        super.tearDown()
        // Reset to default environment
        MyAPIClient.environment = .default
    }

    func testMyAPIClient() {
    	// Your test logic
    }

}

MockedResult

In order to add mocking to your APIClient for unit testing your application you can return an APIClientResult via the mockedResult protocol variable. The mockedResult is only used when you return an APIClientResult and the current environment is set to tests.

var mockedResult: APIClientResult<APIClientResponse>? {
    switch self {
    case .zen:
        // This result will be used when unit tests are running
        let request = APIClientRequest(apiClient: self)
        let response = APIClientResponse(
		url: self.getRequestURL(), 
		status: .ok, 
		payload: "Keep it logically awesome.", 
		request: request
	)
        return .success(response)
    case .user:
        // A real network request will be performed when unit tests are running
        return nil
    }
}

For more details checkout the PerfectAPIClientTests.swift file.

Slashes

When your ask yourself where to put the slash / when returning a String for baseURL and path 🤔

This is the recommended way ☝️:

/// The base url
var baseURL: String {
    return "https://api.awesome.com/"
}
    
/// The path for a specific endpoint
var path: String {
    return "users"
}

Put a slash at the end of your baseURL and skip the slash at the beginning of your path. But don't worry APIClient has a default implementation for the getRequestURL() function which add a slash to the baseURL if you forgot it and remove the first character of your path if it's a slash. If you want to change the behavior just override the function 👌

RawRepresentable

As most of your enumeration cases will be mixed with Associated Values and some without, it's hard to retrieve the enumerations name as a String because you can't declare an Enumeration with associated values like this:

// ❌ Error: enum with raw type cannot have cases with arguments
enum GithubAPIClient: String {
    case zen
    case user(name: String)
}

So here is an example to retrieve the enumeration name via the rawValue property from the RawRepresentable protocol:

enum GithubAPIClient {
    // Without associated value
    case zen
    // With associated value
    case user(name: String)
}

extension GithubAPIClient: RawRepresentable {
    
    /// Associated type RawValue as String
    typealias RawValue = String
    
    /// RawRepresentable initializer. Which always returns nil
    ///
    /// - Parameters:
    ///   - rawValue: The rawValue
    init?(rawValue: String) {
        // Returning nil to avoid constructing enum with String
        return nil
    }
    
    /// The enumeration name as String
    var rawValue: RawValue {
        // Retrieve label via Mirror for Enum with associcated value
        guard let label = Mirror(reflecting: self).children.first?.label else {
            // Return String describing self enumeration with no asscoiated value
            return String(describing: self)
        }
        // Return label
        return label
    }
    
}

Full example GithubAPIClient.swift

Usage

print(GithubAPIClient.zen.rawValue) // zen
print(GithubAPIClient.user(name: "sventiigi").rawValue) // user

Awesome 😎

Linux Build Notes

Ensure that you have installed libcurl.

sudo apt-get install libcurl4-openssl-dev

If you run into problems with JSON-Mapping on Int and Double values using the ObjectMapper library under Linux, please see this issue.

Dependencies

PerfectAPIClient is using the following dependencies:

Contributing

Contributions are very welcome 🙌 🤓

To-Do

  • [ ] Improve Unit-Tests
  • [ ] Improve Linux compatibility
  • [ ] Add automated Jazzy documentation generation via Travis CI

License

MIT License

Copyright (c) 2017 Sven Tiigi

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Github

link
Stars: 19
Help us keep the lights on

Releases

1.3.0 - Jan 15, 2018

Mocking Environment

In order to perform Unit or Integration Tests for your APIClient you can now set the environment property individually on an APIClient.

// Set to tests environment mockedResult is used (if available)
MyAPIClient.environment = .tests

// Set to default environment. Real network requests will be performed
MyAPIClient.environment = .default

Example:

import XCTest
import PerfectAPIClient

class MyAPIClientTestClass: XCTestCase {

    override func setUp() {
        super.setUp()
        // Set to tests environment
        // mockedResult is used if available
        MyAPIClient.environment = .tests
    }
    
    override func tearDown() {
        super.tearDown()
        // Reset to default environment
        MyAPIClient.environment = .default
    }

    func testMyAPIClient() {
    	// Your test logic
    }

}

Protocol Properties Naming

Old: requestPayload | New: payload

In order to simplify the naming the APIClient protocol property requestPayload has been renamed to payload

Old: mockedResponseResult | New: mockedResult

The property mockedResponseResult has been renamed to mockedResult

1.2.1 - Jan 12, 2018

Some small bug fixes 👨‍💻

1.2.0 - Jan 11, 2018

TL;DR

In this version the APIClient has been improved in order to allow a better error handling and mocked data for your unit or integration test. Furthermore, the Protocol has been updated to be more type-safe and allow the implementation to take over more control over the behaviours.

APIClientError

From now on the the APIClientResult will contain an APIClientError on failure case.

GithubAPIClient.zen.request { (result: APIClientResult<APIClientResponse>) in
    result.analysis(success: { (response: APIClientResponse) in
        // Do awesome stuff with the response
    }, failure: { (error: APIClientError) in
        // Analysis the APIClientError
        error.analysis(mappingFailed: { (reason: String, response: APIClientResponse) in
                // Mapping failed
        }, badResponseStatus: { (response: APIClientResponse) in
                // Bad response status
        }, connectionFailed: { (error: Error, request: APIClientRequest) in
                // Connection failure
        })
    }
}
  • MappingFailed: Indicates that the Mapping between your mappable type and the response JSON doesn't match.
  • BadResponseStatus: Indicates that the APIClient has received a bad response status >= 300 or < 200
  • ConnectionFailed: Indicates that an error occurred during the CURL request to the given url.

Mocking / APIClientEnvironment

The APIClientTestCase has been removed due some errors when added PerfectAPIClient to a library package via the Package.swift file. It has been replaces with the APIClientEnvironment which is a Singleton and holds an APIClientEnvironmentMode. There are currently two states available, the first is standard which defines the default mode where the APIClient performs real network request and doesn't use any mocking data. The second state is the test state, in this state the mockResponseResult will be used if it's available. In order to set the EnvironmentMode correctly for your unit or integration tests an example will demonstrate it:

import XCTest
import PerfectAPIClient

class MyAPIClientTestClass: XCTestCase {

    override func setUp() {
        super.setUp()
        // Enable Test Environment Mode
        // MockResponseResult is used if available
        APIClientEnvironment.shared.mode = .test
    }
    
    override func tearDown() {
        super.tearDown()
        // Reset to Standard Environment Mode
        APIClientEnvironment.shared.mode = .standard
    }

    func testMyAPIClient() {
    	// Your test logic
    }

}

APIClientRequest

A newly created model called APIClientRequest stores the request data to allow better debugging. According to this addition the APIClientResponse model initializer has been updated to:

/// APIClientResponse Initializer
public init(url: String, status: HTTPResponseStatus, payload: String,
                request: APIClientRequest, headers: [String: String]? = nil)

When you initialize an APIClientResponse, for example when your want to construct your mockResponseResult object, you have to pass an APIClientRequest. But don't worry an APIClientRequest initialization is simple: let request = APIClientRequest(apiClient: self).

APIClient Protocol Changes

Headers

The headers variable signature has changed HTTPRequestHeader.Name enumeration instead of a String value.

var headers: [HTTPRequestHeader.Name: String]? { get }

willPerformRequest and didRetrieveResponse

The function signature changed according to the APIClientRequest Model:

func willPerformRequest(request: APIClientRequest) {}
func didRetrieveResponse(request: APIClientRequest, result: APIClientResult<APIClientResponse>) {}

Added shouldFailOnBadResponseStatus

By overriding the shouldFailOnBadResponseStatus() function you can decide if the APIClient should evaluate the result as a failure if the response status code is>= 300 or < 200. The default implementation returns true which results that an response with an bad response status code will lead to an APIClientResult of type failure.

/// Indicating if the APIClient should return an error
/// On a bad response code >= 300 and < 200
func shouldFailOnBadResponseStatus() -> Bool

1.1.4 - Dec 14, 2017

Added compatibility to request a JSON Array on APIClient and added getMappablePlayloadArray function to APIClientResponse. Further improved code layout and validations.

1.1.3 - Nov 3, 2017

Updated visibility for APIClientTestCase from public to open to allow overriding by custom TestCases