This is the technical outline of a proposal to make major changes to our primary networking interface from the current HTTPNetworkTransport to a RequestChainNetworkTransport which uses a chain of interceptor objects to set up and process the results of network requests.
Note that this is going to be a 🎉 Spectacularly 🎉 breaking change - while very surface level APIs will remain basically the same, if you're doing anything remotely advanced, this will necessitate some changes, but the idea is to break it now so we don't have to break it way worse later.
I would REALLY love feedback on this before I start working towards making this the default option. You can see the code changes in-place in this PR. I will be updating this RFC with feedback as it is received.
Why The Change?
HTTPNetworkTransport allows you to hook into various delegates to accomplish various things. There are several limitations to this approach:
- Users can only do things that are specifically supported by delegates.
- Asynchronous use of delegates without callbacks is basically impossible.
- Any time we want to add a new feature, we need to add a new delegate method and handle it, creating additional complexity.
- There is no flexibility in terms of order of operations, particularly around whether data should be returned to the UI before being written to the cache.
The other major issue driving this update is that the current networking stack is deeply tied to the current cache architecture. This isn't ideal for many reasons, the biggest of which is that the cache likely to change in relation to the Swift Codegen Rewrite.
What is proposed?
The proposed new architecture uses the Interceptor pattern to create a customizable request chain. This means users can hook into the system at any point during the request creation or data processing process.
This also means that the pieces which will need to be swapped out for the Swift Codegen Rewrite are more clearly defined, and less tied to the actual parsing operation.
Finally, this also opens the opportunity for different patterns than we already support, such as writing to the cache after returning data to the UI instead of before, or creating an array of interceptors which hit the network first, then hit the cache if nothing was returned.
New Protocols
-
FlexibleDecoder: This is mostly going to be helpful for the Codable implementation down the line, but this will allow anything conforming to Decoder to be used to decode data.
-
Parseable: This is a wrapper that allows us to continue to support non-Codable parsing alongside Codable parsing, while keeping us able to constrain and construct things generically. A default implementation for Codable will be provided.
-
ApolloInterceptor: This is an interface which allows you to add an asynchronous handler to perform any necessary work, such as fetching credentials and reading or writing from the cache, asynchronously.
public protocol ApolloInterceptor: class {
/// Called when this interceptor should do its work.
///
/// - Parameters:
/// - chain: The chain the interceptor is a part of.
/// - request: The request, as far as it has been constructed
/// - response: [optional] The response, if received
/// - completion: The completion block to fire when data needs to be returned to the UI.
func interceptAsync<Operation: GraphQLOperation>(
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void)
}
Default implementations of ApolloInterceptor for both Legacy (ie, non Swift Codegen) networking and Swift Codegen networking will be provided.
-
InterceptorProvider This protocol will be used to quickly create a new array of interceptors for a given request:
public protocol InterceptorProvider {
/// Creates a new array of interceptors when called
///
/// - Parameter operation: The operation to provide interceptors for
func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor]
}
This design allows for both flexibility (you can return different interceptors for different types of requests, for instance) and isolation (each request will have its own unique set of interceptors, reducing the possibility of different requests stomping on each other).
Two default interceptor providers are set up:
LegacyInterceptorProvider will provide interceptors mimicking the current stack
CodableInterceptorProvider will provide interceptors for the forthcoming Swift Codegen Rewrite's network stack.
-
ApolloErrorInterceptor will allow you to have additional checks whenever an error is about to be returned. This will be optional to implement, and no default implementation is provided.
/// Asynchronously handles the receipt of an error at any point in the chain.
///
/// - Parameters:
/// - error: The received error
/// - chain: The chain the error was received on
/// - request: The request, as far as it was constructed
/// - response: [optional] The response, if received
/// - completion: The completion closure to fire when the operation has completed. Note that if you call `retry` on the chain, you will not want to call the completion block in this method.
func handleErrorAsync<Operation: GraphQLOperation>(
error: Error,
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void)
New Classes
-
HTTPRequest This object will hold all the information related to a request before it hits the network, with the toURLRequest() method creating an actual URLRequest based on all the information in the request. This is subclass-able (and will mostly be using subclasses).
open class HTTPRequest<Operation: GraphQLOperation> {
open var graphQLEndpoint: URL
open var operation: Operation
open var contentType: String
open var additionalHeaders: [String: String]
open var clientName: String? = nil
open var clientVersion: String? = nil
open var retryCount: Int = 0
public let cachePolicy: CachePolicy
public init(graphQLEndpoint: URL,
operation: Operation,
contentType: String,
additionalHeaders: [String: String],
cachePolicy: CachePolicy = .default)
open func toURLRequest() throws -> URLRequest
open func addHeader(name: String, value: String)
}
JSONRequest subclass of HTTPRequest will handle creating requests with JSON, which will be the vast majority of requests with operations. This is where handling of auto-persisted queries is also layered in:
public class JSONRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
public let requestCreator: RequestCreator
public let autoPersistQueries: Bool
public let useGETForQueries: Bool
public let useGETForPersistedQueryRetry: Bool
public var isPersistedQueryRetry = false
public let serializationFormat = JSONSerializationFormat.self
public init(operation: Operation,
graphQLEndpoint: URL,
additionalHeaders: [String: String] = [:],
cachePolicy: CachePolicy = .default,
autoPersistQueries: Bool = false,
useGETForQueries: Bool = false,
useGETForPersistedQueryRetry: Bool = false,
requestCreator: RequestCreator = ApolloRequestCreator())
}
UploadRequest subclass of HTTPRequest will handle multipart file uploads:
public class UploadRequest<Operation: GraphQLOperation>: HTTPRequest<Operation> {
public let requestCreator: RequestCreator
public let files: [GraphQLFile]
public let manualBoundary: String?
public let serializationFormat = JSONSerializationFormat.self
public init(graphQLEndpoint: URL,
operation: Operation,
additionalHeaders: [String: String] = [:],
files: [GraphQLFile],
manualBoundary: String? = nil,
requestCreator: RequestCreator = ApolloRequestCreator())
}
-
HTTPResponse will represent the objects returned and/or parsed from the server:
/// Designated initializer
///
/// - Parameters:
/// - response: The `HTTPURLResponse` received from the server.
/// - rawData: The raw, unparsed data received from the server.
/// - parsedResponse: [optional] The response parsed into the `ParsedValue` type. Will be nil if not yet parsed, or if parsing failed.
public class HTTPResponse<Operation: GraphQLOperation> {
public var httpResponse: HTTPURLResponse
public var rawData: Data
public var parsedResponse: GraphQLResult<Operation.Data>?
}
-
RequestChain will handle the interaction with the network for a single operation.
public class RequestChain: Cancellable {
/// Creates a chain with the given interceptor array
public init(interceptors: [ApolloInterceptor])
/// Kicks off the request from the beginning of the interceptor array.
///
/// - Parameters:
/// - request: The request to send.
/// - completion: The completion closure to call when the request has completed.
public func kickoff<ParsedValue: Parseable, Operation: GraphQLOperation>(
request: HTTPRequest<Operation>,
completion: @escaping (Result<ParsedValue, Error>) -> Void)
/// Proceeds to the next interceptor in the array.
///
/// - Parameters:
/// - request: The in-progress request object
/// - response: [optional] The in-progress response object, if received yet
/// - completion: The completion closure to call when data has been processed and should be returned to the UI.
public func proceedAsync<ParsedValue: Parseable, Operation: GraphQLOperation>(
request: HTTPRequest<Operation>,
response: HTTPResponse<ParsedValue>?,
completion: @escaping (Result<ParsedValue, Error>) -> Void)
/// Cancels the entire chain of interceptors.
public func cancel()
/// Restarts the request starting from the first inteceptor.
///
/// - Parameters:
/// - request: The request to retry
/// - completion: The completion closure to call when the request has completed.
public func retry<ParsedValue: Parseable, Operation: GraphQLOperation>(
request: HTTPRequest<Operation>,
completion: @escaping (Result<ParsedValue, Error>) -> Void)
}
-
RequestChainNetworkTransport provides an implementation of NetworkTransport which uses an InterceptorProvider to create a request chain for each request.
public class RequestChainNetworkTransport: NetworkTransport {
public init(interceptorProvider: InterceptorProvider,
endpointURL: URL,
additionalHeaders: [String: String] = [:],
autoPersistQueries: Bool = false,
cachePolicy: CachePolicy = .default,
requestCreator: RequestCreator = ApolloRequestCreator(),
useGETForQueries: Bool = false,
useGETForPersistedQueryRetry: Bool = false)
}
Changes to existing Protocols and Classes
-
ApolloStore will no longer require a GraphQLQuery explicitly for fetching data from the store. It will instead return an error if the GraphQLOperationType is not .query. This change is necessary to avoid going down an enormous rabbit hole with generics since GraphQLOperation has an associated type.
-
The NetworkTransport protocol will get a new method to be implemented:
func sendForResult<Operation: GraphQLOperation>(operation: Operation,
completionHandler: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) -> Cancellable
This will avoid the double-wrapping of GraphQLResponse around the GraphQLResult so that only the GraphQLResult is actually returned. The send method will eventually be deprecated and removed.
-
ApolloClient will get a new sendForResult method which calls into the sendForResult method added to NetworkTransport.
How will this work in practice?
Instantiating a new legacy client manually will look like this:
lazy var legacyClient: ApolloClient = {
let url = URL(string: "http://localhost:8080/graphql")!
let store = ApolloStore(cache: InMemoryNormalizedCache())
let provider = LegacyInterceptorProvider(store: store)
let transport = RequestChainNetworkTransport(interceptorProvider: provider, endpointURL: url)
return ApolloClient(networkTransport: transport)
}()
Ideally I'll be able to transparently swap out the existing HTTPNetworkTransport for this so that this would be the under-the-hood setup on ApolloClient, but this may involve a transition period.
Calls to the client will look like this:
legacyClient.fetchForResult(query: HeroNameQuery()) { result in
switch result {
case .success(let graphQLResult):
print(graphQLResult.data?.hero?.name ?? "Name not found")
case .failure(let error):
print("Unexpected error: \(error)")
}
}
Note that this is VERY similar to how they look on the surface at the moment, which is intentional.
This is the technical outline of a proposal to make major changes to our primary networking interface from the current
HTTPNetworkTransportto aRequestChainNetworkTransportwhich uses a chain of interceptor objects to set up and process the results of network requests.Note that this is going to be a 🎉 Spectacularly 🎉 breaking change - while very surface level APIs will remain basically the same, if you're doing anything remotely advanced, this will necessitate some changes, but the idea is to break it now so we don't have to break it way worse later.
I would REALLY love feedback on this before I start working towards making this the default option. You can see the code changes in-place in this PR. I will be updating this RFC with feedback as it is received.
Why The Change?
HTTPNetworkTransportallows you to hook into various delegates to accomplish various things. There are several limitations to this approach:The other major issue driving this update is that the current networking stack is deeply tied to the current cache architecture. This isn't ideal for many reasons, the biggest of which is that the cache likely to change in relation to the Swift Codegen Rewrite.
What is proposed?
The proposed new architecture uses the Interceptor pattern to create a customizable request chain. This means users can hook into the system at any point during the request creation or data processing process.
This also means that the pieces which will need to be swapped out for the Swift Codegen Rewrite are more clearly defined, and less tied to the actual parsing operation.
Finally, this also opens the opportunity for different patterns than we already support, such as writing to the cache after returning data to the UI instead of before, or creating an array of interceptors which hit the network first, then hit the cache if nothing was returned.
New Protocols
FlexibleDecoder: This is mostly going to be helpful for theCodableimplementation down the line, but this will allow anything conforming toDecoderto be used to decode data.Parseable: This is a wrapper that allows us to continue to support non-Codableparsing alongsideCodableparsing, while keeping us able to constrain and construct things generically. A default implementation forCodablewill be provided.ApolloInterceptor: This is an interface which allows you to add an asynchronous handler to perform any necessary work, such as fetching credentials and reading or writing from the cache, asynchronously.Default implementations of
ApolloInterceptorfor both Legacy (ie, non Swift Codegen) networking and Swift Codegen networking will be provided.InterceptorProviderThis protocol will be used to quickly create a new array of interceptors for a given request:This design allows for both flexibility (you can return different interceptors for different types of requests, for instance) and isolation (each request will have its own unique set of interceptors, reducing the possibility of different requests stomping on each other).
Two default interceptor providers are set up:
LegacyInterceptorProviderwill provide interceptors mimicking the current stackCodableInterceptorProviderwill provide interceptors for the forthcoming Swift Codegen Rewrite's network stack.ApolloErrorInterceptorwill allow you to have additional checks whenever an error is about to be returned. This will be optional to implement, and no default implementation is provided.New Classes
HTTPRequestThis object will hold all the information related to a request before it hits the network, with thetoURLRequest()method creating an actualURLRequestbased on all the information in the request. This is subclass-able (and will mostly be using subclasses).JSONRequestsubclass ofHTTPRequestwill handle creating requests with JSON, which will be the vast majority of requests with operations. This is where handling of auto-persisted queries is also layered in:UploadRequestsubclass ofHTTPRequestwill handle multipart file uploads:HTTPResponsewill represent the objects returned and/or parsed from the server:
RequestChainwill handle the interaction with the network for a single operation.RequestChainNetworkTransportprovides an implementation ofNetworkTransportwhich uses anInterceptorProviderto create a request chain for each request.Changes to existing Protocols and Classes
ApolloStorewill no longer require aGraphQLQueryexplicitly for fetching data from the store. It will instead return an error if theGraphQLOperationTypeis not.query. This change is necessary to avoid going down an enormous rabbit hole with generics sinceGraphQLOperationhas an associated type.The
NetworkTransportprotocol will get a new method to be implemented:This will avoid the double-wrapping of
GraphQLResponsearound theGraphQLResultso that only theGraphQLResultis actually returned. Thesendmethod will eventually be deprecated and removed.ApolloClientwill get a newsendForResultmethod which calls into thesendForResultmethod added toNetworkTransport.How will this work in practice?
Instantiating a new legacy client manually will look like this:
Ideally I'll be able to transparently swap out the existing
HTTPNetworkTransportfor this so that this would be the under-the-hood setup onApolloClient, but this may involve a transition period.Calls to the client will look like this:
Note that this is VERY similar to how they look on the surface at the moment, which is intentional.