Skip to content

Commit

Permalink
feature: GraphQL execution for @defer support (apollographql/apollo…
Browse files Browse the repository at this point in the history
  • Loading branch information
calvincestari authored and gh-action-runner committed Jul 19, 2024
1 parent 06c1a89 commit b73d4bb
Show file tree
Hide file tree
Showing 33 changed files with 1,474 additions and 356 deletions.
33 changes: 3 additions & 30 deletions Design/3093-graphql-defer.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public struct Fragments: FragmentContainer {
@Deferred public var deferredFragmentFoo: DeferredFragmentFoo?
}

public struct DeferredFragmentFoo: AnimalKingdomAPI.InlineFragment, ApolloAPI.Deferrable {
public struct DeferredFragmentFoo: AnimalKingdomAPI.InlineFragment {
}
```

Expand Down Expand Up @@ -147,36 +147,9 @@ In the preview release of `@defer`, operations with deferred fragments will **no

### Request header

If an operation can support an incremental delivery response it must add an `Accept` header to the HTTP request specifying the protocol version that can be parsed. An [example](https://github.com/apollographql/apollo-ios/blob/spike/defer/Sources/Apollo/RequestChainNetworkTransport.swift#L115) is HTTP subscription requests that include the `subscriptionSpec=1.0` specification. `@defer` would introduce another operation feature that would request an incremental delivery response.
If an operation can support an incremental delivery response it must add an `Accept` header to the HTTP request specifying the protocol version that can be parsed in the response. An [example](https://github.com/apollographql/apollo-ios/blob/spike/defer/Sources/Apollo/RequestChainNetworkTransport.swift#L115) is HTTP subscription requests that include the `subscriptionSpec=1.0` specification. `@defer` introduces another incremental delivery response protocol. The defer response specification supported at the time of development is `deferSpec=20220824`.

This should not be sent with all requests though so operations will need to be identifiable as having deferred fragments to signal inclusion of the request header.

```swift
// Sample code for RequestChainNetworkTransport
open func constructRequest<Operation: GraphQLOperation>(
for operation: Operation,
cachePolicy: CachePolicy,
contextIdentifier: UUID? = nil
) -> HTTPRequest<Operation> {
let request = ... // build request

if Operation.hasDeferredFragments {
request.addHeader(
name: "Accept",
value: "multipart/mixed;boundary=\"graphql\";deferSpec=20220824,application/json"
)
}

return request
}

// Sample of new property on GraphQLOperation
public protocol GraphQLOperation: AnyObject, Hashable {
// other properties not shown

static var hasDeferredFragments: Bool { get } // computed for each operation during codegen
}
```
All operations will have an `Accept` header specifying the supported incremental delivery response protocol; Subscription operations will have the `subscriptionSpec` protocol, Query and Mutation operations will have the `deferSpec` protocol in the `Accept` header.

### Response parsing

Expand Down
104 changes: 104 additions & 0 deletions Sources/Apollo/AnyGraphQLResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#if !COCOAPODS
import ApolloAPI
#endif

/// An abstract GraphQL response used for full and incremental responses.
struct AnyGraphQLResponse {
let body: JSONObject

private let rootKey: CacheReference
private let variables: GraphQLOperation.Variables?

init(
body: JSONObject,
rootKey: CacheReference,
variables: GraphQLOperation.Variables?
) {
self.body = body
self.rootKey = rootKey
self.variables = variables
}

/// Call this function when you want to execute on an entire operation and its response data.
/// This function should also be called to execute on the partial (initial) response of an
/// operation with deferred selection sets.
func execute<
Accumulator: GraphQLResultAccumulator,
Data: RootSelectionSet
>(
selectionSet: Data.Type,
with accumulator: Accumulator
) throws -> Accumulator.FinalResult? {
guard let dataEntry = body["data"] as? JSONObject else {
return nil
}

return try executor.execute(
selectionSet: Data.self,
on: dataEntry,
withRootCacheReference: rootKey,
variables: variables,
accumulator: accumulator
)
}

/// Call this function to execute on a specific selection set and its incremental response data.
/// This is typically used when executing on deferred selections.
func execute<
Accumulator: GraphQLResultAccumulator,
Operation: GraphQLOperation
>(
selectionSet: any Deferrable.Type,
in operation: Operation.Type,
with accumulator: Accumulator
) throws -> Accumulator.FinalResult? {
guard let dataEntry = body["data"] as? JSONObject else {
return nil
}

return try executor.execute(
selectionSet: selectionSet,
in: Operation.self,
on: dataEntry,
withRootCacheReference: rootKey,
variables: variables,
accumulator: accumulator
)
}

var executor: GraphQLExecutor<NetworkResponseExecutionSource> {
GraphQLExecutor(executionSource: NetworkResponseExecutionSource())
}

func parseErrors() -> [GraphQLError]? {
guard let errorsEntry = self.body["errors"] as? [JSONObject] else {
return nil
}

return errorsEntry.map(GraphQLError.init)
}

func parseExtensions() -> JSONObject? {
return self.body["extensions"] as? JSONObject
}
}

// MARK: - Equatable Conformance

extension AnyGraphQLResponse: Equatable {
static func == (lhs: AnyGraphQLResponse, rhs: AnyGraphQLResponse) -> Bool {
lhs.body == rhs.body &&
lhs.rootKey == rhs.rootKey &&
lhs.variables?._jsonEncodableObject._jsonValue == rhs.variables?._jsonEncodableObject._jsonValue
}
}

// MARK: - Hashable Conformance

extension AnyGraphQLResponse: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(body)
hasher.combine(rootKey)
hasher.combine(variables?._jsonEncodableObject._jsonValue)
}
}
54 changes: 23 additions & 31 deletions Sources/Apollo/CacheWriteInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import ApolloAPI

/// An interceptor which writes data to the cache, following the `HTTPRequest`'s `cachePolicy`.
public struct CacheWriteInterceptor: ApolloInterceptor {

public enum CacheWriteError: Error, LocalizedError {
@available(*, deprecated, message: "Will be removed in a future version.")
case noResponseToParse


case missingCacheRecords

public var errorDescription: String? {
switch self {
case .noResponseToParse:
return "The Cache Write Interceptor was called before a response was received to be parsed. Double-check the order of your interceptors."
case .missingCacheRecords:
return "The Cache Write Interceptor cannot find any cache records. Double-check the order of your interceptors."
}
}
}
Expand Down Expand Up @@ -43,44 +48,31 @@ public struct CacheWriteInterceptor: ApolloInterceptor {
)
return
}

guard
let createdResponse = response,
let legacyResponse = createdResponse.legacyResponse else {
let cacheRecords = createdResponse.cacheRecords
else {
chain.handleErrorAsync(
CacheWriteError.noResponseToParse,
CacheWriteError.missingCacheRecords,
request: request,
response: response,
completion: completion
)
return
return
}

do {
let (_, records) = try legacyResponse.parseResult()

guard !chain.isCancelled else {
return
}

if let records = records {
self.store.publish(records: records, identifier: request.contextIdentifier)
}

chain.proceedAsync(
request: request,
response: createdResponse,
interceptor: self,
completion: completion
)

} catch {
chain.handleErrorAsync(
error,
request: request,
response: response,
completion: completion
)
guard !chain.isCancelled else {
return
}

self.store.publish(records: cacheRecords, identifier: request.contextIdentifier)

chain.proceedAsync(
request: request,
response: createdResponse,
interceptor: self,
completion: completion
)
}
}
Loading

0 comments on commit b73d4bb

Please sign in to comment.