From 68c86f1e93eb6b9db81bd9b73f822fe8f40bf93b Mon Sep 17 00:00:00 2001 From: gh-action-runner Date: Thu, 2 Nov 2023 21:33:27 +0000 Subject: [PATCH] Squashed 'apollo-ios/' changes from eff46e4e..9759ba24 9759ba24 merge: branch into (apollographql/apollo-ios-dev#121) d5c6b1f7 2023-11-02 Roadmap update 5cc8f759 Merge branch 'main' into feature/defer 98dfa681 merge: `feature/defer` update to `main` (#116) 1a7bde44 Merge branch 'main' into feature/defer 60d4dc48 Merge branch 'main' into feature/defer 773b8904 Merge branch 'main' into feature/defer b574fbc6 Merge branch 'main' into feature/defer 72306f73 feature: Operation definition template supports the `@defer` directive (#31) 98c06c68 Merge branch 'project-breakup' into feature/defer febc191d rfc: Clarifying edits (#22) 0b563c9d Migrating defer branch to new project structure git-subtree-dir: apollo-ios git-subtree-split: 9759ba24aee39bcf07df11d21f0115cd142b5b91 --- .gitignore | 3 + Design/3093-graphql-defer.md | 471 ++++++++++++++++++ ROADMAP.md | 9 +- Sources/Apollo/FieldSelectionCollector.swift | 6 +- Sources/Apollo/HTTPURLResponse+Helpers.swift | 47 +- .../Apollo/MultipartResponseDeferParser.swift | 17 + .../MultipartResponseParsingInterceptor.swift | 183 ++----- .../MultipartResponseSubscriptionParser.swift | 129 +++++ .../Apollo/RequestChainNetworkTransport.swift | 60 ++- Sources/Apollo/URLSessionClient.swift | 3 +- Sources/ApolloAPI/DataDict.swift | 31 +- Sources/ApolloAPI/Deferred.swift | 40 ++ Sources/ApolloAPI/FragmentProtocols.swift | 2 +- Sources/ApolloAPI/GraphQLOperation.swift | 7 + Sources/ApolloAPI/Selection+Conditions.swift | 62 ++- Sources/ApolloAPI/Selection.swift | 18 +- 16 files changed, 885 insertions(+), 203 deletions(-) create mode 100644 Design/3093-graphql-defer.md create mode 100644 Sources/Apollo/MultipartResponseDeferParser.swift create mode 100644 Sources/Apollo/MultipartResponseSubscriptionParser.swift create mode 100644 Sources/ApolloAPI/Deferred.swift diff --git a/.gitignore b/.gitignore index 41b895761..514275e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ xcuserdata/ *.xcscmblueprint .DS_Store +## Visual Studio Code +.vscode/launch.json + ## Obj-C/Swift specific *.hmap *.ipa diff --git a/Design/3093-graphql-defer.md b/Design/3093-graphql-defer.md new file mode 100644 index 000000000..44fcc70a2 --- /dev/null +++ b/Design/3093-graphql-defer.md @@ -0,0 +1,471 @@ +* Feature Name: GraphQL `@defer` +* Start Date: 2023-06-26 +* RFC PR: [3093](https://github.com/apollographql/apollo-ios/pull/3093) + +# Summary + +The specification for `@defer`/`@stream` is slowly making it's way through the GraphQL Foundation approval process and once formally merged into the GraphQL specification Apollo iOS will need to support it. However, Apollo already has a public implementation of `@defer` in the other OSS offerings, namely Apollo Server, Apollo Client, and Apollo Kotlin. The goal of this project is to implement support for `@defer` that matches the other Apollo OSS clients which, based on the commit history, we believe is [the specification as dated at `2022-08-24`](https://github.com/graphql/graphql-spec/tree/48cf7263a71a683fab03d45d309fd42d8d9a6659/spec). This project will not include support for the `@stream` directive. + +Based on the progress of `@defer`/`@stream` through the approval process there may be some differences in the final specification vs. what is currently implemented in Apollo's OSS. This project does not attempt to preemptively anticipate those changes nor comply with the potentially merged specification. Any client affecting-changes in the merged specification will be implemented into Apollo iOS. + +# Proposed Changes + +## Update graphql-js dependency + +Apollo iOS uses [graphql-js](https://github.com/graphql/graphql-js) for validation of the GraphQL schema and operation documents as the first step in the code generation workflow. The version of this [dependency](https://github.com/apollographql/apollo-ios/blob/spike/defer/Sources/ApolloCodegenLib/Frontend/JavaScript/package.json#L16) is fixed at [`16.3.0-canary.pr.3510.5099f4491dc2a35a3e4a0270a55e2a228c15f13b`](https://www.npmjs.com/package/graphql/v/16.3.0-canary.pr.3510.5099f4491dc2a35a3e4a0270a55e2a228c15f13b?activeTab=versions). This is a version of graphql-js that supports the experimental [Client Controlled Nullability](https://github.com/graphql/graphql-wg/blob/main/rfcs/ClientControlledNullability.md) feature but does not support the `@defer` directive. + +The latest `16.x` release of graphql-js with support for the `@defer` directive is [`16.1.0-experimental-stream-defer.6`](https://www.npmjs.com/package/graphql/v/16.1.0-experimental-stream-defer.6) but it looks like the 'experimental' named releases for `@defer` have been discontinued and the recommendation is to use [`17.0.0-alpha.2`](https://www.npmjs.com/package/graphql/v/17.0.0-alpha.2). This is further validated by the fact that [`16.7.0` does not](https://github.com/graphql/graphql-js/blob/v16.7.0/src/type/directives.ts#L167) include the `@defer` directive whereas [`17.0.0-alpha.2` does](https://github.com/graphql/graphql-js/blob/v17.0.0-alpha.2/src/type/directives.ts#L159). + +**Preferred solution (see the end of this document for discarded solutions)** + +We will take a staggered approach where we adopt `17.0.0-alpha.2`, or the latest 17.0.0 alpha release, limiting the changes to our frontend javascript only and at a later stage bring the CCN changes from [PR `#3510`](https://github.com/graphql/graphql-js/pull/3510) to the `17.x` release path and reintroduce support for CCN to Apollo iOS. This would also require the experiemental CCN feature to be removed, with no committment to when it would be reintroduced. + +_The work to port the CCN PRs to `17.0.0-alpha.2` is being done externally as part of the renewed interest in the CCN proposal._ + +## Rename `PossiblyDeferred` types/functions + +Adding support for `@defer` brings new meaning of the word 'deferred' to the codebase. There is an enum type named [`PossiblyDeferred`](https://github.com/apollographql/apollo-ios/blob/spike/defer/Sources/Apollo/PossiblyDeferred.swift#L47) which would cause confusion when trying to understand it’s intent. This type and its related functions should be renamed to disambiguate it from the incoming `@defer` related types and functions. + +`PossiblyDeferred` is an internal type so this should have no adverse effect on users’ code. + +## Generated models + +Generated models will need to adapt with the introduction of `@defer` statements in operations. Ideally there is easy-to-read annotation indicating something is deferred by simply reading the generated model code but more importantly it must be easy when using the generated models in code to detect whether something is able to be deferred and it's current state when receiving a response. + +**Preferred solution (see the end of this document for discarded solutions)** + +These are the key changes: + +**All deferred fragments including inline fragments are treated as isolated fragments** + +This is necessary because they are delivered separately in the incremental response, therefore they cannot be merged together. This means that inline fragments, even on the same typecase with matching arguments, will be treated as separate fragments in the same way that named fragments are. They will be placed into the `Fragments` container along with an accessor. + +This is still undecided but we may require that _all_ deferred fragments be named with the `label` argument. This provides us with a naming paradigm and aids us in identifying the fulfilled fragments in the incremental responses. At a minimum any fragments on the same typecase will need to be uniquely identifable with at least one having an associated label. + +**Deferred fragment accessors are stored properties** + +This is different to data fields which are computed properties that use a subscript on the underlying data dictionary to return the value. We decided to do this so that we can use a property wrapper, which are not available for computed properties. The property wrapper is an easy-to-read annotation on the accessor to aid in identifying a deferred fragment from other named fragments in the fragment container. + +It's worth noting though that the fragment accessors are not true stored properties but rather a pseudo stored-property because the property wrapper is still initialized with a data dictionary that holds the data. This is also made possible by the underlying data dictionary having copy-on-write semantics. + +**`@Deferred` property wrapper** + +Aside from being a conveinent annotation the property wrapper also unlocks both deferred value and state. The wrapped value is used to access the returned value and the projected value is used to determine the state of the fragment in the response, i.e.: pending, fulfilled or a not-executed. + +The not-executed case is used to indicate when a merged deferred fragment could never be fulfilled, such as when the response type is different from the deferred fragment typecase. + +Here is a snippet of a generated model to illustrate the above three points: +```swift +public struct Fragments: FragmentContainer { + public let __data: DataDict + public init(_dataDict: DataDict) { + __data = _dataDict + _root = Deferred(_dataDict: _dataDict) + } + + @Deferred public var deferredFragmentFoo: DeferredFragmentFoo? +} + +public struct DeferredFragmentFoo: AnimalKingdomAPI.InlineFragment, ApolloAPI.Deferrable { +} +``` + +Below is the expected property wrapper: +```swift +public protocol Deferrable: SelectionSet { } + +@propertyWrapper +public struct Deferred { + public enum State { // the naming of these cases is not final + case pending + case notExecuted + case fulfilled(Fragment) + } + + public init(_dataDict: DataDict) { + __data = _dataDict + } + + public var state: State { + let fragment = ObjectIdentifier(Fragment.self) + if __data._fulfilledFragments.contains(fragment) { + return .fulfilled(Fragment.init(_dataDict: __data)) + } + else if __data._deferredFragments.contains(fragment) { + return .pending + } else { + return .notExecuted + } + } + + private let __data: DataDict + public var projectedValue: State { state } + public var wrappedValue: Fragment? { + guard case let .fulfilled(value) = state else { + return nil + } + return value + } +} +``` + +`DataDict`, the underlying data dictionary to data fields will now need to keep track of deferred fragments in a new property, as it does for fulfilled fragments: +```swift +public struct DataDict: Hashable { + // initializer and other properties not shown + + @inlinable public var _deferredFragments: Set { + _storage.deferredFragments + } + + // functions not shown +} +``` + +**A new `deferred(if:type:label:)` case in `Selection`** + +This is necessary for the field selection collector to be able to handle both inline and named fragments the same, which is different from the separate case logic that exists for them today. + +Here is a snippet of a generated model to illustrate the selection: +```swift +public static var __selections: [ApolloAPI.Selection] { [ + .deferred(if: "a", DeferredFragmentFoo.self, label: "deferredFragmentFoo") +] } +``` + +**Field merging** + +Field merging is a feature in Apollo iOS where fields from fragments that have the same `__parentType` as the enclosing `SelectionSet` are automatically merged into the enclosing `SelectionSet`. This makes it easier to consume fragment fields instead of having to access the fragment first. + +Deferred fragment fields will **not** be merged into the enclosing selection set. Merging in the fields of a deferred fragment would require the field types to become optional or use another wrapper-type solution where the field value and state can be represented. We decided it would be better to treat deferred fragments as an isolated selection set with clearer sementics on the collective state and values. + +**Selection set initializers** + +In the preview release of `@defer`, operations with deferred fragments will **not** be able to have generated selection set initializers. This is due to the complexities of field merging which is dependent on work being done by other members of the team. Once we can support this the fields will be optional properties on the initializer and fragment fulfillment will be determined at access time, in a lightweight version of the GraphQL executor, to determine if all deferred fragment field values were provided. + +## Networking + +### 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. + +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( + for operation: Operation, + cachePolicy: CachePolicy, + contextIdentifier: UUID? = nil +) -> HTTPRequest { + 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 +} +``` + +### Response parsing + +Apollo iOS already has support for parsing incremental delivery responses. That provides a great foundation to build on however there are some changes needed. + +#### Multipart parsing protocol + +The current `MultipartResponseParsingInterceptor` implementation is specific to the `subscriptionSpec` version `1.0` specification. Adopting a protocol with implementations for each of the supported specifications will enable us to support any number of incremental delivery specifications in the future. + +These would be registered with the `MultipartResponseParsingInterceptor` each with a unique specification string, to be used as a lookup key. When a response is received the specification string is extracted from the response `content-type` header, and the correct specification parser can be used to parse the response data. + +```swift +// Sample code in MultipartResponseParsingInterceptor +public struct MultipartResponseParsingInterceptor: ApolloInterceptor { + private static let responseParsers: [String: MultipartResponseSpecificationParser.Type] = [ + MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self, + MultipartResponseDeferParser.protocolSpec: MultipartResponseDeferParser.self, + ] + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) where Operation : GraphQLOperation { + // response validators not shown + + guard + let multipartBoundary = response.httpResponse.multipartBoundary, + let protocolSpec = response.httpResponse.multipartProtocolSpec, + let protocolParser = Self.responseParsers[protocolSpec], + let dataString = String(data: response.rawData, encoding: .utf8) + else { + // call request chain error handler + + return + } + + let dataHandler: ((Data) -> Void) = { data in + // proceed ahead on the request chain + } + + let errorHandler: (() -> Void) = { + // call request chain error handler + } + + for chunk in dataString.components(separatedBy: "--\(boundary)") { + if chunk.isEmpty || chunk.isBoundaryMarker { continue } + + parser.parse(chunk: chunk, dataHandler: dataHandler, errorHandler: errorHandler) + } + } +} + +// Sample protocol for multipart specification parsing +protocol MultipartResponseSpecificationParser { + static var protocolSpec: String { get } + + static func parse( + chunk: String, + dataHandler: ((Data) -> Void), + errorHandler: ((Error) -> Void) + ) +} + +// Sample implementations of multipart specification parsers + +struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser { + static let protocolSpec: String = "subscriptionSpec=1.0" + + static func parse( + chunk: String, + dataHandler: ((Data) -> Void), + errorHandler: ((Error) -> Void) + ) { + // parsing code currently in MultipartResponseParsingInterceptor + } +} + +struct MultipartResponseDeferParser: MultipartResponseSpecificationParser { + static let protocolSpec: String = "deferSpec=20220824" + + static func parse( + chunk: String, + dataHandler: ((Data) -> Void), + errorHandler: ((Error) -> Void) + ) { + // new code to parse the defer specification + } +} +``` + +#### Response data + +The initial response data and data received in each incremental response will need to be retained and combined so that each incremental response can insert the latest received incremental response data at the correct path and return an up-to-date response to the request callback. + +The data being retained and combined will be passed through the GraphQL executor on each response, initial and incremental. + +### Completion handler + +`GraphQLResult` should be modified to provide query completion blocks with a high-level abstraction of whether the request has been fulfilled or is still in progress. This prevents clients from having to dig into the deferred fragments to identify the state of the overall request. + +**Preferred solution (see the end of this document for discarded solutions)** + +Introduce a new property on the `GraphQLResult` type that can be used to express the state of the request. + +```swift +// New Response type and property +public struct GraphQLResult { + // other properties and types not shown + + public enum Response { + case partial + case complete + } + + public let response: Response +} + +// Sample usage in an app completion block +client.fetch(query: ExampleQuery()) { result in + switch (result) { + case let .success(data): + switch (data.response) { + case .complete: + case .partial: + } + case let .failure(error): + } +} +``` + +## GraphQL execution + +The executor currently executes on an entire operation selection set. It will need to be adapted to be able to execute on a partial response when deferred fragments have not been received. Each response will be passed to the GraphQL executor. + +There is an oustanding question about whether the Apollo Router has implemented early execution of deferred fragments, potentially returning them in the initial response. If it does then that could have an outsided impact on the changes to the executor. This problem does appear to have been addressed in GraphQL spec edits after `2022-08-24`. + +## Caching + +Similarly to GraphQL execution the cache write interceptor is designed to work holistically on the operation and write cache records for a single response. This approach still works for HTTP-based subscriptions because each incremental response contains a selection set for the entire operation. + +This approach is not going to work for the incremental responses of `@defer` though and partial responses cannot be written to the cache for the operation. Instead all deferred responses will need to be fulfilled before the record is written to the cache. + +```swift +// Only write cache records for complete responses +public struct CacheWriteInterceptor: ApolloInterceptor { + // other code not shown + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) { + // response validators not shown + + guard + let createdResponse = response, + let parsedResponse = createdResponse.parsedResponse, + parsedResponse.source == .server(.complete) + else { + // a partial response must have been received and should not be written to the cache + return + } + + // cache write code not shown + } +} +``` + +There is a bunch of complexity in writing partial records to the cache such as: query watchers without deferred fragments; how would we handle failed requests; race conditions to fulfil deferred data; amongst others. These problems need careful, thoughtful solutions and this project will not include them in the scope for initial implementation. + +# Discarded solutions + +## Update graphql-js dependency +1. Add support for Client Controlled Nullability to `17.0.0-alpha.2`, or the latest 17.0.0 alpha release, and publish that to NPM. The level of effort for this is unknown but it would allow us to maintain support for CCN. +2. Use `17.0.0-alpha.2`, or the latest 17.0.0 alpha release, as-is and remove the experimental Client Controlled Nullability feature. We do not know how many users rely on the CCN functionality so this may be a controversial decision. This path doesn’t necessarily imply an easier dependency update because there will be changes needed to our frontend javascript to adapt to the changes in graphql-js. + +## Generated models +1. Property wrappers - I explored Swift's property wrappers but they suffer from the limitation of not being able to be applied to a computed property. All GraphQL fields in the generated models are computed properties because they simply route access to the value in the underlying data dictionary storage. It would be nice to be able to simply annotate fragments and fields with something like `@Deferred` but unfortunately that is not possible. +2. Optional types - this solution would change the deferred property type to an optional version of that type. This may not seem necessary when considering that only fragments can be marked as deferred but it would be required to cater for the way that Apollo iOS does field merging in the generated model fragments. Field merging is non-optional at the moment but there is an issue ([#2560](https://github.com/apollographql/apollo-ios/issues/2560)) that would make this a configuration option. This solution hides detail though because you wouldn't be able to tell whether the field value is `nil` because the response data hasn't been received yet (i.e.: deferred) or whether the data was returned and it was explicitly `null`. It also gets more complicated when a field type is already optional; would that result in a Swift double-optional type? As we learnt with the legacy implementation of GraphQL nullability, double-optionals are difficult to interpret and easily lead to mistakes. +3. `Enum` wrapper - an idea that was suggested by [`@Iron-Ham`](https://github.com/apollographql/apollo-ios/issues/2395#issuecomment-1433628466) is to wrap the type in a Swift enum that can expose the deferred state as well as the underlying value once it has been received. This is an improvement to option 2 where the state of the deferred value can be determined. + +```swift +// Sample enum to wrap deferred properties +enum DeferredValue { + case loading + case result(Result) +} + +// Sample model with a deferred property +public struct ModelSelectionSet: GraphAPI.SelectionSet { + // other properties not shown + + public var name: DeferredValue { __data["name"] } +} +``` + +4. Optional fragments (disabling field merging) - optional types are only needed when fragment fields are merged into entity selection sets. If field merging were disabled automatically for deferred fragments then the solution is simplified and we only need to alter the deferred fragments to be optional. Consuming the result data is intuitive too where a `nil` fragment value would indicate that the fragment data has not yet been received (i.e.: deferred) and when the complete response is received the fragment value is populated and the result sent to the client. This seems a more elegant and ergonimic way to indicate the status of deferred data but complicates the understanding of field merging. + +```swift +// Sample usage in a generated model +public class ExampleQuery: GraphQLQuery { + // other properties and types not shown + + public struct Data: ExampleSchema.SelectionSet { + public static var __selections: [ApolloAPI.Selection] { [ + .fragment(EntityFragment?.self, deferred: true) + ] } + } +} + +// Sample usage in an app completion block +client.fetch(query: ExampleQuery()) { result in + switch (result) { + case let .success(data): + client.fetch(query: ExampleQuery()) { result in + switch (result) { + case let .success(data): + guard let fragment = data.data?.item.fragments.entityFragment else { + // partial result + } + + // complete result + case let .failure(error): + print("Query Failure! \(error)") + } + } + case let .failure(error): + } +} + +``` + +Regardless of the fragment/field solution chosen all deferred fragment definitions in generated models `__selections` will get an additional property to indicate they are deferred. This helps to understand the models when reading them as well as being used by internal code. + +```swift +// Updated Selection enum +public enum Selection { + // other cases not shown + case fragment(any Fragment.Type, deferred: Bool) + case inlineFragment(any InlineFragment.Type, deferred: Bool) + + // other properties and types not shown +} + +// Sample usage in a generated model +public class ExampleQuery: GraphQLQuery { + // other properties and types not shown + + public struct Data: ExampleSchema.SelectionSet { + public static var __selections: [ApolloAPI.Selection] { [ + .fragment(EntityFragment.self, deferred: true), + .inlineFragment(AsEntity.self, deferred: true), + ] } + } +} +``` +## Networking + +1. Another way which may be a bit more intuitive is to make the `server` case on `Source` have an associated value since `cache` sources will always be complete. The cache could return partial responses for deferred operations but for the initial implementation we will probably only write the cache record once all deferred fragments have been received. This solution becomes invalid though once the cache can return partial responses, with that in mind maybe option 1 is better. + +```swift +// Updated server case on Source with associated value of Response type +public struct GraphQLResult { + // other properties and types not shown + + public enum Response { + case partial + case complete + } + + public enum Source: Hashable { + case cache + case server(_ response: Response) + } +} + +// Sample usage in an app +client.fetch(query: ExampleQuery()) { result in + switch (result) { + case let .success(data): + switch (data.source) { + case .server(.complete): + case .server(.partial): + case .cache: + } + case let .failure(error): + } +} +``` diff --git a/ROADMAP.md b/ROADMAP.md index 540bb4a89..f83bddf38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # 🔮 Apollo iOS Roadmap -**Last updated: 2023-10-25** +**Last updated: 2023-11-02** For up to date release notes, refer to the project's [Changelog](https://github.com/apollographql/apollo-ios/blob/main/CHANGELOG.md). @@ -28,6 +28,7 @@ _Approximate Date: TBD - PR from an external contributor is in review_ - Support for Relay-style (cursor-based), offset-based, and arbitrary pagination patterns - `@connection` directive support - This feature will be considered experimental, meaning that the public API could change in backwards-incompatible ways until it is declared stable in a future release +- This package will be versioned independently from Apollo iOS, beginning with `0.1.0` ### [`@defer` support](https://github.com/apollographql/apollo-ios/issues/2395) @@ -39,16 +40,16 @@ We plan to release `@defer` support in a feature branch first, then will move it ### [Improvements to code generation configuration and performance](https://github.com/apollographql/apollo-ios/milestone/67) -_Approximate Date: to be released incrementally, beginning in October 2023 with 1.7.0_ +_Approximate Date: to be released incrementally_ - This effort encompasses several smaller features: - - Make codegen support Swift concurrency (`async`/`await`) + - ✅ Make codegen support Swift concurrency (`async`/`await`): available in v1.7.0 - Add configuration for disabling merging of fragment fields - Fix retain cycles and memory issues causing code generation to take very long on certain large, complex schemas with deeply nested fragment composition ### [Reduce generated schema types](https://github.com/apollographql/apollo-ios/milestone/71) -_Approximate Date: December 2023_ +_Approximate Date: February 2024_ - Right now we are naively generating schema types that we don't always need. A smarter algorithm can reduce generated code for certain large schemas that are currently having every type in their schema generated - Create configuration for manually indicating schema types you would like to have schema types and TestMocks generated for diff --git a/Sources/Apollo/FieldSelectionCollector.swift b/Sources/Apollo/FieldSelectionCollector.swift index 8260e9314..47e82e2b3 100644 --- a/Sources/Apollo/FieldSelectionCollector.swift +++ b/Sources/Apollo/FieldSelectionCollector.swift @@ -77,6 +77,8 @@ struct DefaultFieldSelectionCollector: FieldSelectionCollector { info: info) } + case .deferred(_, _, _): + assertionFailure("Defer execution must be implemented (#3145).") case let .fragment(fragment): groupedFields.addFulfilledFragment(fragment) try collectFields(from: fragment.__selections, @@ -84,6 +86,7 @@ struct DefaultFieldSelectionCollector: FieldSelectionCollector { for: object, info: info) + // TODO: _ is fine for now but will need to be handled in #3145 case let .inlineFragment(typeCase): if let runtimeType = info.runtimeObjectType(for: object), typeCase.__parentType.canBeConverted(from: runtimeType) { @@ -145,7 +148,8 @@ struct CustomCacheDataWritingFieldSelectionCollector: FieldSelectionCollector { for: object, info: info, asConditionalFields: true) - + case .deferred(_, _, _): + assertionFailure("Defer execution must be implemented (#3145).") case let .fragment(fragment): if groupedFields.fulfilledFragments.contains(type: fragment) { try collectFields(from: fragment.__selections, diff --git a/Sources/Apollo/HTTPURLResponse+Helpers.swift b/Sources/Apollo/HTTPURLResponse+Helpers.swift index 5ae54bcae..05f298ad4 100644 --- a/Sources/Apollo/HTTPURLResponse+Helpers.swift +++ b/Sources/Apollo/HTTPURLResponse+Helpers.swift @@ -1,23 +1,50 @@ import Foundation +// MARK: Status extensions extension HTTPURLResponse { var isSuccessful: Bool { return (200..<300).contains(statusCode) } +} +// MARK: Multipart extensions +extension HTTPURLResponse { + /// Returns true if the `Content-Type` HTTP header contains the `multipart/mixed` MIME type. var isMultipart: Bool { return (allHeaderFields["Content-Type"] as? String)?.contains("multipart/mixed") ?? false } - var multipartBoundary: String? { - guard let contentType = allHeaderFields["Content-Type"] as? String else { return nil } + struct MultipartHeaderComponents { + let media: String? + let boundary: String? + let `protocol`: String? + + init(media: String? = nil, boundary: String? = nil, protocol: String? = nil) { + self.media = media + self.boundary = boundary + self.protocol = `protocol` + } + } + + /// Components of the `Content-Type` header specifically related to the `multipart` media type. + var multipartHeaderComponents: MultipartHeaderComponents { + guard let contentType = allHeaderFields["Content-Type"] as? String else { + return MultipartHeaderComponents() + } - let marker = "boundary=" - let markerLength = marker.count + var media: String? = nil + var boundary: String? = nil + var `protocol`: String? = nil for component in contentType.components(separatedBy: ";") { let directive = component.trimmingCharacters(in: .whitespaces) - if directive.prefix(markerLength) == marker { + + if directive.starts(with: "multipart/") { + media = directive.components(separatedBy: "/").last + continue + } + + if directive.starts(with: "boundary=") { if let markerEndIndex = directive.firstIndex(of: "=") { var startIndex = directive.index(markerEndIndex, offsetBy: 1) if directive[startIndex] == "\"" { @@ -28,11 +55,17 @@ extension HTTPURLResponse { endIndex = directive.index(before: endIndex) } - return String(directive[startIndex...endIndex]) + boundary = String(directive[startIndex...endIndex]) } + continue + } + + if directive.contains("Spec=") { + `protocol` = directive + continue } } - return nil + return MultipartHeaderComponents(media: media, boundary: boundary, protocol: `protocol`) } } diff --git a/Sources/Apollo/MultipartResponseDeferParser.swift b/Sources/Apollo/MultipartResponseDeferParser.swift new file mode 100644 index 000000000..f39a9e05e --- /dev/null +++ b/Sources/Apollo/MultipartResponseDeferParser.swift @@ -0,0 +1,17 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +struct MultipartResponseDeferParser: MultipartResponseSpecificationParser { + static let protocolSpec: String = "deferSpec=20220824" + + static func parse( + data: Data, + boundary: String, + dataHandler: ((Data) -> Void), + errorHandler: ((Error) -> Void) + ) { + // TODO: Will be implemented in #3146 + } +} diff --git a/Sources/Apollo/MultipartResponseParsingInterceptor.swift b/Sources/Apollo/MultipartResponseParsingInterceptor.swift index 66a4f2617..0c20e274b 100644 --- a/Sources/Apollo/MultipartResponseParsingInterceptor.swift +++ b/Sources/Apollo/MultipartResponseParsingInterceptor.swift @@ -6,42 +6,23 @@ import ApolloAPI /// Parses multipart response data into chunks and forwards each on to the next interceptor. public struct MultipartResponseParsingInterceptor: ApolloInterceptor { - public enum MultipartResponseParsingError: Error, LocalizedError, Equatable { + public enum ParsingError: Error, LocalizedError, Equatable { case noResponseToParse - case cannotParseResponseData - case unsupportedContentType(type: String) - case cannotParseChunkData - case irrecoverableError(message: String?) - case cannotParsePayloadData + case cannotParseResponse public var errorDescription: String? { switch self { case .noResponseToParse: return "There is no response to parse. Check the order of your interceptors." - case .cannotParseResponseData: + case .cannotParseResponse: return "The response data could not be parsed." - case let .unsupportedContentType(type): - return "Unsupported content type: application/json is required but got \(type)." - case .cannotParseChunkData: - return "The chunk data could not be parsed." - case let .irrecoverableError(message): - return "An irrecoverable error occured: \(message ?? "unknown")." - case .cannotParsePayloadData: - return "The payload data could not be parsed." } } } - private enum ChunkedDataLine { - case heartbeat - case contentHeader(type: String) - case json(object: JSONObject) - case unknown - } - - private static let dataLineSeparator: StaticString = "\r\n\r\n" - private static let contentTypeHeader: StaticString = "content-type:" - private static let heartbeat: StaticString = "{}" + private static let responseParsers: [String: MultipartResponseSpecificationParser.Type] = [ + MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self + ] public var id: String = UUID().uuidString @@ -56,7 +37,7 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { guard let response else { chain.handleErrorAsync( - MultipartResponseParsingError.noResponseToParse, + ParsingError.noResponseToParse, request: request, response: response, completion: completion @@ -74,12 +55,15 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { return } + let multipartComponents = response.httpResponse.multipartHeaderComponents + guard - let boundaryString = response.httpResponse.multipartBoundary, - let dataString = String(data: response.rawData, encoding: .utf8) + let boundary = multipartComponents.boundary, + let `protocol` = multipartComponents.protocol, + let parser = Self.responseParsers[`protocol`] else { chain.handleErrorAsync( - MultipartResponseParsingError.cannotParseResponseData, + ParsingError.cannotParseResponse, request: request, response: response, completion: completion @@ -87,116 +71,51 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { return } - for chunk in dataString.components(separatedBy: "--\(boundaryString)") { - if chunk.isEmpty || chunk.isBoundaryPrefix { continue } - - for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) { - switch (parse(dataLine: dataLine.trimmingCharacters(in: .newlines))) { - case .heartbeat: - // Periodically sent by the router - noop - continue - - case let .contentHeader(type): - guard type == "application/json" else { - chain.handleErrorAsync( - MultipartResponseParsingError.unsupportedContentType(type: type), - request: request, - response: response, - completion: completion - ) - return - } - - case let .json(object): - if let errors = object["errors"] as? [JSONObject] { - let message = errors.first?["message"] as? String - - chain.handleErrorAsync( - MultipartResponseParsingError.irrecoverableError(message: message), - request: request, - response: response, - completion: completion - ) - - // These are fatal-level transport errors, don't process anything else. - return - } - - guard let payload = object["payload"] else { - chain.handleErrorAsync( - MultipartResponseParsingError.cannotParsePayloadData, - request: request, - response: response, - completion: completion - ) - return - } - - if payload is NSNull { - // `payload` can be null such as in the case of a transport error - continue - } - - guard - let payload = payload as? JSONObject, - let data: Data = try? JSONSerializationFormat.serialize(value: payload) - else { - chain.handleErrorAsync( - MultipartResponseParsingError.cannotParsePayloadData, - request: request, - response: response, - completion: completion - ) - return - } - - let response = HTTPResponse( - response: response.httpResponse, - rawData: data, - parsedResponse: nil - ) - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - - case .unknown: - chain.handleErrorAsync( - MultipartResponseParsingError.cannotParseChunkData, - request: request, - response: response, - completion: completion - ) - } - } - } - } - - /// Parses the data line of a multipart response chunk - private func parse(dataLine: String) -> ChunkedDataLine { - if dataLine == Self.heartbeat.description { - return .heartbeat - } + let dataHandler: ((Data) -> Void) = { data in + let response = HTTPResponse( + response: response.httpResponse, + rawData: data, + parsedResponse: nil + ) - if dataLine.starts(with: Self.contentTypeHeader.description) { - return .contentHeader(type: (dataLine.components(separatedBy: ":").last ?? dataLine) - .trimmingCharacters(in: .whitespaces) + chain.proceedAsync( + request: request, + response: response, + interceptor: self, + completion: completion ) } - if - let data = dataLine.data(using: .utf8), - let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject - { - return .json(object: jsonObject) + let errorHandler: ((Error) -> Void) = { parserError in + chain.handleErrorAsync( + parserError, + request: request, + response: response, + completion: completion + ) } - return .unknown + parser.parse( + data: response.rawData, + boundary: boundary, + dataHandler: dataHandler, + errorHandler: errorHandler + ) } } -fileprivate extension String { - var isBoundaryPrefix: Bool { self == "--" } +/// A protocol that multipart response parsers must conform to in order to be added to the list of +/// available response specification parsers. +protocol MultipartResponseSpecificationParser { + /// The specification string matching what is expected to be received in the `Content-Type` header + /// in an HTTP response. + static var protocolSpec: String { get } + + /// Function that will be called to process the response data. + static func parse( + data: Data, + boundary: String, + dataHandler: ((Data) -> Void), + errorHandler: ((Error) -> Void) + ) } diff --git a/Sources/Apollo/MultipartResponseSubscriptionParser.swift b/Sources/Apollo/MultipartResponseSubscriptionParser.swift new file mode 100644 index 000000000..dec316522 --- /dev/null +++ b/Sources/Apollo/MultipartResponseSubscriptionParser.swift @@ -0,0 +1,129 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser { + public enum ParsingError: Swift.Error, LocalizedError, Equatable { + case cannotParseResponseData + case unsupportedContentType(type: String) + case cannotParseChunkData + case irrecoverableError(message: String?) + case cannotParsePayloadData + + public var errorDescription: String? { + switch self { + case .cannotParseResponseData: + return "The response data could not be parsed." + case let .unsupportedContentType(type): + return "Unsupported content type: application/json is required but got \(type)." + case .cannotParseChunkData: + return "The chunk data could not be parsed." + case let .irrecoverableError(message): + return "An irrecoverable error occured: \(message ?? "unknown")." + case .cannotParsePayloadData: + return "The payload data could not be parsed." + } + } + } + + private enum ChunkedDataLine { + case heartbeat + case contentHeader(type: String) + case json(object: JSONObject) + case unknown + } + + static let protocolSpec: String = "subscriptionSpec=1.0" + + private static let dataLineSeparator: StaticString = "\r\n\r\n" + private static let contentTypeHeader: StaticString = "content-type:" + private static let heartbeat: StaticString = "{}" + + static func parse( + data: Data, + boundary: String, + dataHandler: ((Data) -> Void), + errorHandler: ((Error) -> Void) + ) { + guard let dataString = String(data: data, encoding: .utf8) else { + errorHandler(ParsingError.cannotParseResponseData) + return + } + + for chunk in dataString.components(separatedBy: "--\(boundary)") { + if chunk.isEmpty || chunk.isBoundaryPrefix { continue } + + for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) { + switch (parse(dataLine: dataLine.trimmingCharacters(in: .newlines))) { + case .heartbeat: + // Periodically sent by the router - noop + continue + + case let .contentHeader(type): + guard type == "application/json" else { + errorHandler(ParsingError.unsupportedContentType(type: type)) + return + } + + case let .json(object): + if let errors = object["errors"] as? [JSONObject] { + let message = errors.first?["message"] as? String + + errorHandler(ParsingError.irrecoverableError(message: message)) + return + } + + guard let payload = object["payload"] else { + errorHandler(ParsingError.cannotParsePayloadData) + return + } + + if payload is NSNull { + // `payload` can be null such as in the case of a transport error + continue + } + + guard + let payload = payload as? JSONObject, + let data: Data = try? JSONSerializationFormat.serialize(value: payload) + else { + errorHandler(ParsingError.cannotParsePayloadData) + return + } + + dataHandler(data) + + case .unknown: + errorHandler(ParsingError.cannotParseChunkData) + } + } + } + } + + /// Parses the data line of a multipart response chunk + private static func parse(dataLine: String) -> ChunkedDataLine { + if dataLine == Self.heartbeat.description { + return .heartbeat + } + + if dataLine.starts(with: Self.contentTypeHeader.description) { + return .contentHeader(type: (dataLine.components(separatedBy: ":").last ?? dataLine) + .trimmingCharacters(in: .whitespaces) + ) + } + + if + let data = dataLine.data(using: .utf8), + let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + { + return .json(object: jsonObject) + } + + return .unknown + } +} + +fileprivate extension String { + var isBoundaryPrefix: Bool { self == "--" } +} diff --git a/Sources/Apollo/RequestChainNetworkTransport.swift b/Sources/Apollo/RequestChainNetworkTransport.swift index 3d5e95813..89984f25d 100644 --- a/Sources/Apollo/RequestChainNetworkTransport.swift +++ b/Sources/Apollo/RequestChainNetworkTransport.swift @@ -69,7 +69,7 @@ open class RequestChainNetworkTransport: NetworkTransport { self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry } - /// Constructs a default (ie, non-multipart) GraphQL request. + /// Constructs a GraphQL request for the given operation. /// /// Override this method if you need to use a custom subclass of `HTTPRequest`. /// @@ -85,18 +85,36 @@ open class RequestChainNetworkTransport: NetworkTransport { contextIdentifier: UUID? = nil, context: RequestContext? = nil ) -> HTTPRequest { - JSONRequest(operation: operation, - graphQLEndpoint: self.endpointURL, - contextIdentifier: contextIdentifier, - clientName: self.clientName, - clientVersion: self.clientVersion, - additionalHeaders: self.additionalHeaders, - cachePolicy: cachePolicy, - context: context, - autoPersistQueries: self.autoPersistQueries, - useGETForQueries: self.useGETForQueries, - useGETForPersistedQueryRetry: self.useGETForPersistedQueryRetry, - requestBodyCreator: self.requestBodyCreator) + let request = JSONRequest( + operation: operation, + graphQLEndpoint: self.endpointURL, + contextIdentifier: contextIdentifier, + clientName: self.clientName, + clientVersion: self.clientVersion, + additionalHeaders: self.additionalHeaders, + cachePolicy: cachePolicy, + context: context, + autoPersistQueries: self.autoPersistQueries, + useGETForQueries: self.useGETForQueries, + useGETForPersistedQueryRetry: self.useGETForPersistedQueryRetry, + requestBodyCreator: self.requestBodyCreator + ) + + if Operation.operationType == .subscription { + request.addHeader( + name: "Accept", + value: "multipart/mixed;boundary=\"graphql\";\(MultipartResponseSubscriptionParser.protocolSpec),application/json" + ) + } + + if Operation.hasDeferredFragments { + request.addHeader( + name: "Accept", + value: "multipart/mixed;boundary=\"graphql\";\(MultipartResponseDeferParser.protocolSpec),application/json" + ) + } + + return request } // MARK: - NetworkTransport Conformance @@ -113,17 +131,11 @@ open class RequestChainNetworkTransport: NetworkTransport { completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { let chain = makeChain(operation: operation, callbackQueue: callbackQueue) - let request = self.constructRequest(for: operation, - cachePolicy: cachePolicy, - contextIdentifier: contextIdentifier, - context: context) - - if Operation.operationType == .subscription { - request.addHeader( - name: "Accept", - value: "multipart/mixed;boundary=\"graphql\";subscriptionSpec=1.0,application/json" - ) - } + let request = self.constructRequest( + for: operation, + cachePolicy: cachePolicy, + contextIdentifier: contextIdentifier, + context: context) chain.kickoff(request: request, completion: completionHandler) return chain diff --git a/Sources/Apollo/URLSessionClient.swift b/Sources/Apollo/URLSessionClient.swift index c4954434a..ba9e6ce3a 100644 --- a/Sources/Apollo/URLSessionClient.swift +++ b/Sources/Apollo/URLSessionClient.swift @@ -271,7 +271,8 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat taskData.append(additionalData: data) if let httpResponse = dataTask.response as? HTTPURLResponse, httpResponse.isMultipart { - guard let boundaryString = httpResponse.multipartBoundary else { + let multipartHeaderComponents = httpResponse.multipartHeaderComponents + guard let boundaryString = multipartHeaderComponents.boundary else { taskData.completionBlock(.failure(URLSessionClientError.missingMultipartBoundary)) return } diff --git a/Sources/ApolloAPI/DataDict.swift b/Sources/ApolloAPI/DataDict.swift index fbe3df16e..1f2ba6719 100644 --- a/Sources/ApolloAPI/DataDict.swift +++ b/Sources/ApolloAPI/DataDict.swift @@ -44,11 +44,21 @@ public struct DataDict: Hashable { _storage.fulfilledFragments } + @inlinable public var _deferredFragments: Set { + _storage.deferredFragments + } + + #warning("TODO, remove deferredFragments default value when we set these up in executor") public init( data: [String: AnyHashable], - fulfilledFragments: Set + fulfilledFragments: Set, + deferredFragments: Set = [] ) { - self._storage = .init(data: data, fulfilledFragments: fulfilledFragments) + self._storage = .init( + data: data, + fulfilledFragments: fulfilledFragments, + deferredFragments: deferredFragments + ) } @inlinable public subscript(_ key: String) -> T { @@ -73,7 +83,7 @@ public struct DataDict: Hashable { yield &value } } - + @inlinable public subscript(_ key: String) -> T { get { T.init(_fieldData: _data[key]) } set { @@ -108,27 +118,36 @@ public struct DataDict: Hashable { @usableFromInline class _Storage: Hashable { @usableFromInline var data: [String: AnyHashable] @usableFromInline let fulfilledFragments: Set + @usableFromInline let deferredFragments: Set init( data: [String: AnyHashable], - fulfilledFragments: Set + fulfilledFragments: Set, + deferredFragments: Set ) { self.data = data self.fulfilledFragments = fulfilledFragments + self.deferredFragments = deferredFragments } @usableFromInline static func ==(lhs: DataDict._Storage, rhs: DataDict._Storage) -> Bool { lhs.data == rhs.data && - lhs.fulfilledFragments == rhs.fulfilledFragments + lhs.fulfilledFragments == rhs.fulfilledFragments && + lhs.deferredFragments == rhs.deferredFragments } @usableFromInline func hash(into hasher: inout Hasher) { hasher.combine(data) hasher.combine(fulfilledFragments) + hasher.combine(deferredFragments) } @usableFromInline func copy() -> _Storage { - _Storage(data: self.data, fulfilledFragments: self.fulfilledFragments) + _Storage( + data: self.data, + fulfilledFragments: self.fulfilledFragments, + deferredFragments: self.deferredFragments + ) } } } diff --git a/Sources/ApolloAPI/Deferred.swift b/Sources/ApolloAPI/Deferred.swift new file mode 100644 index 000000000..e9310c8d1 --- /dev/null +++ b/Sources/ApolloAPI/Deferred.swift @@ -0,0 +1,40 @@ +public protocol Deferrable: SelectionSet { } + +/// Wraps a deferred selection set (either an inline fragment or fragment spread) to expose the +/// fulfilled value as well as the fulfilled state through the projected value. +@propertyWrapper +public struct Deferred { + public enum State { + /// The deferred selection set has not been received yet. + case pending + /// The deferred value can never be fulfilled, such as in the case of a type case mismatch. + case notExecuted + /// The deferred value has been received. + case fulfilled(Fragment) + } + + public init(_dataDict: DataDict) { + __data = _dataDict + } + + public var state: State { + let fragment = ObjectIdentifier(Fragment.self) + if __data._fulfilledFragments.contains(fragment) { + return .fulfilled(Fragment.init(_dataDict: __data)) + } + else if __data._deferredFragments.contains(fragment) { + return .pending + } else { + return .notExecuted + } + } + + private let __data: DataDict + public var projectedValue: State { state } + public var wrappedValue: Fragment? { + guard case let .fulfilled(value) = state else { + return nil + } + return value + } +} diff --git a/Sources/ApolloAPI/FragmentProtocols.swift b/Sources/ApolloAPI/FragmentProtocols.swift index 5895e74c2..88e8e0d57 100644 --- a/Sources/ApolloAPI/FragmentProtocols.swift +++ b/Sources/ApolloAPI/FragmentProtocols.swift @@ -4,7 +4,7 @@ /// /// A ``SelectionSet`` can be converted to any ``Fragment`` included in it's /// `Fragments` object via its ``SelectionSet/fragments-swift.property`` property. -public protocol Fragment: SelectionSet { +public protocol Fragment: SelectionSet, Deferrable { /// The definition of the fragment in GraphQL syntax. static var fragmentDefinition: StaticString { get } } diff --git a/Sources/ApolloAPI/GraphQLOperation.swift b/Sources/ApolloAPI/GraphQLOperation.swift index f31ba287b..afbd73399 100644 --- a/Sources/ApolloAPI/GraphQLOperation.swift +++ b/Sources/ApolloAPI/GraphQLOperation.swift @@ -59,6 +59,7 @@ public protocol GraphQLOperation: AnyObject, Hashable { static var operationName: String { get } static var operationType: GraphQLOperationType { get } static var operationDocument: OperationDocument { get } + static var hasDeferredFragments: Bool { get } var __variables: Variables? { get } @@ -70,6 +71,12 @@ public extension GraphQLOperation { return nil } + /// `True` if any selection set, or nested selection set, within the operation contains any + /// fragment marked with the `@defer` directive. + static var hasDeferredFragments: Bool { + false + } + static var definition: OperationDefinition? { operationDocument.definition } diff --git a/Sources/ApolloAPI/Selection+Conditions.swift b/Sources/ApolloAPI/Selection+Conditions.swift index ca3ae6919..f19756c22 100644 --- a/Sources/ApolloAPI/Selection+Conditions.swift +++ b/Sources/ApolloAPI/Selection+Conditions.swift @@ -30,25 +30,40 @@ public extension Selection { } } - struct Condition: ExpressibleByStringLiteral, Hashable { - public let variableName: String - public let inverted: Bool + enum Condition: ExpressibleByStringLiteral, ExpressibleByBooleanLiteral, Hashable { + case value(Bool) + case variable(name: String, inverted: Bool) public init( variableName: String, inverted: Bool ) { - self.variableName = variableName - self.inverted = inverted; + self = .variable(name: variableName, inverted: inverted) } public init(stringLiteral value: StringLiteralType) { - self.variableName = value - self.inverted = false + self = .variable(name: value, inverted: false) } - @inlinable public static prefix func !(value: Condition) -> Condition { - .init(variableName: value.variableName, inverted: !value.inverted) + public init(booleanLiteral value: BooleanLiteralType) { + self = .value(value) + } + + @inlinable public static func `if`(_ condition: StringLiteralType) -> Condition { + .variable(name: condition, inverted: false) + } + + @inlinable public static func `if`(_ condition: Condition) -> Condition { + condition + } + + @inlinable public static prefix func !(condition: Condition) -> Condition { + switch condition { + case let .value(value): + return .value(!value) + case let .variable(name, inverted): + return .init(variableName: name, inverted: !inverted) + } } @inlinable public static func &&(_ lhs: Condition, rhs: Condition) -> [Condition] { @@ -101,19 +116,24 @@ fileprivate extension Array where Element == Selection.Condition { // MARK: Conditions - Individual fileprivate extension Selection.Condition { func evaluate(with variables: GraphQLOperation.Variables?) -> Bool { - switch variables?[variableName] { - case let boolValue as Bool: - return inverted ? !boolValue : boolValue - - case let nullable as GraphQLNullable: - let evaluated = nullable.unwrapped ?? false - return inverted ? !evaluated : evaluated - - case .none: - return false + switch self { + case let .value(value): + return value + case let .variable(variableName, inverted): + switch variables?[variableName] { + case let boolValue as Bool: + return inverted ? !boolValue : boolValue + + case let nullable as GraphQLNullable: + let evaluated = nullable.unwrapped ?? false + return inverted ? !evaluated : evaluated + + case .none: + return false - case let .some(wrapped): - fatalError("Expected Bool for \(variableName), got \(wrapped)") + case let .some(wrapped): + fatalError("Expected Bool for \(variableName), got \(wrapped)") + } } } } diff --git a/Sources/ApolloAPI/Selection.swift b/Sources/ApolloAPI/Selection.swift index e574c6468..46c62c4e3 100644 --- a/Sources/ApolloAPI/Selection.swift +++ b/Sources/ApolloAPI/Selection.swift @@ -5,6 +5,8 @@ public enum Selection { case fragment(any Fragment.Type) /// An inline fragment with a child selection set nested in a parent selection set. case inlineFragment(any InlineFragment.Type) + /// A fragment spread or inline fragment marked with the `@defer` directive. + case deferred(if: Condition? = nil, any Deferrable.Type, label: String?) /// A group of selections that have `@include/@skip` directives. case conditional(Conditions, [Selection]) @@ -129,14 +131,18 @@ extension Selection: Hashable { switch (lhs, rhs) { case let (.field(lhs), .field(rhs)): return lhs == rhs - case let (.fragment(lhs), .fragment(rhs)): - return lhs == rhs - case let (.inlineFragment(lhs), .inlineFragment(rhs)): - return lhs == rhs + case let (.fragment(lhsFragment), .fragment(rhsFragment)): + return lhsFragment == rhsFragment + case let (.inlineFragment(lhsFragment), .inlineFragment(rhsFragment)): + return lhsFragment == rhsFragment + case let (.deferred(lhsCondition, lhsFragment, lhsLabel), + .deferred(rhsCondition, rhsFragment, rhsLabel)): + return lhsCondition == rhsCondition && + lhsFragment == rhsFragment && + lhsLabel == rhsLabel case let (.conditional(lhsConditions, lhsSelections), .conditional(rhsConditions, rhsSelections)): - return lhsConditions == rhsConditions && - lhsSelections == rhsSelections + return lhsConditions == rhsConditions && lhsSelections == rhsSelections default: return false } }