Skip to content

🌏 A zero-dependency networking solution for building modern and secure iOS, watchOS, macOS and tvOS applications.

License

Notifications You must be signed in to change notification settings

billp/TermiNetwork

Repository files navigation

A zero-dependency networking solution for building modern and secure iOS, watchOS, macOS and tvOS applications.

πŸš€ TermiNetwork has been tested in a production environment with a heavy load of asynchronous requests and tens of thousands of unique clients per day.

Features

  • Multi-environment setup
  • Model deserialization with Codables
  • Async await support
  • Decodes response to the given type: Codable, Transformer, UIImage, Data or String
  • UIKit/SwiftUI extensions for downloading remote images
  • Request grouping with Repositories
  • Detects network status with Reachability
  • Transformers: convert models from one type to another easily
  • Error Handling
  • Interceptors
  • Request mocking
  • Certificate Pinning
  • Flexible configuration
  • Middleware support
  • File/Data Upload/Download support
  • Pretty printed debug information

Table of contents

Installation

You can install TermiNetwork with one of the following ways...

CocoaPods

Add the following line to your Podfile and run pod install in your terminal:

pod 'TermiNetwork', '~> 4.0'

Carthage

Add the following line to your Carthage and run carthage update in your terminal:

github "billp/TermiNetwork" ~> 4.0

Swift Package Manager

Go to File > Swift Packages > Add Package Dependency and add the following URL:

https://github.com/billp/TermiNetwork

Demo Application

To see all the features of TermiNetwork in action, download the source code and run the TermiNetworkExamples scheme.

Usage

Simple usage (Request)

Let's say you have the following Codable model:

struct Todo: Codable {
   let id: Int
   let title: String
}

The following example creates a request that adds a new Todo:

let params = ["title": "Go shopping."]
let headers = ["x-auth": "abcdef1234"]

Request(method: .get,
	url: "https://myweb.com/api/todos",
	headers: headers,
	params: params)
    .success(responseType: Todo.self) { todos in
	print(todos)
    }
    .failure { error in
	print(error.localizedDescription)
    }

or with async await:

let request = Request(method: .get, 
                      url: "https://myweb.com/api/todos", 
                      headers: headers, 
                      params: params)

do {
    let todos: [Todo] = try await request.async()
    print(todos)
} catch let error { 
    print(error.localizedDescription)
}

Parameters Explanation

method

One of the following supported HTTP methods:

.get, .head, .post, .put, .delete, .connect, .options, .trace or .patch
responseType

One of the following supported response types

Codable.self (implementations), UIImage.self, Data.self or String.self
onSuccess

A callback that returns an object of the given type. (specified in responseType parameter)

onFailure

A callback that returns a Error and the response Data (if any).

Advanced usage of Request with Configuration and custom Queue

The following example uses a custom Queue with maxConcurrentOperationCount and a configuration object. To see the full list of available configuration properties, take a look at Configuration properties in documentation.

let myQueue = Queue(failureMode: .continue)
myQueue.maxConcurrentOperationCount = 2

let configuration = Configuration(
    cachePolicy: .useProtocolCachePolicy,
    timeoutInterval: 30,
    requestBodyType: .JSON
)

let params = ["title": "Go shopping."]
let headers = ["x-auth": "abcdef1234"]

Request(method: .post,
        url: "https://myweb.com/todos",
        headers: headers,
        params: params,
        configuration: configuration)
    .queue(queue)
    .success(responseType: String.self) { response in
        print(response)
    }
    .failure { error in
        print(error.localizedDescription)
    }

or with async await:

do {
    let response = try Request(
    	method: .post,
	url: "https://myweb.com/todos",
	headers: headers,
	params: params,
	configuration: configuration
    )
    .queue(queue)
    .async(as: String.self)
} catch let error {
    print(error.localizedDescription)
}

The request above uses a custom queue myQueue with a failure mode of .continue (default), which means that the queue continues its execution if a request fails.

Complete setup with Environments and Repositories

The complete and recommended setup of TermiNetwork consists of defining Environments and Repositories.

Environment setup

Create a swift enum that implements the EnvironmentProtocol and define your environments.

Example
enum MyAppEnvironments: EnvironmentProtocol {
    case development
    case qa

    func configure() -> Environment {
        switch self {
        case .development:
            return Environment(scheme: .https,
                               host: "localhost",
                               suffix: .path(["v1"]),
                               port: 3000)
        case .qa:
            return Environment(scheme: .http,
                               host: "myqaserver.com",
                               suffix: .path(["v1"]))
        }
    }
}

Optionally you can pass a configuration object to make all Repositories and Endpoints to inherit the given configuration settings.

To set your global environment use Environment.set method

Environment.set(MyAppEnvironments.development)

Repository setup

Create a swift enum that implements the EndpointProtocol and define your endpoints.

The following example creates a TodosRepository with all the required endpoints as cases.

Example
enum TodosRepository: EndpointProtocol {
    // Define your endpoints
    case list
    case show(id: Int)
    case add(title: String)
    case remove(id: Int)
    case setCompleted(id: Int, completed: Bool)

    static let configuration = Configuration(requestBodyType: .JSON,
                                             headers: ["x-auth": "abcdef1234"])


    // Set method, path, params, headers for each endpoint
    func configure() -> EndpointConfiguration {

        switch self {
        case .list:
            return .init(method: .get,
                         path: .path(["todos"]), // GET /todos
                         configuration: Self.configuration)
        case .show(let id):
            return .init(method: .get,
                         path: .path(["todo", String(id)]), // GET /todos/[id]
                         configuration: Self.configuration)
        case .add(let title):
            return .init(method: .post,
                         path: .path(["todos"]), // POST /todos
                         params: ["title": title],
                         configuration: Self.configuration)
        case .remove(let id):
            return .init(method: .delete,
                         path: .path(["todo", String(id)]), // DELETE /todo/[id]
                         configuration: configuration)
        case .setCompleted(let id, let completed):
            return .init(method: .patch,
                         path: .path(["todo", String(id)]), // PATCH /todo/[id]
                         params: ["completed": completed],
                         configuration: configuration)
        }
    }
}

You can optionally pass a configuration object to each case if you want provide different configuration for each endpoint.

Make a request

To create the request you have to initialize a Client instance and specialize it with your defined Repository, in our case TodosRepository:

Client<TodosRepository>().request(for: .add(title: "Go shopping!"))
    .success(responseType: Todo.self) { todo in
        // do something with todo
    }
    .failure { error in
        // do something with error
    }

or with async await

do {
    let toto: Todo = Client<TodosRepository>()
	.request(for: .add(title: "Go shopping!"))
	.async()
} catch let error {
    print(error.localizedDescription)
}

Queue Hooks

Hooks are closures that run before and/or after a request execution in a queue. The following hooks are available:

Queue.shared.beforeAllRequestsCallback = {
    // e.g. show progress loader
}

Queue.shared.afterAllRequestsCallback = { completedWithError in
    // e.g. hide progress loader
}

Queue.shared.beforeEachRequestCallback = { request in
    // do something with request
}

Queue.shared.afterEachRequestCallback = { request, data, urlResponse, error
    // do something with request, data, urlResponse, error
}

For more information take a look at Queue in documentation.

File/Data Upload

You can use the .upload, .asyncUpload methods of a Request object to start an upload operation. The upload is perfomed by passing a Content-Type: multipart/form-data request header. All the param values should be passed as MultipartFormDataPartType.

Example

do {
    try await Request(method: .post,
	    url: "https://mywebsite.com/upload",
	    params: [
		"file1": MultipartFormDataPartType.url(.init(filePath: "/path/to/file.zip")),
		"file2": MultipartFormDataPartType.data(data: Data(), filename: "test.png", contentType: "zip"),
		"expiration_date": MultipartFormDataPartType.value(value: Date.now.ISO8601Format())
	    ])
    .asyncUpload(as: ResponseModel.self) { _, _, progress in
	debugPrint("\(progress * 100)% completed")
    }

    debugPrint("Upload finished)
} catch let error {
    debugPrint(error)
}

File Download

You can use the .download, .asyncDownload methods of a Request object to start a download operation. The only thing you need to pass is the local file path of the file to be saved.

Example

guard var localFile = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first.appendPathComponent("download.zip") else {
    return
}

do {
    try await Request(method: .get, 
		      url: "https://mywebsite.com/files/download.zip")
	.asyncDownload(destinationPath: localFile.path,
		       progressUpdate: { bytesSent, totalBytes, progress in
		debugPrint("\(progress * 100)% completed")
	})
} catch let error {
    debugPrint(error.localizedDescription)
}

debugPrint("File saved to: \(localFile.path)")

Error Handling

TermiNetwork provides its own error types (TNError) for all the possible error cases. These errors are typically returned in onFailure callbacks of start methods.

To see all the available errors, please visit the TNError in documentation.

Example

Client<TodosRepository>().request(for: .add(title: "Go shopping!"))
      .success(responseType: Todo.self) { todo in
         // do something with todo
      }
      .failure: { error in
          switch error {
          case .notSuccess(let statusCode):
               debugPrint("Status code " + String(statusCode))
               break
          case .networkError(let error):
               debugPrint("Network error: " + error.localizedDescription)
               break
          case .cancelled:
               debugPrint("Request cancelled")
               break
          default:
               debugPrint("Error: " + error.localizedDescription)
       }

or with async await

do {
    let todo: Todo = Client<TodosRepository>()
	.request(for: .add(title: "Go shopping!"))
	.async()
} catch let error {
    switch error as? TNError {
    case .notSuccess(let statusCode, let data):
         let errorModel = try? data.deserializeJSONData() as MyErrorModel
	 debugPrint("Status code " + String(statusCode) + ". API Error: " + errorModel?.errorMessage)
	 break
    case .networkError(let error):
	 debugPrint("Network error: " + error.localizedDescription)
	 break
    case .cancelled:
	 debugPrint("Request cancelled")
	 break
    default:
	 debugPrint("Error: " + error.localizedDescription)
 }

Cancelling a request

You can cancel a request that is executing by calling the .cancel() method.

Example

let params = ["title": "Go shopping."]
let headers = ["x-auth": "abcdef1234"]

let request = Request(method: .get, 
	      url: "https://myweb.com/api/todos", 
	      headers: headers, 
	      params: params)

	
request.success(responseType: Todo.self) { todos in
    print(todos)
}
.failure { error in
    print(error.localizedDescription)
}
	
request.cancel()

or with async await:

let task = Task {
    let request = Request(method: .get, 
	url: "https://myweb.com/api/todos", 
	headers: headers, 
	params: params)
    do {
        let todos: [Todo] = try await request.async()
        print(todos)
    } catch let error { 
        print(error.localizedDescription)
    }
}

task.cancel()

Reachability

With Reachability you can monitor the network state of the device, like whether it is connected through wifi or cellular network.

Example

let reachability = Reachability()
try? reachability.monitorState { state in
    switch state {
    case .wifi:
        // Connected through wifi
    case .cellular:
        // Connected through cellular network
    case .unavailable:
        // No connection
    }
}

Transformers

Transformers enables you to convert your Rest models to Domain models by defining your custom transform functions. To do so, you have to create a class that inherits the Transformer class and specializing it by providing the FromType and ToType generics.

The following example transforms an array of RSCity (rest) to an array of City (domain) by overriding the transform function.

Example

final class CitiesTransformer: Transformer<[RSCity], [City]> {
    override func transform(_ object: [RSCity]) throws -> [City] {
        object.map { rsCity in
            City(id: UUID(),
                 cityID: rsCity.id,
                 name: rsCity.name,
                 description: rsCity.description,
                 countryName: rsCity.countryName,
                 thumb: rsCity.thumb,
                 image: rsCity.image)
        }
    }
}

Finally, pass the CitiesTransformer in the Request's start method:

Example

Client<CitiesRepository>()
    .request(for: .cities)
    .success(transformer: CitiesTransformer.self) { cities in
        self.cities = cities
    }
    .failure { error in
        switch error {
        case .cancelled:
            break
        default:
            self.errorMessage = error.localizedDescription
        }
    }

or with async await

do {
    let cities = await Client<CitiesRepository>()
        .request(for: .cities)
        .async(using: CitiesTransformer.self)
} catch let error {
    switch error as? TNError {
    case .cancelled:
        break
    default:
        self.errorMessage = error.localizedDescription
    }
}

Mock responses

Mock responses is a powerful feature of TermiNetwork that enables you to provide a local resource file as Request's response. This is useful, for example, when the API service is not yet available and you need to implement the app's functionality without losing any time. (Prerequisite for this is to have an API contract)

Steps to enable mock responses

  1. Create a Bundle resource and put your files there. (File > New -> File... > Settings Bundle)
  2. Specify the Bundle path in Configuration

    Example

    let configuration = Configuration()
    if let path = Bundle.main.path(forResource: "MockData", ofType: "bundle") {
        configuration.mockDataBundle = Bundle(path: path)
    }
  3. Enable Mock responses in Configuration

    Example

    configuration.mockDataEnabled = true
  4. Define the mockFilePath path in your endpoints.

    Example

    enum CitiesRepository: EndpointProtocol {
        case cities
    
        func configure() -> EndpointConfiguration {
        switch self {
        case .cities:
            return EndpointConfiguration(method: .get,
                                         path: .path(["cities"]),
                                         mockFilePath: .path(["Cities", "cities.json"]))
            }
        }
    }
    The example above loads the Cities/cities.json from MockData.bundle and returns its data as Request's response.

For a complete example, open the demo application and take a look at City Explorer - Offline Mode.

Interceptors

Interceptors offers you a way to change or augment the usual processing cycle of a Request. For instance, you can refresh an expired access token (unauthorized status code 401) and then retry the original request. To do so, you just have to implement the InterceptorProtocol.

The following Interceptor implementation tries to refresh the access token with a retry limit (5).

Example

final class UnauthorizedInterceptor: InterceptorProtocol {
    let retryDelay: TimeInterval = 0.1
    let retryLimit = 5

    func requestFinished(responseData data: Data?,
                         error: TNError?,
                         request: Request,
                         proceed: @escaping (InterceptionAction) -> Void) {
        switch error {
        case .notSuccess(let statusCode):
            if statusCode == 401, request.retryCount < retryLimit {
                // Login and get a new token.
                Request(method: .post,
                        url: "https://www.myserviceapi.com/login",
                        params: ["username": "johndoe",
                                 "password": "p@44w0rd"])
                    .success(responseType: LoginResponse.self) { response in
                        let authorizationValue = String(format: "Bearer %@", response.token)

                        // Update the global header in configuration which is inherited by all requests.
                        Environment.current.configuration?.headers["Authorization"] = authorizationValue

                        // Update current request's header.
                        request.headers["Authorization"] = authorizationValue

                        // Finally retry the original request.
                        proceed(.retry(delay: retryDelay))
                    }
            } else {
	 	// Continue if the retry limit is reached
	    	proceed(.continue)
            }
        default:
            proceed(.continue)
        }
    }
}

Finally, you have to pass the UnauthorizedInterceptor to the interceptors property in Configuration:

Example

let configuration = Configuration()
configuration.interceptors = [UnauthorizedInterceptor.self]

SwiftUI/UIKit Image Helpers

TermiNetwork provides two different helpers for setting remote images.

SwiftUI Image Helper

Examples

  1. Example with URL

    var body: some View {
        TermiNetwork.Image(withURL: "https://example.com/path/to/image.png",
    	               defaultImage: UIImage(named: "DefaultThumbImage"))
    }
  2. Example with Request

    var body: some View {
        TermiNetwork.Image(withRequest: Client<CitiesRepository>().request(for: .image(city: city)),
                           defaultImage: UIImage(named: "DefaultThumbImage"))
    }

UIImageView, NSImageView, WKInterfaceImage Extensions

  1. Example with URL

    let imageView = UIImageView() // or NSImageView (macOS), or WKInterfaceImage (watchOS)
    imageView.tn_setRemoteImage(url: sampleImageURL,
                                defaultImage: UIImage(named: "DefaultThumbImage"),
                                preprocessImage: { image in
        // Optionally pre-process image and return the new image.
        return image
    }, onFinish: { image, error in
        // Optionally handle response
    })
  2. Example with Request

    let imageView = UIImageView() // or NSImageView (macOS), or WKInterfaceImage (watchOS)
    imageView.tn_setRemoteImage(request: Client<CitiesRepository>().request(for: .thumb(withID: "3125")),
                                defaultImage: UIImage(named: "DefaultThumbImage"),
                                preprocessImage: { image in
        // Optionally pre-process image and return the new image.
        return image
    }, onFinish: { image, error in
        // Optionally handle response
    })

Middleware

Middleware enables you to modify headers, params and response before they reach the success/failure callbacks. You can create your own middleware by implementing the RequestMiddlewareProtocol and passing it to a Configuration object.

Take a look at ./Examples/Communication/Middleware/CryptoMiddleware.swift for an example that adds an additional encryption layer to the application.

Debug Logging

You can enable the debug logging by setting the verbose property to true in your Configuration.

let configuration = Configuration()
configuration.verbose = true

... and you will see a beautiful pretty-printed debug output in debug window

Tests

To run the tests open the Xcode Project > TermiNetwork scheme, select Product -> Test or simply press ⌘U on keyboard.

Contributors

Alex Athanasiadis, alexanderathan@gmail.com

License

TermiNetwork is available under the MIT license. See the LICENSE file for more info.