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 yourmappabletype and the responseJSONdoesn't match.BadResponseStatus: Indicates that theAPIClienthas received a bad response status>= 300or< 200ConnectionFailed: 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) // userAwesome
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.

