Skip to content

Latest commit

 

History

History
278 lines (207 loc) · 13.1 KB

3.Advanced_HTTPClient.md

File metadata and controls

278 lines (207 loc) · 13.1 KB

Advanced HTTP Client

Why use a custom HTTPClient

When your app is complex and you need to manage different http webservices, or you want to avoid using the shared client to create a decupled implementation, creating a custom HTTPClient is the best thing you can do.

With a custom HTTPClient you can define your own rules to validate and handle a response coming from a particular webservice, as well as any edge cases you may encounter.

This is a custom client:

public lazy var b2cClient: HTTPClient = {
    var config = URLSessionConfiguration.default
    config.httpShouldSetCookies = true
    config.networkServiceType = .responsiveData
        
    let client = HTTPClient(baseURL: "https://myappb2c.ws.org/api/v2/",configuration: config)

    // Setup some common HTTP Headers for all requests
    client.headers = HTTPHeaders([
        .init(name: .userAgent, value: myAgent),
        .init(name: "X-API-Experimental", value: "true")
    ])
        
    return client
}()

It contains a particular URLSessionConfiguration and a set of common HTTP Headers which are automatically used by any HTTPRequest you run on it.

Validate Responses: Validators

Each raw response from a network call can be validated by a set of objects that conform to the HTTPValidator protocol.

HTTPClient instances have a validators array which may contain an ordered list of validators which respond to this method:

func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult { }

This function analyzes the response coming from request and determines the next step.
In particular, you can return:

Approve the response

nextValidator
The response is okay, you can move to the next validator (if any) or return the response received by the server.
The HTTPResponse is a class where the methods are open, so you can alter these values inside the validators if you need.

Approve with a custom response

nextValidatorWithResponse
The response is okay, you can move to the next validator (if any) or return. In this case you can return a new HTTPResponse subclass with additional properties.
This is the case where you must do some additional business logic with your response before sending it outside the library.

Fail with error

failChain(Error)
Received response is not valid (for example you have an error node in your json response which indicates the failure). You can parse the response and return a custom error bypassing the initial response.

Retry with strategy

retry(HTTPRetryStrategy)
Something bad has occurred; however, you can retry if maxRetries of the HTTPRequest is > 1.
The options are:

  • immediate will retry the original call immediately
  • delayed will retry the original call after a given amount of seconds
  • exponential and fibonacci are the same as delayed, but with sequentially increasing delay times
  • after(HTTPRequest, TimeInterval, AltRequestCatcher?) will retry the original call after calling an alternate request. For example, if you are making an authenticated request and the session has expired; you can then call a login alternate request to perform a new login and retry the original call.
  • afterTask(TimeInterval, RetryTask, RetryTaskErrorCatcher?) performs an async task before retrying the original request. By using an async Task, you may perform work outside of the scope of RealHTTP and inject whatever you need into the original request. Note that, like with the existing retry with HTTPRequest strategy, any error triggered by the async Task is not propagated to the original request. However, a callback could be provided to at least "see" it.

We'll take a closer look at these strategies below.

The Default Validator

Each new client implements a single validators object called HTTPDefaultValidator.
This object contains the standard logic to validate a response from the server.
In particular, it:

  • Checks for empty responses. If you set allowsEmptyResponses = false when an empty response is received, the chain will fail with an HTTPError(.emptyResponse) error.
  • Checks the HTTP status code. If the code is an error code, the chain may fail (see the check above)
  • If HTTP status code is an error code or the underlying URLSession received an error code (timeout, connection drop etc.) the retriableHTTPStatusCodes map is read. If the error is in that list, then a new retry may be triggered (but only if maxRetries of the original HTTPRequest > 0).

This validator should never be removed unless you have a really unusual use case for parsing and validating errors.
Typically, you will want to add a new validator after this, in order to perform your own logic for handling the unique setup of your webservice.

Alt Request Validator

RealHTTP also provides a special validator called HTTPAltRequestValidator. This validator can be used when you need to execute a specific HTTPRequest if another request fails for a given reason.

A typical example would be the silent login operation; if you receive an unauthorized or .forbidden error for a protected resource you may want to try a silent login operation, then re-execute the initial (failed) request.

The HTTPAltRequestValidator is triggered by certain HTTP status codes; by default 401/403 require a callback which returns a specific HTTPRequest for a certain failed request.

NOTE: By default this validator is not triggered by network failure. If you want to perform it even when no response is received from server, add the HTTPStatusCode .none to the list in the statusCodes property.

Often, you will want to execute your validator first (before the default one).
This is an example which performs a JWT session token refresh before retrying the initial request:

let client = HTTPClient(...)
// The alt validator is triggered only when 401 error is received from any request's response.
let authValidator = HTTPAltRequestValidator(statusCodes: [.unauthorized], { request, response in
    // If triggered here you'll specify the alt call to execute in order to refresh a JWT session token
    // before any retry of the initial failed request.
    return HTTPRequest("https://.../refreshToken")
} onReceiveAltResponse: { request, response in
    // Once you have received response from your `refreshToken` call
    // you can do anything you need to use it.
    // In this example we'll set the global client's authorization header.
    let receivedToken = response.data...
    client.headers.set(.authorization, receivedToken)
}

// append at the top of the validators chain
client.validators.insert(authValidator, at: 0)

Custom Validators

When your client has custom logic to run before returning responses you can create your own validator to ensure all your requests are managed by the same validation logic.

Consider a webservice which always returns a JSON object with the following keys:

  • code: 0 if everything is okay, 1 if an error has occurred
  • errorMsg: the message error, if not null something bad occurred
  • data: a dictionary with the response of the request, must be always present

We can create a custom validator for this logic as seen below:

import SwiftyJSON

public class MyBadWSValidator: HTTPValidator {
    
    public func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult {
        // Structure logic check
        guard let data = response.data, let jsonData = JSON(data) else {
            return .failChain(HTTPError(.invalidResponse)) // response must be always JSON, no retry is allowed
        }
        
        guard data["code"].intValue == 0 else {
            let errorMsg = data["errorMsg"].string ?? "Unknown error"
            return .failChain(HTTPError(.internal, errorMsg)) // an error has occurred
        }
        
        // Business logic check
        let isRetriable = data["retriable"].boolValue
        
        guard data["data"].notExist == true, data["data"].type != JSON.Type.dictionary else {
            if isRetriable {
                return .retry(.fibonacci)
            }
            return .failChain(HTTPError(.invalidResponse)) // response must be always JSON, no retry is allowed
        }
        
        return .nextValidator // everything is okay
    }
    
}

To add this validator to your client next to the default one just append it to the validators property:

// Configure client
let client = HTTPClient(...)
client.validators.append(MyBadWSValidator())

// Prepare a request
let req = HTTPRequest(...)
req.maxRetries = 3

let result = try await req.fetch(client)

Once you set it all the requests executed in this client will also be validated by your own validator.

Retry After [Another] Call

The retry strategy called .after(HTTPRequest, TimeInterval, AltRequestCatcher?) allows you to execute an alternate request if the initial one fails, and then retry the initial one again.

This kind of retry is particularly useful for silent login when an authenticated request fails due to an expired session.

Consider this auth call:

let usersBooks = HTTPRequest("https://.../user/books/scifi")
userBooks.headers = HTTPHeaders([
    "X-Token": authToken
])
let books = try await usersBooks.fetch()

What happens if token is expired? Your call fails with a poor user experience.
You can make it a better experience by attempting to refresh the token and automatically retry your call.
How?

First, create your own custom validator:

import SwiftyJSON

public class SilentLoginValidator: HTTPValidator {
    
    /// This is the request which is used to refresh the token
    public var tokenRefreshRequest: HTTPRequest
    
    public func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult {
        guard response.statusCode == .unauthorized else {
            // If unauthorized error has occurred we'll try to make a silent login and
            // retry the initial request after 0.3 seconds by setting the authorization token,
            let silentLogin: HTTPRetryStrategy = .after(tokenRefreshRequest, 0.3) { request, response in
                if let response = JSONSerialization.jsonObject(with: response.data ?? Data(), options: .fragmentsAllowed) as? [String: String] {
                   // Set the new received token
                    request.headers.set("X-Token", response["token"] as! String)
                }
            }
            return .retry(silentLogin)
        }
        
        // No error, move to the next validator
        return .nextValidator
    }
    
}

Just set this validator to automatically retry after performing a silent login.

Retry by modifying the original request

This strategy performs an async task before retrying the original request.

With the after() strategy, you must provide another HTTPRequest to be performed before the retry. If the request Authorization should be handled by something other than an HTTP service, there is no way to interface with the Validator to create/refresh the authorization before the retry.

However, by using afterTask(), you may perform work outside of the scope of RealHTTP and inject whatever the user wants into the original request.
Note that, like with the existing retry with HTTPRequest strategy, any error triggered by the async Task is not propagated to the original request. However, a callback could be provided to at least "see" it.

// Define an async function called before retry the original request.
let patchRequestAndRetryTask: (HTTPRequest) async throws -> Void = { originalRequest in
    originalRequest.headers.set(.authBearerToken("abcdefg"))
}

// Create a custom client with a callback validator to show up the action
let newClient = HTTPClient(baseURL: nil)
newClient.validators = [
    CallbackValidator { response, request in
        if request.currentRetry < 2 {
            return .retry(.afterTask(4, { originalRequest in
                // we can specify an async function which modify the original request
                // before retry it, after 4 seconds.
                try await patchRequestAndRetryTask(originalRequest)
            }, { error in
                retryTaskError = error
            }))
        } else {
            // just one retry, if it fails an error is triggered
            return .failChain(HTTPError(.tooManyRequests)) 
        }
    }
]

// Execute any request
let aRequest = HTTPRequest { // configure your request }
let aResponse = try await aRequest.fetch(newClient)