Skip to content

RFC: Networking Updates #1340

@designatednerd

Description

@designatednerd

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions