Leash
Index
Introduction
Do you use Alamofire? Are you used to create some network layer called APIManager
or Alamofire+NameOfProject
in every new project? Are you used to spend time deintegrating and taking what you need from previous implementations? Or even coding what you have previously done? What if there is a solution and you should not deal with this anymore?
Now, instead of creating a network layer, you create Interceptors
, which are responsible for intercepting the requests at different moments of its life cycle (depending on the requirements). Go to Interceptors for more information.
Moreover, Leash
also includes some processes that are common on the network layers, such as encoding, decoding, authentication and more. And, just to clarify, it also uses Alamofire
.
Requirements
- Xcode 10.0+
- Swift 5.0+
Installation
Swift Package Manager
To integrate Leash
into your project using Swift Package Manager, specify it in your Package.swift
:
// swift-tools-version:5.0
import PackageDescription
let package = Package(
name: "YourPackageName",
dependencies: [
.package(
url: "https://github.com/LucianoPolit/Leash.git",
.upToNextMajor(from: "3.2.0")
)
],
targets: [
.target(
name: "YourTarget",
dependencies: [
"Leash",
"LeashInterceptors",
"RxLeash"
]
)
]
)
CocoaPods
To integrate Leash
into your project using CocoaPods, specify it in your Podfile
:
pod 'Leash', '~> 3.2'
pod 'Leash/Interceptors', '~> 3.2'
pod 'Leash/RxSwift', '~> 3.2'
Carthage
To integrate Leash
into your project using Carthage, specify it in your Cartfile
:
github "LucianoPolit/Leash" ~> 3.2
Usage
Setup
First, we need to configure a Manager
. You can see here all the options available. Here is an example:
let manager = Manager.Builder()
.scheme("http")
.host("localhost")
.port(8080)
.path("api")
.build()
Then, we need a Client
to create and execute the requests:
let client = Client(
manager: manager
)
Now, assuming that we have already created an APIEndpoint
with all the reachable endpoints, we can execute requests. For example:
client.execute(
APIEndpoint.readAllUsers
) { (response: Response<[User]>) in
// Do whatever you have to do with the response here.
}
Ok, that is good. But, what if we just want to call it like this?
usersClient.readAll { response in
// Do whatever you have to do with the response here.
}
Much simpler, huh? To keep your project as simple and clean as possible, follow the architecture of the example project.
Encoding
Now that you know how to execute the requests you may be asking how to configure the parameters (because Any
is accepted). There are different options depending if the endpoint is query or body encodable:
- Query types:
QueryEncodable
or[String: CustomStringConvertible]
. - Body types:
Encodable
or[String: Any]
.
Here is an example with all the possibilities:
enum APIEndpoint {
case first(QueryEncodable)
case second([String: CustomStringConvertible])
case third(Encodable)
case fourth([String: Any])
}
extension APIEndpoint: Endpoint {
var path: String {
return "/it/does/not/matter/"
}
var method: HTTPMethod {
switch self {
case .first: return .get
case .second: return .get
case .third: return .post
case .fourth: return .post
}
}
var parameters: Any? {
switch self {
case .first(let request): return request // This is `QueryEncodable`.
case .second(let request): return request // This is `[String: CustomStringConvertible]`.
case .third(let request): return request // This is `Encodable`.
case .fourth(let request): return request // This is `[String: Any]`.
}
}
}
Three different classes are being used to encode the parameters:
- URLEncoding: to encode
QueryEncodable
and[String: CustomStringConvertible]
. - JSONEncoding: to encode
[String: Any]
. - JSONEncoder: to encode
Encodable
.
In case you want to encode the parameters in a different way, you have to override the method Client.urlRequest(for:)
.
Decoding
When you execute a request you have to specify the type of the response. Also, this type must conform to Decodable
. After executing the request, in case of a successful response, you will have the response with a value of the specified type. So, a simple example could be:
client.execute(
APIEndpoint.readAllUsers
) { (response: Response<[User]>) in
// Here, in case of a successful response,
// the `response.value` is of the type `[User]`.
}
The only available option to serialize the response is through the JSONDecoder. In case you need to do it in a different way, you should implement your own response serializer. Yes, it uses the DataResponseSerializerProtocol provided by Alamofire
!
For example, here is how a JSON
response handler might be implemented:
extension DataRequest {
@discardableResult
func responseJSON(
client: Client,
endpoint: Endpoint,
completion: @escaping (Response<Any>) -> Void
) -> Self {
return response(
client: client,
endpoint: endpoint,
serializer: JSONResponseSerializer(),
completion: completion
)
}
}
To facilitate the process of executing requests, you should add a method like this to the Client
:
extension Client {
@discardableResult
func execute(
_ endpoint: Endpoint,
completion: @escaping (Response<Any>) -> Void
) -> DataRequest? {
do {
return request(for: endpoint)
.responseJSON(
client: self,
endpoint: endpoint,
completion: completion
)
} catch {
completion(
.failure(
Error.encoding(error)
)
)
return nil
}
}
}
An example of the result of these extensions could be:
client.execute(
APIEndpoint.readAllUsers
) { (response: Response<Any>) in
// Here, in case of a successful response,
// the `response.value` is of the type `Any`.
}
Now, you are able to create your own DataResponseSerializer
and use all the features of Leash
!
Authenticator
Do you need to authenticate your requests? That's something really simple, lets do it!
class APIAuthenticator {
var accessToken: String?
}
extension APIAuthenticator: Authenticator {
static var header: String = "Authorization"
var authentication: String? {
guard let accessToken = accessToken else { return nil }
return "Bearer \(accessToken)"
}
}
Then, you just need to register the authenticator:
let authenticator = APIAuthenticator()
let manager = Manager.Builder()
{ ... }
.authenticator(authenticator)
.build()
Now, you have all your requests authenticated. But, are you wondering about token expiration? Here is the solution!
Interceptors
Now, it is the moment of the most powerful tool of this framework. The Interceptors
gives you the capability to intercept the requests in different moments of its life cycle. There are five different moments to be explicit:
- Execution: called before a request is executed.
- Failure: called when there is a problem executing a request.
- Success: called when there is no problem executing a request.
- Completion: called before the completion handler.
- Serialization: called after a serialization operation.
Three types of Interceptors
are called in every request (Execution
, Failure
or Success
, Completion
). Also, there is one more type that is called depending if you are serializing the response or not (Serialization
).
The Manager
can store as many Interceptors
as you need. And, at the moment of being called, it is done one per time, asynchronously, in a queue order (the same order in which they were added). In case that one of any type is completed requesting to finish the operation, no more Interceptors
of that type are called.
One of the best advantages is that the Interceptors
does not depend between them to work. So, any of them is a piece that can be taken out of your project at any moment (without having to deal with compilation issues). Moreover, you can take any of these pieces and reuse them in any other project without having to make changes at all!
Below I will show you how to interact with the different types with some examples. There are a lot more use cases, it is up to you and your requirements!
Just as a reminder, do not forget to add the Interceptors
to the Manager
, like this:
let manager = Manager.Builder()
{ ... }
.add(
interceptor: Interceptor()
)
.build()
Execution
The purpose of this Interceptor
is to intercept before a request is executed. Let me show you two examples:
First, the simplest one, we need to log every request that is executed:
class LoggerInterceptor: ExecutionInterceptor {
func intercept(
chain: InterceptorChain<Data>
) {
defer { chain.proceed() }
guard let request = try? chain.request.convertible.asURLRequest(),
let method = request.httpMethod,
let url = request.url?.absoluteString
else { return }
Logger.shared.logDebug("👉👉👉 \(method) \(url)")
}
}
Now, one more complex, but not more complicated to implement:
class CacheInterceptor: ExecutionInterceptor {
let controller = CacheController()
func intercept(
chain: InterceptorChain<Data>
) {
// On that case, the cache controller may need to finish
// the operation or not (depending on the policies).
// So, we can easily tell the chain wether the operation
// should be finished or not.
defer { chain.proceed() }
guard let cachedResponse =
try? controller.cachedResponse(for: chain.endpoint)
else { return }
chain.complete(
with: cachedResponse.data,
finish: cachedResponse.finish
)
}
}
Failure
Basically, the purpose of this Interceptor
is to intercept when Alamofire
retrieves an error. A simple example could be:
class ErrorValidator: FailureInterceptor {
func intercept(
chain: InterceptorChain<Data>,
error: Swift.Error
) {
defer { chain.proceed() }
guard case Error.some = error else { return }
chain.complete(with: Error.another)
}
}
Success
Basically, the purpose of this Interceptor
is to intercept when Alamofire
retrieves a response. Let me show you two examples:
We know that, sometimes, the API
could retrieve a custom error with more information:
class BodyValidator: SuccessInterceptor {
func intercept(
chain: InterceptorChain<Data>,
response: HTTPURLResponse,
data: Data
) {
defer { chain.proceed() }
guard let error =
try? chain.client.manager.jsonDecoder.decode(APIError.self, from: data)
else { return }
chain.complete(
with: Error.server(error)
)
}
}
Maybe, there is no custom error, but we still need to validate the status code of the response:
class ResponseValidator: SuccessInterceptor {
func intercept(
chain: InterceptorChain<Data>,
response: HTTPURLResponse,
data: Data
) {
defer { chain.proceed() }
let error: Error
switch response.statusCode {
// You should match your errors here.
case 200...299: return
case 401, 403: error = .unauthorized
default: error = .unknown
}
chain.complete(with: error)
}
}
Completion
The purpose of this Interceptor
is to intercept before the completion handler is called. Let me show you two examples:
Again, the simplest one, we need to log every response:
class LoggerInterceptor: CompletionInterceptor {
func intercept(
chain: InterceptorChain<Data>,
response: Response<Data>
) {
defer { chain.proceed() }
guard let request = try? chain.request.convertible.asURLRequest(),
let method = request.httpMethod,
let url = request.url?.absoluteString
else { return }
switch response {
case .success:
Logger.shared.logDebug("✔✔✔ \(method) \(url)")
case .failure(let error):
Logger.shared.logDebug("✖✖✖ \(method) \(url)")
Logger.shared.logError(error)
}
}
}
Now, one more complex, we have to update the authentication
when expired. There are two options here:
- Use the Adapter and Retrier provided by
Alamofire
. - Use the Authenticator and an
Interceptor
! Let me show you how it should look like:
class AuthenticationValidator: CompletionInterceptor {
func intercept(
chain: InterceptorChain<Data>,
response: Response<Data>
) {
guard let error = response.error, case Error.unauthorized = error else {
chain.proceed()
return
}
RefreshTokenManager.shared.refreshTokenIfNeeded { authenticated in
guard authenticated else {
chain.complete(with: Error.unableToAuthenticate)
return
}
do {
try chain.retry()
} catch {
// In almost every case, no error is thrown.
// But, because we prefer the safest way, we use the do-catch.
chain.complete(with: Error.unableToRetry)
}
}
}
}
Serialization
This is the last Interceptor
in being called. Also, it is optional. It depends if you are serializing your response or you only need the Data
.
The serialization process is built on top of the process of getting the Data
from the request. This is why it is called after all the previous Interceptors
. Let me show you an example:
We started before with the CacheController
, right? Well, now we need to update the cache in case that the response was serialized successfully:
class CacheInterceptor: SerializationInterceptor {
let controller = CacheController()
func intercept<T: DataResponseSerializerProtocol>(
chain: InterceptorChain<T.SerializedObject>,
response: Response<Data>,
result: Result<T.SerializedObject, Swift.Error>,
serializer: T
) {
defer { chain.proceed() }
guard let value = response.value,
(try? result.get()) != nil
else { return }
controller.updateCacheIfNeeded(
for: chain.endpoint,
value: value
)
}
}
RxSwift
Do you use RxSwift? There is an extension for that! Let me show you how to use it:
client.rx.execute(
APIEndpoint.readAllUsers,
type: [User].self
).subscribe { event in
// Do whatever you have to do with the response here.
}
Ok, that is good. But, what if we just want to call it like this?
usersClient.rx.readAll().subscribe { event in
// Do whatever you have to do with the response here.
}
Much simpler, again, huh? As mentioned before, to keep your project as simple and clean as possible, follow the architecture of the example project.
Communication
- If you need help, open an issue.
- If you found a bug, open an issue.
- If you have a feature request, open an issue.
- If you want to contribute, submit a pull request.
Author
Luciano Polit, [email protected]
License
Leash
is available under the MIT license. See the LICENSE file for more info.