The network layer, represented by the Network
type, serves as a namespace encapsulating the different pieces required to deal with core networking operations.
The core element is the network stack, acting as the main entry point for any networking operations. In its turn, a stack can have a configuration that may provide additional functionality to the base implementation, such as a server trust validator or request interceptors.
The network stack, represented by the NetworkStack
protocol, is the centerpiece of a network layer.
This protocol has the following associated types:
Resource
- the remote resource representation, which acts as the stack's unit of work and provides any required capabilities, metadata and/or state (e.g. retries).Remote
- the remote payload's raw type, typically a byte buffer representation likeData
.Request
- the underlying network client's request type, likeURLRequest
on aURLSession
.Response
- the underlying network client's response type, likeURLResponse
on aURLSession
.FetchError
- the underlying network client's error type, likeURLError
on aURLSession
.
It enforces a single function fetch
that represents a network request, which as its name implies fetches a Resource
and then calls a completion block with a result, which is either a successful value (wrapping the remote data and response objects) or a failed one (wrapping a network error).
It provides a default fetchAndDecode
function implementation that builds upon fetch
and requires an additional ModelDecoding
instance to decode a network value upon a successful response into an arbitrary T
.
Although one can conform to the NetworkStack
protocol to create a network stack from scratch, Alicerce provides a default stack implementation to handle HTTP requests, represented by the URLSessionNetworkStack
class, which should cover most of the use cases. Internally, this stack is backed by a URLSession
.
To be instantiated, a URLSessionNetworkStack
requires the following dependencies:
-
The authentication challenge handler, represented by the
AuthenticationChallengeHandler
protocol, has a single method for handling challenges from a server requiring authentication from the client (e.g. establishing an encrypted TLS session).- The
ServerTrustEvaluator
is an implementation of aAuthenticationChallengeHandler
that performs HTTP Public Key Pinning (HPKP) validation, based on RFC 7469 (not strict), by pinning the Certificates' Subject Public Key Info (SPKI).
- The
-
The retry queue, represented by a
DispatchQueue
, is the queue that will be used by the network stack to reschedule (retry) any resources that have failed and a delay is defined by the resource's retry policies, via aasyncAfter()
call.
The URLSessionNetworkStack
constrains to the following types to conform to the NetworkStack
protocol:
Resource == URLSessionResource
Remote == Data
Response == URLResponse
FetchError == URLSessionError
The resource is the work unit of a Network stack, and abstracts an object that can have multiple representations (e.g. local, remote), capabilities and requirements. This allows each stack to require the set of behaviors it requires in a single concrete type, reducing the complexity of generic constraints.
To fetch data using a URLSessionNetworkStack
we need to use a URLSessionResource
, which one can build by passing in the following dependencies:
-
A
BaseRequestMaking<URLRequest>
instance: attempts to generate a new baseURLRequest
asynchronously every time a request is scheduled for this Resource. This base request is then processed by the interceptor chain to possibly be enriched/modified before being scheduled on the network. -
An
ErrorDecoding<Data, URLResponse>
instance: attempts to decode an arbitrary custom error (e.g. API error) from the payload and response whenever a request completes with an unsuccessful HTTP status code (i.e. not 2xx). -
An array of
URLSessionResourceInterceptor
: as the name implies, these are objects that intercept key events in the lifecycle of a resource. They are chained and executed in order for each event, allow countless customizations in a resource's flow and business logic. Examples include passive interception for logging or performance measuring purposes, or active interception to support custom authentication or retries. -
A
Retry.Action.CompareClosure
closure: compares twoRetry.Action
s to determine which one should prevail when iterating over the retry actions returned by all the resource's interceptors upon request failure. A default implementation is already provided.
The HTTPResourceEndpoint
protocol represents an HTTP resource's endpoint and contains the most frequent components required to create and configure a URLRequest
, via a makeRequest()
function. When conformed to by an enum
type, it provides an elegant way to model the different endpoints of an API, while allowing complete control over the resulting URLRequest
s.
It provides a protocol extension containing a default implementation of makeRequest()
, which should serve most needs.
The BaseRequestMaking<Request>
contains a single closure property make
which attempts to generate a new base Request
whenever required, while providing a Cancelable
instance to allow cancelling any pending asynchronous work.
By being a struct and not a protocol (while modelling the same behavior), it greatly simplifies generics and allows easy default implementations via static factory methods. It currently provides an .endpoint()
helper to build a BaseRequestMaking<URLRequest>
from a particular HTTPResourceEndpoint
instance.
The ErrorDecoding<Payload, Metadata>
type contains a single closure property decode
which attempts to decode an arbitrary Error
instance from a given Payload?
and Metadata
whenever a request completes with an unsuccessful HTTP status code (i.e. not 2xx).
The Payload
is the main source of data to perform the decoding, but on some scenarios an additional Metadata
can be helpful (e.g. information contained in response headers).
By being a struct and not a protocol (while modelling the same behavior), it greatly simplifies generics and allows easy default implementations via static factory methods. It currently provides a .json()
helper to build an ErrorDecoding<Data, _>
that attempts to decode a particular Decodable
error type E
encoded in JSON using a JSONDecoder
.
The URLSessionResourceInterceptor
protocol represents an entity that intercepts specific events of a URLSessionResource
's lifecycle. These events are:
-
Make Request: invoked before a particular resource's
URLRequest
is scheduled on the session via aURLSessionDataTask
. The interceptor receives either the base request result (from the resource'sBaseRequestMaking
witness), or the result from the previous interceptor. The interceptor is then able to modify the request result or not, according to its needs (e.g. authenticate the request, or just log the event). -
Scheduled Task: invoked before a particular resource's
URLSessionDataTask
is scheduled. Interceptors can't modify any behavior at this point, so it's mostly suited for logging purposes and/or performance metrics. -
Successful Task: invoked when a particular resource's
URLSessionDataTask
has completed successfully. Interceptors can't modify any behavior at this point, so it's mostly suited for logging purposes and/or performance metrics. -
Failed Task: invoked when a particular resource's
URLSessionDataTask
has completed with an error. Interceptors receive information about the failure and current resource context, and should return a specificRetry.Action
for the stack to perform for this resource. This allows complex behaviors to be created on a perURLSessionResource
basis, like respond to authentication failures, or apply a retry policy. The event is processed by all elements in the interceptor chain, and the most prioritary retry action is obtained via the resource'sretryActionPriority
property.
Alicerce already provides default URLSessionResourceInterceptor
implementations on the following types:
-
URLRequestAuthenticator
: provides default implementation to the make request and failed task events, to handle authentication. -
URLSessionRetryPolicy
: provides default protocol conformance and implements the failed task event, to apply a specific retry policy to a resource.
The URLRequestAuthenticator
protocol represents an entity that handles authentication of URLRequest
's. The authenticator has two main entry points:
- Request authentication: invoked to authenticate a request before scheduling
- Request failure: invoked to handle and react to authentication failures, which allows the authenticator to trigger reauthentication under the hood as well as provide a specific retry action to apply to the request's parent operation (e.g. a
URLSessionResource
).
Can be used as an element of a URLSessionResource
's interceptor chain.
Alicerce provides a set of types tailored to handling retries of arbitraty operations, namespaced under the [Retry
][Retry] enum
. The types are:
-
Retry.Action
: represents the action to take after evaluating a retry policy. It can be:none
: don't take any action.noRetry(Retry.Error)
: don't retry the operation due to the specified error.retry
: retry the operation immediately.retryAfter(Retry.Delay)
: retry the operation after the specified delay.
-
Retry.Error
: represents an error that caused the operation to not be retried:retries(Retries)
: the maximum amount of retries have been reached.delay(Delay)
: the maximum retry delay has been reached.custom(Error)
: an arbitrary error has prevented the operation from being retried.
-
Retry.State
: contains the retry state and history of an operation, so that policies can be correctly evaluated against. -
Retry.Policy<Metadata>
: models a policy to evaluate operations against. It can be:maxRetries(Retries)
: limit the total number of retries.backoff(Backoff)
: applies a backoff strategy to delay and limit retries until a particular truncation rule. Current available strategies areconstant
andexponential
, and truncations aremaxRetries
andmaxDelay
.custom(Rule)
: applies a custom rule consisting of a closure.
The
Metadata
generic type is used so that arbitrary data about the operation can be passed into custom rules, so that more complex behaviors can be achieved. As an example,URLSessionResource
uses a URLSessionRetryPolicy, which is a typealias forRetry.Policy<(URLRequest, Data?, URLResponse?)>
. This enables custom rules to inspect things like the request URL, payload or response headers, unlocking a fine grained control over the resulting retry action.Whenever an operation fails, a particular policy is evaluated using the
shouldRetry
function and produces aRetry.Action
.Complex retry rulesets can be built by composing multiple policies (e.g. an array). Upon operation failure, these can be evaluated serially to obtain their respective retry actions from which a single "most prioritary" action emerges. A default implementation for this comparison is provided in the
Retry.Action.mostPrioritary()
static function.Can be used as an element of a
URLSessionResource
's interceptor chain.
In the same way that network requests are commonly made via HTTP, it's also very frequent for them to require some form of authentication. To address these scenarios, another set of protocols was added to abstract common mechanics and make our life easier:
-
[
RequestAuthenticator
][RequestAuthenticator] protocol: represents an object that authenticates requests of a given typeRequest
, and also defines a custom authenticationError
type. It defines a single functionauthenticate
that authenticates requests asynchronously.-
[
RetryableRequestAuthenticator
][RetryableRequestAuthenticator] protocol: represents aRequestAuthenticator
that provides a retry policy rule to handle authentication errors. It defines theRemote
andResponse
types that are then used to compose theRetryMetadata
used by the authenticator'sRetryPolicy
(together with theRequest
). What enables the resource's authentication errors to be handled by the authenticator is theretryPolicyRule
property, which should be injected as a.custom
policy in the resource'sretryPolicies
.- [
RetryableURLRequestAuthenticator
][RetryableURLRequestAuthenticator] protocol: represents aRetryableRequestAuthenticator
specialized to authenticateURLRequest
's withData
remote type andURLResponse
's.
- [
-
URLRequestAuthenticator
protocol: represents aRequestAuthenticator
specialized to authenticateURLRequest
's.
-
-
[
AuthenticatedRequestResource
][AuthenticatedRequestResource] protocol: represents aRequestResource
that can be fetched using authenticatedRequest
's. It defines anAuthenticator: RequestAuthenticator
type that is used as the resource'sauthenticator
property type.The
NetworkResource
protocol contains an extension that provides a default implementation ofmakeRequest
whenSelf
also conforms toBaseRequestResource & AuthenticatedRequestResource
, by returning thebaseRequest
authenticated by theauthenticator
.
With the above protocols, we have the necessary infrastructure to fetch resources that require authentication. Additionally, since the authentication logic is only coupled to each Resource type and can (and should) be made asynchronously, it allows sharing the same network stack for all resources of an app, including authentication ones! 💪
As mentioned above, the NetworkStack
provides a fetchAndDecode
function that automatically fetches and decodes a Resource
, give it's provided with a ModelDecoding
witness. The failure type is a FetchAndDecodeError
to allow the caller to differentiate the "origin" of the failure.
The ModelDecoding<T, Payload, Metadata>
type contains a single closure property decode
which attempts to decode an arbitrary T
instance from a given Payload
and Metadata
whenever a request completes with a successful HTTP status code (i.e. 2xx, except 204 and 205 which expect empty bodies).
The Payload
is the main source of data to perform the decoding, but on some scenarios an additional Metadata
can be helpful (e.g. information contained in response headers).
By being a struct and not a protocol (while modelling the same behavior), it greatly simplifies generics and allows easy default implementations via static factory methods. It currently provides an .json()
helper to build an Decoding<T, Data, _>
that attempts to decode a particular Decodable
model T
encoded in JSON using a JSONDecoder
.
The fetching action of an HTTP network stack, in case of error, should throw an error of the URLSessionError
type. This type encapsulates the different possible error scenarios:
noRequest
, when the resource'smakeRequest
fails.http
, when the request failed with an HTTP protocol error (i.e. non 2xx status code), and may contain a custom API error decoded by the resource'serrorDecoding
witness.noData
, when the response body is unexpectedly empty.url
, when the request fails with a network failure, expressed as anURLError
(i.e. theerror
in a dataTask's completion handler is nonnil
).badResponse
, when a valid HTTP response is missing.retry
, when the request was explicitly not retried when evaluated by its retry policies after having failed with an error.cancelled
, when the fetch was cancelled via theCancelable
instance.
The FetchAndDecodeError
is a simple error type used on fetchAndDecode
calls and is used to differentiate between errors originating from either the fetch or decode operations. As such, it has just two cases which wrap an error each:
fetch(Error)
decode(Error)
Let's walk through the basic steps required to start making some requests with Alicerce. A similar setup is also available in a Swift playground as a live example.
First, start with a network stack, the centerpiece of your network layer. For HTTP networking, it's simple as initializing a URLSessionNetworkStack
. You need to inject a session into it before making any requests – not doing will result in a fatal error.
import Alicerce
let network = Network.URLSessionNetworkStack(retryQueue: DispatchQueue(label: "com.alicerce.network.retry-queue"))
network.session = URLSession(configuration: .default, delegate: network, delegateQueue: nil)
Note that the delegate assigned to the session must be the network stack itself. A session without a delegate or a delegate that is anything but the stack itself results in a fatal error.
To preserve dependency injection, and since a session's delegate is only defined on its initialization, the session must be injected via a property.
Second, you need to create your implementation of a resource and associated types. The following example uses Swift's Codable
to parse the models and custom API error.
To model our API and the endpoints we will use, we start by creating a custom HTTPResourceEndpoint
type:
enum GitHubEndpoint: HTTPResourceEndpoint {
case repo(owner: String, name: String)
case repoCollaborators(owner: String, name: String, affiliation: RepoAffiliation = .allg)
enum RepoAffiliation: String {
case outside
case direct
case all
}
var method: HTTP.Method {
switch self {
case .repo, .repoCollaborators:
return .GET
}
}
var baseURL: URL { URL(string: "https://api.github.com")! }
var path: String? {
switch self {
case .repo(let owner, let name):
return "/repos/\(owner)/\(name)"
case .repoCollaborators(let owner, let name):
return "/repos/\(owner)/\(name)/collaborators"
}
}
var queryItems: [URLQueryItem]? {
switch self {
case .repo:
return nil
case .repoCollaborators(_, _, let affiliation):
return [URLQueryItem(name: "affiliation", value: affiliation.rawValue)]
}
}
var headers: HTTP.Headers? { ["Accept": "application/vnd.github.v3+json"] }
}
To represent our custom API errors, we also create a type:
enum GitHubAPIError: Error, Decodable {
case generic(message: String)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let message = try container.decode(String.self, forKey: .message)
self = .generic(message: message)
}
private enum CodingKeys: String, CodingKey {
case message
}
}
We can then create a helper to easily build URLSessionResource
s for our API, so that we can fetch them on our network stack:
extension Network.URLSessionResource {
static func github(
endpoint: GitHubEndpoint,
interceptors: [URLSessionResourceInterceptor] = [],
retryActionPriority: @escaping Retry.Action.CompareClosure = Retry.Action.mostPrioritary
) -> Self {
.init(
baseRequestMaking: .endpoint(endpoint),
errorDecoding: .json(GitHubAPIError.self),
interceptors: interceptors
)
}
}
network.fetch(resource: .github(endpoint: .repo(owner: "Mindera", name: "Alicerce"))) { result in
switch result {
case .success(let value):
// network value (raw payload + response)
case .failure(.http(let statusCode, let apiError as GitHubAPIError, let response)):
// API error
case .failure(let error):
// other error
}
}
That's it, we've successfully made your first network request with Alicerce 🎉
We have the JSON payload for a particular API, but we would really like to decode that data into an actual model type.
Let's define our model type for a particular endpoint:
struct GitHubRepo: Decodable {
var name: String
var fullName: String
var stars: Int
private enum CodingKeys: String, CodingKey {
case name
case fullName = "full_name"
case stars = "stargazers_count"
}
}
Now, we can take advantage of the NetworkStack
's fetchAndDecode
method to easily achieve our goal:
network.fetchAndDecode(
resource: .github(endpoint: .repo(owner: "Mindera", name: "Alicerce")),
decoding: .json(GitHubRepo.self)
) { result in
switch result {
case .success(let value):
// decoded value (decoded model + response)
case .failure(.fetch(Network.URLSessionError.http(let statusCode, let apiError as GitHubAPIError, let response))):
// API error
case .failure(let error):
// other error
}
}
Supporting retries on failure is really simple, and you just have to set up your retry policies as a part of the resource's interceptor chain:
// retry with an exponentially higher delay (0.1s x N) until we delayed for a total of 0.4s
let retryInterceptors: [URLSessionResourceInterceptor] = [
Network.URLSessionRetryPolicy.backoff(
.exponential(
baseDelay: 0.1,
scale: { delay, retry in delay * Double(retry) },
until: .maxDelay(0.4)
)
)
]
network.fetch(
resource: .github(
endpoint: .repo(owner: "Mindera", name: "Alicerce"),
interceptors: retryInterceptors
)
) { result in
// ...
}
Our default URLSessionResource.github
resource works perfectly with any non authenticated GitHub endpoint (e.g. like .repo
), but will not be able to fetch any resource from an authenticated GitHub endpoint (e.g. like .repoCollaborators
), since it will fail with an authentication error (e.g. a 401 Unauthorized
).
To address this, we can use a URLRequestAuthenticator
that will authenticate GitHub requests when working alongside our resource. Assuming we will use OAuth2 authentication and we already have an OAuth2 client implementation, there are essentially two approaches:
- Create our custom
URLRequestAuthenticator
type that wraps the OAuth2 client. - Extend the OAuth2 client to conform to
URLRequestAuthenticator
.
In this example, we will follow the 2nd approach:
import YourFavouriteOAuth2Lib
enum OAuth2ClientError: Error {
//...
}
class OAuth2Client {
typealias OAuth2Token = String
// example async API to fetch the current OAuth2 token, or wait for one to be fetched
func token(for request: URLRequest, completion: (Result<OAuth2Token, OAuth2ClientError>) -> Void) -> Cancelable {
// ...
}
}
extension OAuth2Client: URLRequestAuthenticator {
@discardableResult
func authenticateRequest(_ request: URLRequest, handler: @escaping AuthenticationHandler) -> Cancelable {
let cancelableBag = CancelableBag()
// the client is responsible for providing the current token (if any), which it then injects on the request
// ideally this should be made asynchronously so it doesn't block the network stack
cancelableBag += token(for: request) { result in
switch result {
case .failure(let error):
// something went wrong, and the request can't be authenticated
cancelableBag += handler(.failure(error))
case .success(let token):
// the request can be authenticated with the given token
var request = request
var httpHeaders = request.allHTTPHeaderFields ?? [:]
httpHeaders["Authorization"] = "token \(token)"
request.allHTTPHeaderFields = httpHeaders
cancelableBag += handler(.success(request))
}
}
return cancelableBag
}
func evaluateFailedRequest(
_ request: URLRequest,
data: Data?,
response: URLResponse?,
error: Error,
retryState: Retry.State
) -> Retry.Action {
// extract the token used by the failed request (if any)
let rawToken = request.allHTTPHeaderFields?["Authorization"]
let oAuthToken = rawToken?.split(separator: " ").last.flatMap(String.init)
// handle the request's error and evaluate the action to take according to the current authentication state:
// - trigger a (re)auth behind the scenes, and retry the request after some delay
// - ignore the error as the token has already been refreshed, and retry the request
// - mandate that the request should not be retried, as authentication failed
// - ignore the error as the error is not related to authentication
switch (error, self.state) {
case ...:
default:
return .none
}
}
}
Once the authenticator is available, we simply need to add it to our resource's interceptor chain for it to be used:
let authenticator = OAuth2Client(...)
network.fetchAndDecode(
resource: .github(
endpoint: .repoCollaborators(owner: "Mindera", name: "Alicerce", affiliation: .all),
interceptors: [authenticator]
),
decoding: .json([GitHubRepoCollaborator].self)
) { result in
switch result {
case .success(let value):
// decoded value
case .failure(.fetch(Network.URLSessionError.retry(let retryError, let state))):
// API error
case .failure(let error):
// other error
}
}
If all went well, the above resource will now be authenticated when fetched on our network stack. 🔑
Please note that if we don't need to react to authentication errors and retry requests based on them, we can simply return
.none
in theevaluateFailedRequest()
implementation.
As mentioned before, Alicerce provides HTTP Public Key Pinning (HPKP) validation based on RFC 7469 (not strict), thru the ServerTrustEvaluator
class. It works by pinning the Certificates' Subject Public Key Info (SPKI) SHA256 Base64 encoded hashes. Once you decide which certificate(s) you want to pin, you can obtain the SPKI data via either:
-
OpenSSL:
openssl x509 -inform der -in <cert_name> -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64`
-
ssllabs.com
Enter the server's URL -> analyse -> go to Certification Paths -> look for "Pin SHA256" entries
With the above information, you can then configure the ServerTrustEvaluator
instance by providing it a ServerTrustEvaluator.Configuration
object containing any number of ServerTrustEvaluator.PinningPolicy
's you want.
Continuing with our example, this could be a simple certificate pinning setup for our GitHub API client:
// for now, use the expiration date from the certificate itself as the policy's expiration date
let gitHubRootExpirationDate = ISO8601DateFormatter().date(from: "2031-11-10T00:00:00Z")!
let gitHubPolicy = try ServerTrustEvaluator.PinningPolicy(
domainName: "github.com",
includeSubdomains: true,
expirationDate: gitHubRootExpirationDate,
pinnedHashes: ["WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="], // DigiCertHighAssuranceEVRootCA
enforceBackupPin: false // we should ideally have a backup pin that's not in the chain to avoid bricking clients
)
let configuration = try ServerTrustEvaluator.Configuration(
pinningPolicies: [gitHubPolicy],
certificateCheckingOrder: .rootToLeaf,
allowNotPinnedDomains: false,
allowExpiredDomainPolicies: false
)
let serverTrustEvaluator = try ServerTrustEvaluator(configuration: configuration)
let network = Network.URLSessionNetworkStack(
authenticationChallengeHandler: serverTrustEvaluator,
retryQueue: DispatchQueue(label: "com.alicerce.network.retry-queue")
)
network.session = URLSession(configuration: .default, delegate: network, delegateQueue: nil)
// ...
And that's it! Our network stack is now protected by certificate pinning! 📌
For more information on Certificate and Public Key Pinning, please consult the following links:
- OWASP's Certificate and Public Key Pinning page.
- Chris Palmer's About Public Key Pinning blog page (he's one of the authors of the RFC 7469).