diff --git a/.github/workflows/prometheus-project-add.yml b/.github/workflows/prometheus-project-add.yml index 9a554c2287..f644483e53 100644 --- a/.github/workflows/prometheus-project-add.yml +++ b/.github/workflows/prometheus-project-add.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add to project - uses: actions/add-to-project@v1.0.0 + uses: actions/add-to-project@v1.0.2 with: project-url: https://github.com/orgs/apollographql/projects/21 github-token: ${{ secrets.PROMETHEUS_PROJECT_ACCESS_SECRET }} diff --git a/ApolloTestSupport.podspec b/ApolloTestSupport.podspec index ac22c314fe..8408cff89a 100644 --- a/ApolloTestSupport.podspec +++ b/ApolloTestSupport.podspec @@ -13,6 +13,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '10.14' s.tvos.deployment_target = '12.0' s.watchos.deployment_target = '5.0' + s.visionos.deployment_target = '1.0' s.source_files = 'Sources/ApolloTestSupport/*.swift' s.dependency 'Apollo', '= ' + version diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d08367c9..267f944321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Change Log +## v1.15.2 + +### Improvements +- **Set `URLRequest` cache policy on GET requests ([#476](https://github.com/apollographql/apollo-ios-dev/pull/476)):** Uses the Apollo cache policy to set a comparable cache policy on `URLRequest`. Previously there was no way to opt-out of default `URLRequest` caching behaviour. +- **Batch writing records to the SQLite store ([#498](https://github.com/apollographql/apollo-ios-dev/pull/498)):** Uses the `insertMany` to batch write records for a given operation vs previously performing a write for each individual record. + +### Fixed +- **Fix `ListData` type check ([#473](https://github.com/apollographql/apollo-ios-dev/pull/473)):** Fixed bool type check in `ListData`. +- **Remove local cache mutation type condition setter ([#485](https://github.com/apollographql/apollo-ios-dev/pull/485)):** Removes the setter for mutable inline fragments. The correct way to initialize with a type condition is to use `asRootEntityType`. + +## v1.15.1 + +### Fixed +- **Fix decoding of deprecated `selectionSetInitializer` option `localCacheMutations` ([#467](https://github.com/apollographql/apollo-ios-dev/pull/467)):** This option was deprecated in `1.15.0`, and the removal of the code to parse the option resulted in a validation error when the deprecated option was present in the JSON code generation config file. This is now fixed so that the option is ignored but does not cause code generation to fail. +- **Disfavour deprecated watch function ([#469](https://github.com/apollographql/apollo-ios-dev/pull/469)):** A deprecated version of the `watch` function matched the overload of the current version if certain parameters were omitted. This caused an incorrect deprecation warning in this situation. We've fixed this by adding `@_disfavoredOverload` to the deprecated function signature. + +## v1.15.0 + +### New +- **Add ability to disable fragment field merging ([#431](https://github.com/apollographql/apollo-ios-dev/pull/431)):** Added `ApolloCodegenConfiguration` option to allow for disabling fragment field merging on generated models. For more information on this feature see the notes [here](https://github.com/apollographql/apollo-ios/releases/tag/preview-field-merging.1). + +### Fixed +- **Fix `legacyResponse` property not being set on `HTTPResponse` ([#456](https://github.com/apollographql/apollo-ios-dev/pull/456)):** When the `legacyResponse` property of `HTTPResponse` was deprecated setting the value was also removed; this was incorrect as it created a hidden breaking change for interceptors that might have been using the value. +- **Fix `ObjectData` type check ([#459](https://github.com/apollographql/apollo-ios-dev/pull/459)):** Fixed bool type check in `ObjectData`. +- **Fix `SelectionSetTemplate` scope comparison ([#460](https://github.com/apollographql/apollo-ios-dev/pull/460)):** Refactored the selection set template scope comparison to account for an edge case in merged sources. +- **Fix memory leak in DataLoader closure ([#457](https://github.com/apollographql/apollo-ios-dev/pull/457)):** Fixed a memory leak in the DataLoader closure in `ApolloStore` caused by implicit use of `self`. _Thank you to [@prabhuamol](https://github.com/prabhuamol) for finding and fixing this._ + +### Breaking +- **Bug Fix: Generated Selections Sets in Inclusion Condition Scope:** This fixes a bug when using @include/@skip where generated models that should have been generated inside of a conditional inline fragment were generated outside of the conditional scope. This may cause breaking changes for a small number of users. Those breaking changes are considered a bug fix since accessing the conditional inline fragments outside of the conditional scope could cause runtime crashes (if the conditions for their inclusion were not met). More information [here](https://github.com/apollographql/apollo-ios/releases/tag/preview-field-merging.1) + +## v1.14.1 + +### New +- **Ability to set the journal mode on sqlite cache databases ([#3399](https://github.com/apollographql/apollo-ios/issues/3399)):** There is now a function to set the journal mode of the connected sqlite database and control how the journal file is stored and processed. See PR [#443](https://github.com/apollographql/apollo-ios-dev/pull/443). _Thanks to [@pixelmatrix](https://github.com/pixelmatrix) for the feature request._ + +### Fixed +- **Fix crash when `GraphQLError` is “too many validation errors”" ([#438](https://github.com/apollographql/apollo-ios-dev/pull/438)):** When a GraphQLError from the JS parsing step is a “Too many validation errors” error, there is no `source` in the error object. Codegen will now check for it to avoid this edge case crash. +- **Cache write interceptor should gracefully handle missing cache records ([#439](https://github.com/apollographql/apollo-ios-dev/pull/439)):** The work to support the `@defer` directive introduced a bug where the cache write interceptor would throw if no cache records were returned during response parsing. This is incorrect as there are no cache records in the case of an `errors` only GraphQL response. +- **Avoid using `fatalError` on `JSONEncodable` ([#128](https://github.com/apollographql/apollo-ios-dev/pull/128)):** The fatal error logic in `JSONEncodable` was replaced with a type constraint `where` clause. _Thank you to [@arnauddorgans](https://github.com/arnauddorgans) for the contribution._ +- **Introspection-based schema download creates duplicate `@defer` directive definition ([#3417](https://github.com/apollographql/apollo-ios/issues/3417)):** The codegen engine can now correctly detect pre-existing `@defer` directive definitions in introspection sources and prevent the duplicate definition. See PR [#440](https://github.com/apollographql/apollo-ios-dev/pull/440). _Thanks to [@loganblevins](https://github.com/loganblevins) for reporting the issue._ + +## v1.14.0 + +### New +- **Experimental support for the `@defer` directive:** You can now use the `@defer` directive in your operations and code generation will generate models that support asynchronously receiving the deferred selection sets. There is a helpful property wrapper with a projected value to determine the state of the deferred selection set, and support for cache reads and writes. This feature is enabled by default but is considered [experimental](https://www.apollographql.com/docs/resources/product-launch-stages/#experimental-features). Please refer to the [documentation](https://www.apollographql.com/docs/ios/fetching/defer/) for further details. +- **Add `debugDescription` to `SelectionSet` ([#3374](https://github.com/apollographql/apollo-ios/issues/3374)):** This adds the ability to easily print code generated models to the Xcode debugger console. See PR [#412](https://github.com/apollographql/apollo-ios-dev/pull/412). _Thanks to [@raymondk-nf](https://github.com/raymondk-nf) for raising the issue._ +- **Xcode 16 editor config files ([#3404](https://github.com/apollographql/apollo-ios/issues/3404)):** Xcode 16 introduced support for `.editorconfig` files that represent settings like spaces vs. tabs, how many spaces per tab, etc. We've added a `.editorconfig` file with the projects preferred settings, so that the editor will use them automatically. See PR [#419](https://github.com/apollographql/apollo-ios-dev/pull/419). _Thanks to [@TizianoCoroneo](https://github.com/TizianoCoroneo) for raising the issue._ + +### Fixed +- **Local cache mutation build error in Swift 6 ([#3398](https://github.com/apollographql/apollo-ios/issues/3398)):** Mutating a property of a fragment annotated with the `@apollo_client_ios_localCacheMutation` directive caused a compile time error in Xcode 16 with Swift 6. See PR [#417](https://github.com/apollographql/apollo-ios-dev/pull/417). _Thanks to [@martin-muller](https://github.com/martin-muller) for raising the issue._ + +## v1.13.0 + +### New +- **Added `ExistentialAny` requirement ([#379](https://github.com/apollographql/apollo-ios-dev/pull/379)):** This adds the `-enable-upcoming-feature ExistentialAny` to all targets to ensure compatibility with the upcoming Swift feature. +- **Schema type renaming ([#388](https://github.com/apollographql/apollo-ios-dev/pull/388)):** This adds the feature to allow customizing the names of schema types in Swift generated code. +- **JSONConverter helper ([#380](https://github.com/apollographql/apollo-ios-dev/pull/380)):** This adds a new helper class for handling JSON conversion of data including the ability to convert `SelectionSet` instances to JSON. + +### Fixed +- **ApolloSQLite build error with Xcode 16 ([#386](https://github.com/apollographql/apollo-ios-dev/pull/386)):** This fixes a naming conflict with Foundation in iOS 18 and the SQLite library. _Thanks to [@rastersize](https://github.com/rastersize) for the contributon._ + +## v1.12.2 + +### Fixed +- **Rebuilt the CLI binary with the correct version number:** The CLI binary included in the `1.12.1` package was built with an incorrect version number causing a version mismatch when attempting to execute code generation. + +## v1.12.1 + +### Fixed +- **Rebuilt the CLI binary:** The CLI binary included in the `1.12.0` package was built with inconsistent SDK versions resulting in the linker signing not working correctly. + +## v1.12.0 + +### New +- **`ID` as a custom scalar ([#3379](https://github.com/apollographql/apollo-ios/issues/3379)):** This changes the generation of the built-in GraphQL `ID` scalar to be treated as a custom scalar that can be modified by the user. See PR [#363](https://github.com/apollographql/apollo-ios-dev/pull/363). + +### Fixed +- **Adds visionOS deployment to ApolloTestSupport podspec ([#364](https://github.com/apollographql/apollo-ios-dev/pull/364)):** This adds the `visionOS` deployment target to the ApolloTestSupport podspec to match the other package managers. +- **Add `@_spi(Execution)` to executor for import in test mocks ([#362](https://github.com/apollographql/apollo-ios-dev/pull/362)):** This replaces the use of `@testable` in ApolloTestSupport with specific `@_spi` scopes. This resolves a few issues that have been reported where the Apollo module could not be built for testing in non-debug configurations. + +## v1.11.0 + +### New + +- **Added `refetchOnFailedUpdates` option to `GraphQLQueryWatcher` ([#347](https://github.com/apollographql/apollo-ios/pull/347)):** This allows you to configure the query watcher not to refetch it's query from the server when a cache read to update it's data fails. + +### Fixed + +- **Generated input objects have default `nil` value for parameters with a schema-defined default value ([#2997](https://github.com/apollographql/apollo-ios/issues/2997)):** When the schema defines a default value for an input parameter, you can now omit that parameter when initializing the input object and the default value will be used. This corrects feature parity with the Apollo Kotlin client. See PR [#358](https://github.com/apollographql/apollo-ios-dev/pull/358). + +- **Fix namespacing error in `InterfaceTemplate` ([#3375](https://github.com/apollographql/apollo-ios/issues/3375)):** This fixes an issue where having a schema type named `Interface` caused compilation errors in generated code. See PR [#359](https://github.com/apollographql/apollo-ios-dev/pull/359). + ## v1.10.0 ### New diff --git a/CLI/apollo-ios-cli.tar.gz b/CLI/apollo-ios-cli.tar.gz index 9f6d226ae0..656e6be839 100644 Binary files a/CLI/apollo-ios-cli.tar.gz and b/CLI/apollo-ios-cli.tar.gz differ diff --git a/Design/3093-graphql-defer.md b/Design/3093-graphql-defer.md index 44fcc70a25..49dac8c59d 100644 --- a/Design/3093-graphql-defer.md +++ b/Design/3093-graphql-defer.md @@ -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 { } ``` @@ -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( - 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 -} -``` +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 diff --git a/Package.swift b/Package.swift index ddd9cf2888..1c1d30e3f5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,8 @@ // swift-tools-version:5.9 +// // The swift-tools-version declares the minimum version of Swift required to build this package. +// Swift 5.9 is available from Xcode 15.0. + import PackageDescription @@ -34,14 +37,16 @@ let package = Package( ], resources: [ .copy("Resources/PrivacyInfo.xcprivacy") - ] + ], + swiftSettings: [.enableUpcomingFeature("ExistentialAny")] ), .target( name: "ApolloAPI", dependencies: [], resources: [ .copy("Resources/PrivacyInfo.xcprivacy") - ] + ], + swiftSettings: [.enableUpcomingFeature("ExistentialAny")] ), .target( name: "ApolloSQLite", @@ -51,7 +56,8 @@ let package = Package( ], resources: [ .copy("Resources/PrivacyInfo.xcprivacy") - ] + ], + swiftSettings: [.enableUpcomingFeature("ExistentialAny")] ), .target( name: "ApolloWebSocket", @@ -60,7 +66,8 @@ let package = Package( ], resources: [ .copy("Resources/PrivacyInfo.xcprivacy") - ] + ], + swiftSettings: [.enableUpcomingFeature("ExistentialAny")] ), .target( name: "ApolloTestSupport", @@ -78,7 +85,8 @@ let package = Package( "ApolloSQLite", "ApolloWebSocket", "ApolloTestSupport", - ] + ], + swiftSettings: [.enableUpcomingFeature("ExistentialAny")] ), .plugin( name: "Install CLI", diff --git a/README.md b/README.md index 328e3c80bc..0b608bdcb4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Apollo GraphQL + Apollo GraphQL

diff --git a/ROADMAP.md b/ROADMAP.md index 2277c96d16..5513dadb7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # 🔮 Apollo iOS Roadmap -**Last updated: 2024-04-02** +**Last updated: 2024-09-24** For up to date release notes, refer to the project's [Changelog](https://github.com/apollographql/apollo-ios/blob/main/CHANGELOG.md). @@ -21,12 +21,15 @@ Please see our [patch releases milestone](https://github.com/apollographql/apoll As we identify feature sets that we intend to ship, we'll add to and update the subheadings in this section. We intend to keep this section in chronological order. In order to enable rapid and continuous feature delivery, we'll avoid assigning minor version numbers to these feature groups in the roadmap. -### [`@defer` support](https://github.com/apollographql/apollo-ios/issues/2395) +### `@defer` support - Available in release [1.14.0](https://github.com/apollographql/apollo-ios/releases/tag/1.14.0) -_Now available for preview in the `preview-defer.1` branch_ -_Approximate Date: 2024-04-09 (experimental)_ +The `@defer` directive enables your queries to receive data for specific fields asynchronously. This is helpful whenever some fields in a query take much longer to resolve than others. [Apollo Kotlin](https://www.apollographql.com/docs/kotlin/fetching/defer/) and [Apollo Client (web)](https://www.apollographql.com/docs/react/data/defer/) currently support this syntax, so if you're interested in learning more check out their documentation. This has been released as an experimental feature in `1.14.0`. -The `@defer` directive enables your queries to receive data for specific fields asynchronously. This is helpful whenever some fields in a query take much longer to resolve than others. [Apollo Kotlin](https://www.apollographql.com/docs/kotlin/fetching/defer/) and [Apollo Client (web)](https://www.apollographql.com/docs/react/data/defer/) currently support this syntax, so if you're interested in learning more check out their documentation. Apollo iOS has released a preview version of this feature in the `preview-defer.1` branch. This will be released as an experimental feature in an upcoming `1.x` minor version. +* ✅ Code generation +* ✅ Partial incremental execution +* ✅ Partial and incremental caching +* ✅ Local cache mutations +* 🔲 Selection Set Initializers (_next_) ### [Improvements to code generation configuration and performance](https://github.com/apollographql/apollo-ios/milestone/67) @@ -34,30 +37,30 @@ _Approximate Date: to be released incrementally_ - This effort encompasses several smaller features: - ✅ Make codegen support Swift concurrency (`async`/`await`): available in v1.7.0 - - Add configuration for disabling merging of fragment fields + - ✅ [Add configuration for disabling merging of fragment fields](https://github.com/apollographql/apollo-ios/issues/2560) - (in progress) 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) +### [2.0 Release] - Swift 6 compatibility -_Approximate Date: 2024-04-30_ +To support the breaking language changes in Swift 6, a major version 2.0 of Apollo iOS will be released. This version will include support for the new Swift Concurrency Model and improve upon networking and caching APIs. -- 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 +_Approximate Date: _Pending completion of design review._ Current RFC for design is available [here](https://github.com/apollographql/apollo-ios/issues/3411). -### Swift 6 compatibility +- ✅ [`ExistentialAny` upcoming feature](https://github.com/apollographql/apollo-ios/issues/3205) +- (in progress) [`Sendable` types and `async/await` APIs](https://github.com/apollographql/apollo-ios/issues/3291) -_Approximate Date: 2024-05-17_ +### `@oneOf` Input Object Support -- [`Sendable` types](https://github.com/apollographql/apollo-ios/issues/3291) -- [`ExistentialAny` upcoming feature](https://github.com/apollographql/apollo-ios/issues/3205) +_Approximate Date: TBD, awaiting final approval of RFC into the GraphQL specification._ -### [Configuration to rename generated models for schema types](https://github.com/apollographql/apollo-ios/issues/3283) +For more information on this feature, see the [RFC](https://github.com/graphql/graphql-spec/pull/825) for its addition to the GraphQL specification. -_Approximate Date: 2024-05-29_ +### [Reduce generated schema types](https://github.com/apollographql/apollo-ios/milestone/71) -- Allow client-side users to override the names of schema types in the generated models. -- This will allow user's to improve the quality and expressiveness of client side APIs when schema type names are not appropriate for client usage. -- This also allows workarounds for issues when names of schema types conflict with Swift types. +_Approximate Date: TBD_ + +- 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 ### [Mutable generated reponse models](https://github.com/apollographql/apollo-ios/issues/3246) @@ -85,21 +88,13 @@ _Approximate Date: TBD_ Version 0.1 of this module was released in March 2024. We are iterating quickly based on user feedback - please see the project's Issues and PRs for up-to-date information. We expect the API to become more stable over time and will consider a v1 release when appropriate. -## [2.0](https://github.com/apollographql/apollo-ios/milestone/60) - -_Approximate Date: TBD_ +# [Future Major Releases](https://github.com/apollographql/apollo-ios/milestone/60) -These are the major initiatives planned for 2.0/2.x: +Major release items are still in pre-planning, and are subject to change. More details will come in the future. -- **Networking Stack Improvements**: The goal is to simplify and stabilise the networking stack. - - The [updated network stack](https://github.com/apollographql/apollo-ios/issues/1340) solved a number of long standing issues with the old barebones NetworkTransport but still has limitations and is complicated to use. Adopting patterns that have proven useful for the web client, such as Apollo Link, will provide more flexibility and give developers full control over the steps that are invoked to satisfy requests. - - We will support some of the new Swift concurrency features, such as async/await, in Apollo iOS. It may involve Apollo iOS dropping support for macOS 10.14 and iOS 12. +These are the initiatives planned for future major version releases: -## 3.0 - -_Approximate Date: TBD_ - -These are the major initiatives planned for 3.0/3.x: +## Caching - **Cache Improvements**: Here we are looking at bringing across some features inspired by Apollo Client 3 and Apollo Kotlin - Better pagination support. Better support for caching and updating paginated lists of objects. @@ -107,5 +102,4 @@ These are the major initiatives planned for 3.0/3.x: - Reducing over-normalization. Only separating out results into individual records when something that can identify them is present - Real cache eviction & dangling reference collection. There's presently a way to manually remove objects for a given key or pattern, but Apollo Client 3 has given us a roadmap for how to handle some of this stuff much more thoroughly and safely. - Cache metadata. Ability to add per-field metadata if needed, to allow for TTL and time-based invalidation, etc. - -This major release is still in pre-planning, more details will come in the future. + - Querying/sorting cached data by field values. diff --git a/Sources/Apollo/AnyGraphQLResponse.swift b/Sources/Apollo/AnyGraphQLResponse.swift new file mode 100644 index 0000000000..f89f6278a5 --- /dev/null +++ b/Sources/Apollo/AnyGraphQLResponse.swift @@ -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 { + 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) + } +} diff --git a/Sources/Apollo/ApolloClient.swift b/Sources/Apollo/ApolloClient.swift index 90405e7d64..c63d1789aa 100644 --- a/Sources/Apollo/ApolloClient.swift +++ b/Sources/Apollo/ApolloClient.swift @@ -27,12 +27,12 @@ public enum CachePolicy: Hashable { /// /// - Parameters: /// - result: The result of a performed operation. Will have a `GraphQLResult` with any parsed data and any GraphQL errors on `success`, and an `Error` on `failure`. -public typealias GraphQLResultHandler = (Result, Error>) -> Void +public typealias GraphQLResultHandler = (Result, any Error>) -> Void /// The `ApolloClient` class implements the core API for Apollo by conforming to `ApolloClientProtocol`. public class ApolloClient { - let networkTransport: NetworkTransport + let networkTransport: any NetworkTransport public let store: ApolloStore @@ -52,7 +52,7 @@ public class ApolloClient { /// - Parameters: /// - networkTransport: A network transport used to send operations to a server. /// - store: A store used as a local cache. Note that if the `NetworkTransport` or any of its dependencies takes a store, you should make sure the same store is passed here so that it can be cleared properly. - public init(networkTransport: NetworkTransport, store: ApolloStore) { + public init(networkTransport: any NetworkTransport, store: ApolloStore) { self.networkTransport = networkTransport self.store = store } @@ -75,16 +75,18 @@ public class ApolloClient { extension ApolloClient: ApolloClientProtocol { public func clearCache(callbackQueue: DispatchQueue = .main, - completion: ((Result) -> Void)? = nil) { + completion: ((Result) -> Void)? = nil) { self.store.clearCache(callbackQueue: callbackQueue, completion: completion) } - @discardableResult public func fetch(query: Query, - cachePolicy: CachePolicy = .default, - contextIdentifier: UUID? = nil, - context: RequestContext? = nil, - queue: DispatchQueue = .main, - resultHandler: GraphQLResultHandler? = nil) -> Cancellable { + @discardableResult public func fetch( + query: Query, + cachePolicy: CachePolicy = .default, + contextIdentifier: UUID? = nil, + context: (any RequestContext)? = nil, + queue: DispatchQueue = .main, + resultHandler: GraphQLResultHandler? = nil + ) -> (any Cancellable) { return self.networkTransport.send(operation: query, cachePolicy: cachePolicy, contextIdentifier: contextIdentifier, @@ -94,13 +96,28 @@ extension ApolloClient: ApolloClientProtocol { } } - public func watch(query: Query, - cachePolicy: CachePolicy = .default, - context: RequestContext? = nil, - callbackQueue: DispatchQueue = .main, - resultHandler: @escaping GraphQLResultHandler) -> GraphQLQueryWatcher { + /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the current contents of the cache and the specified cache policy. After the initial fetch, the returned query watcher object will get notified whenever any of the data the query result depends on changes in the local cache, and calls the result handler again with the new result. + /// + /// - Parameters: + /// - query: The query to fetch. + /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the local cache. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Should default to `true`. + /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. + /// - callbackQueue: A dispatch queue on which the result handler will be called. Should default to the main queue. + /// - resultHandler: [optional] A closure that is called when query results are available or when an error occurs. + /// - Returns: A query watcher object that can be used to control the watching behavior. + public func watch( + query: Query, + cachePolicy: CachePolicy = .default, + refetchOnFailedUpdates: Bool = true, + context: (any RequestContext)? = nil, + callbackQueue: DispatchQueue = .main, + resultHandler: @escaping GraphQLResultHandler + ) -> GraphQLQueryWatcher { let watcher = GraphQLQueryWatcher(client: self, query: query, + refetchOnFailedUpdates: refetchOnFailedUpdates, context: context, callbackQueue: callbackQueue, resultHandler: resultHandler) @@ -109,12 +126,14 @@ extension ApolloClient: ApolloClientProtocol { } @discardableResult - public func perform(mutation: Mutation, - publishResultToStore: Bool = true, - contextIdentifier: UUID? = nil, - context: RequestContext? = nil, - queue: DispatchQueue = .main, - resultHandler: GraphQLResultHandler? = nil) -> Cancellable { + public func perform( + mutation: Mutation, + publishResultToStore: Bool = true, + contextIdentifier: UUID? = nil, + context: (any RequestContext)? = nil, + queue: DispatchQueue = .main, + resultHandler: GraphQLResultHandler? = nil + ) -> (any Cancellable) { return self.networkTransport.send( operation: mutation, cachePolicy: publishResultToStore ? .default : .fetchIgnoringCacheCompletely, @@ -128,12 +147,14 @@ extension ApolloClient: ApolloClientProtocol { } @discardableResult - public func upload(operation: Operation, - files: [GraphQLFile], - context: RequestContext? = nil, - queue: DispatchQueue = .main, - resultHandler: GraphQLResultHandler? = nil) -> Cancellable { - guard let uploadingTransport = self.networkTransport as? UploadingNetworkTransport else { + public func upload( + operation: Operation, + files: [GraphQLFile], + context: (any RequestContext)? = nil, + queue: DispatchQueue = .main, + resultHandler: GraphQLResultHandler? = nil + ) -> (any Cancellable) { + guard let uploadingTransport = self.networkTransport as? (any UploadingNetworkTransport) else { assertionFailure("Trying to upload without an uploading transport. Please make sure your network transport conforms to `UploadingNetworkTransport`.") queue.async { resultHandler?(.failure(ApolloClientError.noUploadTransport)) @@ -148,11 +169,13 @@ extension ApolloClient: ApolloClientProtocol { resultHandler?(result) } } - - public func subscribe(subscription: Subscription, - context: RequestContext? = nil, - queue: DispatchQueue = .main, - resultHandler: @escaping GraphQLResultHandler) -> Cancellable { + + public func subscribe( + subscription: Subscription, + context: (any RequestContext)? = nil, + queue: DispatchQueue = .main, + resultHandler: @escaping GraphQLResultHandler + ) -> any Cancellable { return self.networkTransport.send(operation: subscription, cachePolicy: .default, contextIdentifier: nil, @@ -162,4 +185,27 @@ extension ApolloClient: ApolloClientProtocol { } } +// MARK: - Deprecations +extension ApolloClient { + + @_disfavoredOverload + @available(*, deprecated, + renamed: "watch(query:cachePolicy:refetchOnFailedUpdates:context:callbackQueue:resultHandler:)") + public func watch( + query: Query, + cachePolicy: CachePolicy = .default, + context: (any RequestContext)? = nil, + callbackQueue: DispatchQueue = .main, + resultHandler: @escaping GraphQLResultHandler + ) -> GraphQLQueryWatcher { + let watcher = GraphQLQueryWatcher(client: self, + query: query, + context: context, + callbackQueue: callbackQueue, + resultHandler: resultHandler) + watcher.fetch(cachePolicy: cachePolicy) + return watcher + } + +} diff --git a/Sources/Apollo/ApolloClientProtocol.swift b/Sources/Apollo/ApolloClientProtocol.swift index deb877ec3d..e10102d21b 100644 --- a/Sources/Apollo/ApolloClientProtocol.swift +++ b/Sources/Apollo/ApolloClientProtocol.swift @@ -15,7 +15,7 @@ public protocol ApolloClientProtocol: AnyObject { /// - Parameters: /// - callbackQueue: The queue to fall back on. Should default to the main queue. /// - completion: [optional] A completion closure to execute when clearing has completed. Should default to nil. - func clearCache(callbackQueue: DispatchQueue, completion: ((Result) -> Void)?) + func clearCache(callbackQueue: DispatchQueue, completion: ((Result) -> Void)?) /// Fetches a query from the server or from the local cache, depending on the current contents of the cache and the specified cache policy. /// @@ -30,9 +30,9 @@ public protocol ApolloClientProtocol: AnyObject { func fetch(query: Query, cachePolicy: CachePolicy, contextIdentifier: UUID?, - context: RequestContext?, + context: (any RequestContext)?, queue: DispatchQueue, - resultHandler: GraphQLResultHandler?) -> Cancellable + resultHandler: GraphQLResultHandler?) -> (any Cancellable) /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the current contents of the cache and the specified cache policy. After the initial fetch, the returned query watcher object will get notified whenever any of the data the query result depends on changes in the local cache, and calls the result handler again with the new result. /// @@ -45,7 +45,7 @@ public protocol ApolloClientProtocol: AnyObject { /// - Returns: A query watcher object that can be used to control the watching behavior. func watch(query: Query, cachePolicy: CachePolicy, - context: RequestContext?, + context: (any RequestContext)?, callbackQueue: DispatchQueue, resultHandler: @escaping GraphQLResultHandler) -> GraphQLQueryWatcher @@ -62,9 +62,9 @@ public protocol ApolloClientProtocol: AnyObject { func perform(mutation: Mutation, publishResultToStore: Bool, contextIdentifier: UUID?, - context: RequestContext?, + context: (any RequestContext)?, queue: DispatchQueue, - resultHandler: GraphQLResultHandler?) -> Cancellable + resultHandler: GraphQLResultHandler?) -> (any Cancellable) /// Uploads the given files with the given operation. /// @@ -77,9 +77,9 @@ public protocol ApolloClientProtocol: AnyObject { /// - Returns: An object that can be used to cancel an in progress request. func upload(operation: Operation, files: [GraphQLFile], - context: RequestContext?, + context: (any RequestContext)?, queue: DispatchQueue, - resultHandler: GraphQLResultHandler?) -> Cancellable + resultHandler: GraphQLResultHandler?) -> (any Cancellable) /// Subscribe to a subscription /// @@ -91,9 +91,9 @@ public protocol ApolloClientProtocol: AnyObject { /// - resultHandler: An optional closure that is called when mutation results are available or when an error occurs. /// - Returns: An object that can be used to cancel an in progress subscription. func subscribe(subscription: Subscription, - context: RequestContext?, + context: (any RequestContext)?, queue: DispatchQueue, - resultHandler: @escaping GraphQLResultHandler) -> Cancellable + resultHandler: @escaping GraphQLResultHandler) -> any Cancellable } // MARK: - Backwards Compatibilty Extension @@ -112,10 +112,10 @@ public extension ApolloClientProtocol { func fetch( query: Query, cachePolicy: CachePolicy, - context: RequestContext?, + context: (any RequestContext)?, queue: DispatchQueue, resultHandler: GraphQLResultHandler? - ) -> Cancellable { + ) -> (any Cancellable) { self.fetch( query: query, cachePolicy: cachePolicy, @@ -138,10 +138,10 @@ public extension ApolloClientProtocol { func perform( mutation: Mutation, publishResultToStore: Bool, - context: RequestContext?, + context: (any RequestContext)?, queue: DispatchQueue, resultHandler: GraphQLResultHandler? - ) -> Cancellable { + ) -> (any Cancellable) { self.perform( mutation: mutation, publishResultToStore: publishResultToStore, diff --git a/Sources/Apollo/ApolloErrorInterceptor.swift b/Sources/Apollo/ApolloErrorInterceptor.swift index cb7cdc3644..8b088e837b 100644 --- a/Sources/Apollo/ApolloErrorInterceptor.swift +++ b/Sources/Apollo/ApolloErrorInterceptor.swift @@ -14,9 +14,9 @@ public protocol ApolloErrorInterceptor { /// - response: [optional] The response, if one was 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( - error: Error, - chain: RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) + error: any Error, + chain: any RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, any Error>) -> Void) } diff --git a/Sources/Apollo/ApolloInterceptor.swift b/Sources/Apollo/ApolloInterceptor.swift index 81b36c6762..c999f298ae 100644 --- a/Sources/Apollo/ApolloInterceptor.swift +++ b/Sources/Apollo/ApolloInterceptor.swift @@ -20,8 +20,8 @@ public protocol ApolloInterceptor { /// - response: [optional] The response, if received /// - completion: The completion block to fire when data needs to be returned to the UI. func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) + completion: @escaping (Result, any Error>) -> Void) } diff --git a/Sources/Apollo/ApolloStore.swift b/Sources/Apollo/ApolloStore.swift index 2f844b1aa1..ada4053667 100644 --- a/Sources/Apollo/ApolloStore.swift +++ b/Sources/Apollo/ApolloStore.swift @@ -22,16 +22,16 @@ public protocol ApolloStoreSubscriber: AnyObject { /// The `ApolloStore` class acts as a local cache for normalized GraphQL results. public class ApolloStore { - private let cache: NormalizedCache + private let cache: any NormalizedCache private let queue: DispatchQueue - internal var subscribers: [ApolloStoreSubscriber] = [] + internal var subscribers: [any ApolloStoreSubscriber] = [] /// Designated initializer /// - Parameters: /// - cache: An instance of `normalizedCache` to use to cache results. /// Defaults to an `InMemoryNormalizedCache`. - public init(cache: NormalizedCache = InMemoryNormalizedCache()) { + public init(cache: any NormalizedCache = InMemoryNormalizedCache()) { self.cache = cache self.queue = DispatchQueue(label: "com.apollographql.ApolloStore", attributes: .concurrent) } @@ -47,7 +47,7 @@ public class ApolloStore { /// - Parameters: /// - callbackQueue: The queue to call the completion block on. Defaults to `DispatchQueue.main`. /// - completion: [optional] A completion block to be called after records are merged into the cache. - public func clearCache(callbackQueue: DispatchQueue = .main, completion: ((Result) -> Void)? = nil) { + public func clearCache(callbackQueue: DispatchQueue = .main, completion: ((Result) -> Void)? = nil) { queue.async(flags: .barrier) { let result = Result { try self.cache.clear() } DispatchQueue.returnResultAsyncIfNeeded( @@ -65,7 +65,7 @@ public class ApolloStore { /// to assist in de-duping cache hits for watchers. /// - callbackQueue: The queue to call the completion block on. Defaults to `DispatchQueue.main`. /// - completion: [optional] A completion block to be called after records are merged into the cache. - public func publish(records: RecordSet, identifier: UUID? = nil, callbackQueue: DispatchQueue = .main, completion: ((Result) -> Void)? = nil) { + public func publish(records: RecordSet, identifier: UUID? = nil, callbackQueue: DispatchQueue = .main, completion: ((Result) -> Void)? = nil) { queue.async(flags: .barrier) { do { let changedKeys = try self.cache.merge(records: records) @@ -90,7 +90,7 @@ public class ApolloStore { /// - Parameters: /// - subscriber: A subscriber to receive content change notificatons. To avoid a retain cycle, /// ensure you call `unsubscribe` on this subscriber before it goes out of scope. - public func subscribe(_ subscriber: ApolloStoreSubscriber) { + public func subscribe(_ subscriber: any ApolloStoreSubscriber) { queue.async(flags: .barrier) { self.subscribers.append(subscriber) } @@ -101,7 +101,7 @@ public class ApolloStore { /// - Parameters: /// - subscriber: A subscribe that has previously been added via `subscribe`. To avoid retain cycles, /// call `unsubscribe` on all active subscribers before they go out of scope. - public func unsubscribe(_ subscriber: ApolloStoreSubscriber) { + public func unsubscribe(_ subscriber: any ApolloStoreSubscriber) { queue.async(flags: .barrier) { self.subscribers = self.subscribers.filter({ $0 !== subscriber }) } @@ -116,7 +116,7 @@ public class ApolloStore { public func withinReadTransaction( _ body: @escaping (ReadTransaction) throws -> T, callbackQueue: DispatchQueue? = nil, - completion: ((Result) -> Void)? = nil + completion: ((Result) -> Void)? = nil ) { self.queue.async { do { @@ -146,7 +146,7 @@ public class ApolloStore { public func withinReadWriteTransaction( _ body: @escaping (ReadWriteTransaction) throws -> T, callbackQueue: DispatchQueue? = nil, - completion: ((Result) -> Void)? = nil + completion: ((Result) -> Void)? = nil ) { self.queue.async(flags: .barrier) { do { @@ -205,7 +205,12 @@ public class ApolloStore { } public class ReadTransaction { - fileprivate let cache: NormalizedCache + fileprivate let cache: any NormalizedCache + + fileprivate lazy var loader: DataLoader = DataLoader { [weak self] batchLoad in + guard let self else { return [:] } + return try cache.loadRecords(forKeys: batchLoad) + } fileprivate lazy var loader: DataLoader = DataLoader(self.cache.loadRecords) fileprivate lazy var executor = GraphQLExecutor( diff --git a/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift b/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift index d42bd72bd2..5de2506a0d 100644 --- a/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift +++ b/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift @@ -30,10 +30,10 @@ public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { public init() {} public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) { + completion: @escaping (Result, any Error>) -> Void) { guard let jsonRequest = request as? JSONRequest, jsonRequest.autoPersistQueries else { diff --git a/Sources/Apollo/CacheReadInterceptor.swift b/Sources/Apollo/CacheReadInterceptor.swift index de14511e35..716e0bd897 100644 --- a/Sources/Apollo/CacheReadInterceptor.swift +++ b/Sources/Apollo/CacheReadInterceptor.swift @@ -18,10 +18,10 @@ public struct CacheReadInterceptor: ApolloInterceptor { } public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) { + completion: @escaping (Result, any Error>) -> Void) { switch Operation.operationType { case .mutation, @@ -134,8 +134,8 @@ public struct CacheReadInterceptor: ApolloInterceptor { private func fetchFromCache( for request: HTTPRequest, - chain: RequestChain, - completion: @escaping (Result, Error>) -> Void) { + chain: any RequestChain, + completion: @escaping (Result, any Error>) -> Void) { self.store.load(request.operation) { loadResult in guard !chain.isCancelled else { diff --git a/Sources/Apollo/CacheWriteInterceptor.swift b/Sources/Apollo/CacheWriteInterceptor.swift index 350e8845b1..1eeea3ca80 100644 --- a/Sources/Apollo/CacheWriteInterceptor.swift +++ b/Sources/Apollo/CacheWriteInterceptor.swift @@ -5,10 +5,10 @@ import ApolloAPI /// An interceptor which writes data to the cache, following the `HTTPRequest`'s `cachePolicy`. public struct CacheWriteInterceptor: ApolloInterceptor { - + public enum CacheWriteError: Error, LocalizedError { case noResponseToParse - + public var errorDescription: String? { switch self { case .noResponseToParse: @@ -28,11 +28,15 @@ public struct CacheWriteInterceptor: ApolloInterceptor { } public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) { - + completion: @escaping (Result, any Error>) -> Void) { + + guard !chain.isCancelled else { + return + } + guard request.cachePolicy != .fetchIgnoringCacheCompletely else { // If we're ignoring the cache completely, we're not writing to it. chain.proceedAsync( @@ -43,44 +47,26 @@ public struct CacheWriteInterceptor: ApolloInterceptor { ) return } - - guard - let createdResponse = response, - let legacyResponse = createdResponse.legacyResponse else { + + guard let createdResponse = response else { chain.handleErrorAsync( CacheWriteError.noResponseToParse, 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 - ) + if let cacheRecords = createdResponse.cacheRecords { + self.store.publish(records: cacheRecords, identifier: request.contextIdentifier) } + + chain.proceedAsync( + request: request, + response: createdResponse, + interceptor: self, + completion: completion + ) } } diff --git a/Sources/Apollo/Constants.swift b/Sources/Apollo/Constants.swift index 7b6cb0f401..6469fe553d 100644 --- a/Sources/Apollo/Constants.swift +++ b/Sources/Apollo/Constants.swift @@ -1,5 +1,5 @@ import Foundation public enum Constants { - public static let ApolloVersion: String = "1.10.0" + public static let ApolloVersion: String = "1.15.2" } diff --git a/Sources/Apollo/DataDict+Merging.swift b/Sources/Apollo/DataDict+Merging.swift new file mode 100644 index 0000000000..1d2d83f04d --- /dev/null +++ b/Sources/Apollo/DataDict+Merging.swift @@ -0,0 +1,202 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +// MARK: Internal + +extension DataDict { + enum MergeError: Error, LocalizedError, Equatable { + case emptyMergePath + case dataTypeNotAccessibleByPathComponent(PathComponent) + case invalidPathComponentForDataType(PathComponent, String) + case cannotFindPathComponent(PathComponent) + case incrementalMergeNeedsDataDict + case cannotOverwriteFieldData(AnyHashable, AnyHashable) + + public var errorDescription: String? { + switch self { + case .emptyMergePath: + return "The merge path cannot be empty." + + case let .dataTypeNotAccessibleByPathComponent(pathComponent): + return "The data type at \(pathComponent) is not accessible by path component." + + case let .invalidPathComponentForDataType(pathComponent, dataType): + return "Invalid path component \(pathComponent) for data type \(dataType)." + + case let .cannotFindPathComponent(pathComponent): + return "Path component \(pathComponent) is an invalid key or out-of-bounds index." + + case .incrementalMergeNeedsDataDict: + return "Invalid data type for incremental merge, expected DataDict." + + case let .cannotOverwriteFieldData(current, new): + return "Incremental data merge cannot overwrite field data value '\(current)' with mismatched value '\(new)'." + } + } + } + + /// Creates a new `DataDict` instance by merging the given `DataDict` into this `DataDict` at the + /// specified path. + /// + /// - Parameters: + /// - newDataDict: The `DataDict` to merge. + /// - path: The target path at which `newDataDict` should be merged. + /// - Returns: A new `DataDict` with the combined keys and values of this `DataDict` and `newDataDict`. + func merging(_ newDataDict: DataDict, at path: [PathComponent]) throws -> DataDict { + let value = try value(at: path) + guard let pathDataDict = value as? DataDict else { + throw MergeError.incrementalMergeNeedsDataDict + } + + let mergedData = try pathDataDict._data.merging(newDataDict._data) { current, new in + if current != new { + throw MergeError.cannotOverwriteFieldData(current, new) + } + + return current + } + + let mergedFulfilledFragments = pathDataDict._fulfilledFragments + .union(newDataDict._fulfilledFragments) + + let mergedDeferredFragments = pathDataDict._deferredFragments + .subtracting(newDataDict._fulfilledFragments) + .union(newDataDict._deferredFragments) + + let mergedDataDict = DataDict( + data: mergedData, + fulfilledFragments: mergedFulfilledFragments, + deferredFragments: mergedDeferredFragments + ) + + var result = self + try result.set(value: mergedDataDict, at: path) + + return result + } +} + +// MARK: - Private + +/// Functions that provide the ability to get and set a value when type-specific access to the +/// underlying data storage is required. +fileprivate protocol PathComponentAccessible { + func value(at path: PathComponent) throws -> AnyHashable? + mutating func set(value newValue: AnyHashable?, at path: PathComponent) throws +} + +/// Common implementations for working with an array of path components - `[PathComponent]`. +extension PathComponentAccessible { + fileprivate func value(at path: [PathComponent]) throws -> AnyHashable? { + switch path.headAndTail() { + case nil: + throw DataDict.MergeError.emptyMergePath + + case let (head, remaining)? where remaining.isEmpty: + return try value(at: head) + + case let (head, remaining)?: + switch try value(at: head) { + case let dict as DataDict: + return try dict.value(at: remaining) + + case let array as [AnyHashable?]: + return try array.value(at: remaining) + + default: + throw DataDict.MergeError.dataTypeNotAccessibleByPathComponent(head) + } + } + } + + fileprivate mutating func set(value newValue: AnyHashable?, at path: [PathComponent]) throws { + switch path.headAndTail() { + case nil: + throw DataDict.MergeError.emptyMergePath + + case let (head, remaining)? where remaining.isEmpty: + try set(value: newValue, at: head) + + case let (head, remaining)?: + switch try value(at: head) { + case var dict as DataDict: + try dict.set(value: newValue, at: remaining) + try set(value: dict, at: head) + + case var array as [AnyHashable?]: + try array.set(value: newValue, at: remaining) + try set(value: array, at: head) + + default: + throw DataDict.MergeError.dataTypeNotAccessibleByPathComponent(head) + } + } + } +} + +extension DataDict: PathComponentAccessible { + fileprivate func value(at path: PathComponent) throws -> AnyHashable? { + let key = try validatedKeyFromPath(path) + + return self._data[key] + } + + fileprivate mutating func set(value: AnyHashable?, at path: PathComponent) throws { + let key = try validatedKeyFromPath(path) + + self._data[key] = value + } + + private func validatedKeyFromPath(_ path: PathComponent) throws -> String { + guard case let .field(key) = path else { + throw DataDict.MergeError.invalidPathComponentForDataType(path, "DataDict") + } + + guard _data.keys.contains(key) else { + throw DataDict.MergeError.cannotFindPathComponent(path) + } + + return key + } +} + +extension Array: PathComponentAccessible where Element == AnyHashable? { + fileprivate func value(at path: PathComponent) throws -> AnyHashable? { + let index = try validatedIndexFromPath(path) + + return self[index] + } + + fileprivate mutating func set(value: AnyHashable?, at path: PathComponent) throws { + let index = try validatedIndexFromPath(path) + + self[index] = value + } + + private func validatedIndexFromPath(_ path: PathComponent) throws -> Int { + guard case let .index(index) = path else { + throw DataDict.MergeError.invalidPathComponentForDataType(path, "Array") + } + + guard index < endIndex else { + throw DataDict.MergeError.cannotFindPathComponent(path) + } + + return index + } +} + +/// Splits the first `PathComponent` element returning the first element and an array of all +/// remaining elements. +extension Array where Element == PathComponent { + fileprivate func headAndTail() -> (head: PathComponent, tail: [PathComponent])? { + guard !isEmpty else { return nil } + + var tail = self + let head = tail.removeFirst() + + return (head, tail) + } +} diff --git a/Sources/Apollo/DataDictMapper.swift b/Sources/Apollo/DataDictMapper.swift new file mode 100644 index 0000000000..6f74f84907 --- /dev/null +++ b/Sources/Apollo/DataDictMapper.swift @@ -0,0 +1,91 @@ +#if !COCOAPODS +import ApolloAPI +#endif + +/// An accumulator that converts executed data to the correct values for use in a selection set. +@_spi(Execution) +public class DataDictMapper: GraphQLResultAccumulator { + + public let requiresCacheKeyComputation: Bool = false + + let handleMissingValues: HandleMissingValues + + public enum HandleMissingValues { + case disallow + case allowForOptionalFields + /// Using this option will result in an unsafe `SelectionSet` that will crash + /// when a required field that has missing data is accessed. + case allowForAllFields + } + + init(handleMissingValues: HandleMissingValues = .disallow) { + self.handleMissingValues = handleMissingValues + } + + public func accept(scalar: AnyHashable, info: FieldExecutionInfo) throws -> AnyHashable? { + switch info.field.type.namedType { + case let .scalar(decodable as any JSONDecodable.Type): + // This will convert a JSON value to the expected value type. + return try decodable.init(_jsonValue: scalar)._asAnyHashable + default: + preconditionFailure() + } + } + + public func accept(customScalar: AnyHashable, info: FieldExecutionInfo) throws -> AnyHashable? { + switch info.field.type.namedType { + case let .customScalar(decodable as any JSONDecodable.Type): + // This will convert a JSON value to the expected value type, + // which could be a custom scalar or an enum. + return try decodable.init(_jsonValue: customScalar)._asAnyHashable + default: + preconditionFailure() + } + } + + public func acceptNullValue(info: FieldExecutionInfo) -> AnyHashable? { + return DataDict._NullValue + } + + public func acceptMissingValue(info: FieldExecutionInfo) throws -> AnyHashable? { + switch handleMissingValues { + case .allowForOptionalFields where info.field.type.isNullable: fallthrough + case .allowForAllFields: + return nil + + default: + throw JSONDecodingError.missingValue + } + } + + public func accept(list: [AnyHashable?], info: FieldExecutionInfo) -> AnyHashable? { + return list + } + + public func accept(childObject: DataDict, info: FieldExecutionInfo) throws -> AnyHashable? { + return childObject + } + + public func accept( + fieldEntry: AnyHashable?, + info: FieldExecutionInfo + ) -> (key: String, value: AnyHashable)? { + guard let fieldEntry = fieldEntry else { return nil } + return (info.responseKeyForField, fieldEntry) + } + + public func accept( + fieldEntries: [(key: String, value: AnyHashable)], + info: ObjectExecutionInfo + ) throws -> DataDict { + return DataDict( + data: .init(fieldEntries, uniquingKeysWith: { (_, last) in last }), + fulfilledFragments: info.fulfilledFragments, + deferredFragments: info.deferredFragments + ) + } + + public func finish(rootValue: DataDict, info: ObjectExecutionInfo) -> DataDict { + return rootValue + } +} diff --git a/Sources/Apollo/DataLoader.swift b/Sources/Apollo/DataLoader.swift index fa959c22fe..2f6afd72a9 100644 --- a/Sources/Apollo/DataLoader.swift +++ b/Sources/Apollo/DataLoader.swift @@ -2,7 +2,7 @@ final class DataLoader { public typealias BatchLoad = (Set) throws -> [Key: Value] private var batchLoad: BatchLoad - private var cache: [Key: Result] = [:] + private var cache: [Key: Result] = [:] private var pendingLoads: Set = [] public init(_ batchLoad: @escaping BatchLoad) { diff --git a/Sources/Apollo/DefaultInterceptorProvider.swift b/Sources/Apollo/DefaultInterceptorProvider.swift index bef366f9a8..fecfd327cc 100644 --- a/Sources/Apollo/DefaultInterceptorProvider.swift +++ b/Sources/Apollo/DefaultInterceptorProvider.swift @@ -38,13 +38,22 @@ open class DefaultInterceptorProvider: InterceptorProvider { NetworkFetchInterceptor(client: self.client), ResponseCodeInterceptor(), MultipartResponseParsingInterceptor(), - JSONResponseParsingInterceptor(), + jsonParsingInterceptor(for: operation), AutomaticPersistedQueryInterceptor(), CacheWriteInterceptor(store: self.store), ] } - open func additionalErrorInterceptor(for operation: Operation) -> ApolloErrorInterceptor? { + private func jsonParsingInterceptor(for operation: Operation) -> any ApolloInterceptor { + if Operation.hasDeferredFragments { + return IncrementalJSONResponseParsingInterceptor() + + } else { + return JSONResponseParsingInterceptor() + } + } + + open func additionalErrorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? { return nil } } diff --git a/Sources/Apollo/DispatchQueue+Optional.swift b/Sources/Apollo/DispatchQueue+Optional.swift index 4836b81f86..f87e0195e8 100644 --- a/Sources/Apollo/DispatchQueue+Optional.swift +++ b/Sources/Apollo/DispatchQueue+Optional.swift @@ -15,8 +15,8 @@ extension DispatchQueue { } static func returnResultAsyncIfNeeded(on callbackQueue: DispatchQueue?, - action: ((Result) -> Void)?, - result: Result) { + action: ((Result) -> Void)?, + result: Result) { if let action = action { self.performAsyncIfNeeded(on: callbackQueue) { action(result) diff --git a/Sources/Apollo/ExecutionSources/CacheDataExecutionSource.swift b/Sources/Apollo/ExecutionSources/CacheDataExecutionSource.swift index 691f62f7c5..c998b0c44a 100644 --- a/Sources/Apollo/ExecutionSources/CacheDataExecutionSource.swift +++ b/Sources/Apollo/ExecutionSources/CacheDataExecutionSource.swift @@ -19,6 +19,14 @@ struct CacheDataExecutionSource: GraphQLExecutionSource { /// against the cache data. weak var transaction: ApolloStore.ReadTransaction? + /// Used to determine whether deferred selections within a selection set should be executed at the same + /// time as the other selections. + /// + /// When executing on cache data all selections, including deferred, must be executed together because + /// there is only a single response from the cache data. Any deferred selection that was cached will + /// be returned in the response. + var shouldAttemptDeferredFragmentExecution: Bool { true } + init(transaction: ApolloStore.ReadTransaction) { self.transaction = transaction } @@ -78,7 +86,7 @@ struct CacheDataExecutionSource: GraphQLExecutionSource { return transaction.loadObject(forKey: reference.key) } - func computeCacheKey(for object: Record, in schema: SchemaMetadata.Type) -> CacheKey? { + func computeCacheKey(for object: Record, in schema: any SchemaMetadata.Type) -> CacheKey? { return object.key } diff --git a/Sources/Apollo/ExecutionSources/NetworkResponseExecutionSource.swift b/Sources/Apollo/ExecutionSources/NetworkResponseExecutionSource.swift index ce4246a40b..6cf142270f 100644 --- a/Sources/Apollo/ExecutionSources/NetworkResponseExecutionSource.swift +++ b/Sources/Apollo/ExecutionSources/NetworkResponseExecutionSource.swift @@ -5,11 +5,22 @@ import Foundation /// A `GraphQLExecutionSource` configured to execute upon the JSON data from the network response /// for a GraphQL operation. -struct NetworkResponseExecutionSource: GraphQLExecutionSource, CacheKeyComputingExecutionSource { - typealias RawObjectData = JSONObject - typealias FieldCollector = DefaultFieldSelectionCollector +@_spi(Execution) +public struct NetworkResponseExecutionSource: GraphQLExecutionSource, CacheKeyComputingExecutionSource { + public typealias RawObjectData = JSONObject + public typealias FieldCollector = DefaultFieldSelectionCollector - func resolveField( + /// Used to determine whether deferred selections within a selection set should be executed at the same + /// time as the other selections. + /// + /// When executing on a network response, deferred selections are not executed at the same time as the + /// other selections because they are sent from the server as independent responses, are parsed + /// sequentially, and the results are returned separately. + public var shouldAttemptDeferredFragmentExecution: Bool { false } + + public init() {} + + public func resolveField( with info: FieldExecutionInfo, on object: JSONObject ) -> PossiblyDeferred<(AnyHashable?, Date)> { @@ -17,17 +28,17 @@ struct NetworkResponseExecutionSource: GraphQLExecutionSource, CacheKeyComputing return .immediate(.success(result)) } - func opaqueObjectDataWrapper(for rawData: JSONObject) -> ObjectData { + public func opaqueObjectDataWrapper(for rawData: JSONObject) -> ObjectData { ObjectData(_transformer: DataTransformer(), _rawData: rawData) } struct DataTransformer: _ObjectData_Transformer { func transform(_ value: AnyHashable) -> (any ScalarType)? { switch value { - case let scalar as ScalarType: + case let scalar as any ScalarType: return scalar - case let customScalar as CustomScalarType: - return customScalar._jsonValue as? ScalarType + case let customScalar as any CustomScalarType: + return customScalar._jsonValue as? (any ScalarType) default: return nil } } diff --git a/Sources/Apollo/ExecutionSources/SelectionSetModelExecutionSource.swift b/Sources/Apollo/ExecutionSources/SelectionSetModelExecutionSource.swift index ff839ffc52..bcc8e8d045 100644 --- a/Sources/Apollo/ExecutionSources/SelectionSetModelExecutionSource.swift +++ b/Sources/Apollo/ExecutionSources/SelectionSetModelExecutionSource.swift @@ -9,6 +9,8 @@ struct SelectionSetModelExecutionSource: GraphQLExecutionSource, CacheKeyComputi typealias RawObjectData = DataDict typealias FieldCollector = CustomCacheDataWritingFieldSelectionCollector + var shouldAttemptDeferredFragmentExecution: Bool { false } + func resolveField( with info: FieldExecutionInfo, on object: DataDict @@ -24,10 +26,10 @@ struct SelectionSetModelExecutionSource: GraphQLExecutionSource, CacheKeyComputi struct DataTransformer: _ObjectData_Transformer { func transform(_ value: AnyHashable) -> (any ScalarType)? { switch value { - case let scalar as ScalarType: + case let scalar as any ScalarType: return scalar - case let customScalar as CustomScalarType: - return customScalar._jsonValue as? ScalarType + case let customScalar as any CustomScalarType: + return customScalar._jsonValue as? (any ScalarType) default: return nil } } diff --git a/Sources/Apollo/FieldSelectionCollector.swift b/Sources/Apollo/FieldSelectionCollector.swift index 47e82e2b32..7948d59fa3 100644 --- a/Sources/Apollo/FieldSelectionCollector.swift +++ b/Sources/Apollo/FieldSelectionCollector.swift @@ -3,12 +3,16 @@ import Foundation import ApolloAPI #endif -struct FieldSelectionGrouping: Sequence { - private var fieldInfoList: [String: FieldExecutionInfo] = [:] +@_spi(Execution) +public struct FieldSelectionGrouping { + fileprivate(set) var fieldInfoList: [String: FieldExecutionInfo] = [:] fileprivate(set) var fulfilledFragments: Set = [] + fileprivate(set) var deferredFragments: Set = [] + fileprivate(set) var cachedFragmentIdentifierTypes: [ObjectIdentifier: any SelectionSet.Type] = [:] init(info: ObjectExecutionInfo) { self.fulfilledFragments = info.fulfilledFragments + self.deferredFragments = info.deferredFragments } var count: Int { fieldInfoList.count } @@ -24,12 +28,27 @@ struct FieldSelectionGrouping: Sequence { } mutating func addFulfilledFragment(_ type: T.Type) { - fulfilledFragments.insert(ObjectIdentifier(type)) + precondition( + !deferredFragments.contains(type: type), + "Cannot fulfill \(type.self) fragment, it's already deferred!" + ) + + let identifier = ObjectIdentifier(type) + fulfilledFragments.insert(identifier) + cachedFragmentIdentifierTypes[identifier] = type } - func makeIterator() -> Dictionary.Iterator { - fieldInfoList.makeIterator() + mutating func addDeferredFragment(_ type: T.Type) { + precondition( + !fulfilledFragments.contains(type: type), + "Cannot defer \(type.self) fragment, it's already fulfilled!" + ) + + let identifier = ObjectIdentifier(type) + deferredFragments.insert(identifier) + cachedFragmentIdentifierTypes[identifier] = type } + } /// A protocol for a type that defines how to collect and group the selections for an object @@ -38,7 +57,8 @@ struct FieldSelectionGrouping: Sequence { /// A `FieldSelectionController` is responsible for determining which selections should be executed /// and which fragments are being fulfilled during execution. It does this by adding them to the /// provided `FieldSelectionGrouping`. -protocol FieldSelectionCollector { +@_spi(Execution) +public protocol FieldSelectionCollector { associatedtype ObjectData @@ -57,8 +77,9 @@ protocol FieldSelectionCollector { } -struct DefaultFieldSelectionCollector: FieldSelectionCollector { - static func collectFields( +@_spi(Execution) +public struct DefaultFieldSelectionCollector: FieldSelectionCollector { + static public func collectFields( from selections: [Selection], into groupedFields: inout FieldSelectionGrouping, for object: JSONObject, @@ -77,8 +98,31 @@ struct DefaultFieldSelectionCollector: FieldSelectionCollector { info: info) } - case .deferred(_, _, _): - assertionFailure("Defer execution must be implemented (#3145).") + case let .deferred(condition, typeCase, _): + // In Apollo's implementation (Router + Server) of deferSpec=20220824 ALL defer directives + // will be honoured and sent as separate incremental responses. This means deferred + // selection fields only need to be collected when they are parsed with the incremental + // data, at which time they are no longer deferred. The deferred fragment identifiers still + // need to be collected becuase that is how the user determines the state of the deferred + // fragment via the @Deferred property wrapper. + // + // If the defer condition evaluates to false though, the fragment is considered to be fulfilled + // and and the fields must be collected. + let isDeferred: Bool = { + if let condition, !condition.evaluate(with: info.variables) { + return false + } + return true + }() + + if isDeferred { + groupedFields.addDeferredFragment(typeCase) + + } else { + groupedFields.addFulfilledFragment(typeCase) + try collectFields(from: typeCase.__selections, into: &groupedFields, for: object, info: info) + } + case let .fragment(fragment): groupedFields.addFulfilledFragment(fragment) try collectFields(from: fragment.__selections, @@ -86,7 +130,6 @@ 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) { @@ -148,8 +191,18 @@ struct CustomCacheDataWritingFieldSelectionCollector: FieldSelectionCollector { for: object, info: info, asConditionalFields: true) - case .deferred(_, _, _): - assertionFailure("Defer execution must be implemented (#3145).") + + case let .deferred(_, deferredFragment, _): + if groupedFields.fulfilledFragments.contains(type: deferredFragment) { + try collectFields( + from: deferredFragment.__selections, + into: &groupedFields, + for: object, + info: info, + asConditionalFields: false + ) + } + case let .fragment(fragment): if groupedFields.fulfilledFragments.contains(type: fragment) { try collectFields(from: fragment.__selections, diff --git a/Sources/Apollo/GraphQLError.swift b/Sources/Apollo/GraphQLError.swift index 6c99dea419..03295303ed 100644 --- a/Sources/Apollo/GraphQLError.swift +++ b/Sources/Apollo/GraphQLError.swift @@ -56,23 +56,7 @@ public struct GraphQLError: Error, Hashable { } } - /// Represents a path in a GraphQL query. - public enum PathEntry: Equatable { - /// A String value for a field in a GraphQL query - case field(String) - /// An Int value for an index in a GraphQL List - case index(Int) - - init?(_ value: JSONValue) { - if let string = value as? String { - self = .field(string) - } else if let int = value as? Int { - self = .index(int) - } else { - return nil - } - } - } + public typealias PathEntry = PathComponent } extension GraphQLError: CustomStringConvertible { @@ -89,11 +73,6 @@ extension GraphQLError: LocalizedError { extension GraphQLError { func asJSONDictionary() -> [String: Any] { - var dict: [String: Any] = [:] - if let message = self["message"] { dict["message"] = message } - if let locations = self["locations"] { dict["locations"] = locations } - if let path = self["path"] { dict["path"] = path } - if let extensions = self["extensions"] { dict["extensions"] = extensions } - return dict + JSONConverter.convert(self) } } diff --git a/Sources/Apollo/GraphQLExecutionSource.swift b/Sources/Apollo/GraphQLExecutionSource.swift index a5c62c9ba2..1a16bf3b42 100644 --- a/Sources/Apollo/GraphQLExecutionSource.swift +++ b/Sources/Apollo/GraphQLExecutionSource.swift @@ -9,7 +9,8 @@ import ApolloAPI /// Based on the source of execution data, the way we handle portions of the execution pipeline will /// be different. Each implementation of this protocol provides the necessary implementations for /// executing upon data from a specific source. -protocol GraphQLExecutionSource { +@_spi(Execution) +public protocol GraphQLExecutionSource { /// The type that represents each object in data from the source. associatedtype RawObjectData @@ -17,6 +18,10 @@ protocol GraphQLExecutionSource { /// GraphQL execution. associatedtype FieldCollector: FieldSelectionCollector + /// Used to determine whether deferred selections within a selection set should be executed at the same + /// time as the other selections. + var shouldAttemptDeferredFragmentExecution: Bool { get } + /// Resolves the value for given field on a data object from the source. /// /// Because data may be loaded from a database, these loads are batched for performance reasons. @@ -39,12 +44,13 @@ protocol GraphQLExecutionSource { /// - Returns: A cache key for normalizing the object in the cache. If `nil` is returned the /// object is assumed to be stored in the cache with no normalization. The executor will /// construct a cache key based on the object's path in its enclosing operation. - func computeCacheKey(for object: RawObjectData, in schema: SchemaMetadata.Type) -> CacheKey? + func computeCacheKey(for object: RawObjectData, in schema: any SchemaMetadata.Type) -> CacheKey? } /// A type of `GraphQLExecutionSource` that uses the user defined cache key computation /// defined in the ``SchemaConfiguration``. -protocol CacheKeyComputingExecutionSource: GraphQLExecutionSource { +@_spi(Execution) +public protocol CacheKeyComputingExecutionSource: GraphQLExecutionSource { /// A function that should return an `ObjectData` wrapper that performs and custom /// transformations required to transform the raw object data from the source into a consistent /// format to be exposed to the user's ``SchemaConfiguration/cacheKeyInfo(for:object:)`` function. @@ -52,7 +58,7 @@ protocol CacheKeyComputingExecutionSource: GraphQLExecutionSource { } extension CacheKeyComputingExecutionSource { - func computeCacheKey(for object: RawObjectData, in schema: SchemaMetadata.Type) -> CacheKey? { + @_spi(Execution) public func computeCacheKey(for object: RawObjectData, in schema: any SchemaMetadata.Type) -> CacheKey? { let dataWrapper = opaqueObjectDataWrapper(for: object) return schema.cacheKey(for: dataWrapper) } diff --git a/Sources/Apollo/GraphQLExecutor.swift b/Sources/Apollo/GraphQLExecutor.swift index 9d42013b5c..1c247dcc54 100644 --- a/Sources/Apollo/GraphQLExecutor.swift +++ b/Sources/Apollo/GraphQLExecutor.swift @@ -15,16 +15,20 @@ typealias ReferenceResolver = (CacheReference) -> PossiblyDeferred<(JSONObject, class ObjectExecutionInfo { let rootType: any RootSelectionSet.Type +@_spi(Execution) +public class ObjectExecutionInfo { + let rootType: any SelectionSet.Type let variables: GraphQLOperation.Variables? - let schema: SchemaMetadata.Type + let schema: any SchemaMetadata.Type private(set) var responsePath: ResponsePath = [] private(set) var cachePath: ResponsePath = [] fileprivate(set) var fulfilledFragments: Set + fileprivate(set) var deferredFragments: Set = [] fileprivate init( - rootType: any RootSelectionSet.Type, + rootType: any SelectionSet.Type, variables: GraphQLOperation.Variables?, - schema: SchemaMetadata.Type, + schema: (any SchemaMetadata.Type), responsePath: ResponsePath, cachePath: ResponsePath ) { @@ -37,9 +41,9 @@ class ObjectExecutionInfo { } fileprivate init( - rootType: any RootSelectionSet.Type, + rootType: any SelectionSet.Type, variables: GraphQLOperation.Variables?, - schema: SchemaMetadata.Type, + schema: (any SchemaMetadata.Type), withRootCacheReference root: CacheReference? = nil ) { self.rootType = rootType @@ -68,7 +72,8 @@ class ObjectExecutionInfo { /// /// GraphQL validation makes sure all fields sharing the same response key have the same /// arguments and are of the same type, so we only need to resolve one field. -class FieldExecutionInfo { +@_spi(Execution) +public class FieldExecutionInfo { let field: Selection.Field let parentInfo: ObjectExecutionInfo @@ -166,7 +171,7 @@ public struct GraphQLExecutionError: Error, LocalizedError { public var pathString: String { path.description } /// The error that occurred during parsing. - public let underlying: Error + public let underlying: any Error /// A description of the error which includes the path where the error occurred. public var errorDescription: String? { @@ -174,24 +179,31 @@ public struct GraphQLExecutionError: Error, LocalizedError { } } -/// A GraphQL executor is responsible for executing a selection set and generating a result. It is initialized with a resolver closure that gets called repeatedly to resolve field values. +/// A GraphQL executor is responsible for executing a selection set and generating a result. It is +/// initialized with a resolver closure that gets called repeatedly to resolve field values. /// -/// An executor is used both to parse a response received from the server, and to read from the normalized cache. It can also be configured with an accumulator that receives events during execution, and these execution events are used by `GraphQLResultNormalizer` to normalize a response into a flat set of records and by `GraphQLDependencyTracker` keep track of dependent keys. +/// An executor is used both to parse a response received from the server, and to read from the +/// normalized cache. It can also be configured with an accumulator that receives events during +/// execution, and these execution events are used by `GraphQLResultNormalizer` to normalize a +/// response into a flat set of records and by `GraphQLDependencyTracker` keep track of dependent +/// keys. /// /// The methods in this class closely follow the /// [execution algorithm described in the GraphQL specification] /// (http://spec.graphql.org/draft/#sec-Execution) -final class GraphQLExecutor { +@_spi(Execution) +public final class GraphQLExecutor { private let executionSource: Source - init(executionSource: Source) { + public init(executionSource: Source) { self.executionSource = executionSource } // MARK: - Execution - func execute< + @_spi(Execution) + public func execute< Accumulator: GraphQLResultAccumulator, SelectionSet: RootSelectionSet >( @@ -202,14 +214,55 @@ final class GraphQLExecutor { variables: GraphQLOperation.Variables? = nil, accumulator: Accumulator ) throws -> Accumulator.FinalResult { - let info = ObjectExecutionInfo( - rootType: SelectionSet.self, + return try execute( + selectionSet: selectionSet, + on: data, + withRootCacheReference: root, variables: variables, schema: SelectionSet.Schema.self, + accumulator: accumulator + ) + } + + func execute< + Accumulator: GraphQLResultAccumulator, + Operation: GraphQLOperation + >( + selectionSet: any SelectionSet.Type, + in operation: Operation.Type, + on data: Source.RawObjectData, + withRootCacheReference root: CacheReference? = nil, + variables: GraphQLOperation.Variables? = nil, + accumulator: Accumulator + ) throws -> Accumulator.FinalResult { + return try execute( + selectionSet: selectionSet, + on: data, + withRootCacheReference: root, + variables: variables, + schema: Operation.Data.Schema.self, + accumulator: accumulator + ) + } + + private func execute< + Accumulator: GraphQLResultAccumulator + >( + selectionSet: any SelectionSet.Type, + on data: Source.RawObjectData, + withRootCacheReference root: CacheReference? = nil, + variables: GraphQLOperation.Variables? = nil, + schema: (any SchemaMetadata.Type), + accumulator: Accumulator + ) throws -> Accumulator.FinalResult { + let info = ObjectExecutionInfo( + rootType: selectionSet, + variables: variables, + schema: schema, withRootCacheReference: root ) - let rootValue = execute( + let rootValue: PossiblyDeferred = execute( selections: selectionSet.__selections, on: data, firstReceivedAt: firstReceivedAt, @@ -227,9 +280,28 @@ final class GraphQLExecutor { info: ObjectExecutionInfo, accumulator: Accumulator ) -> PossiblyDeferred { + let fieldEntries: [PossiblyDeferred] = execute( + selections: selections, + on: object, + info: info, + accumulator: accumulator + ) + + return compactLazilyEvaluateAll(fieldEntries).map { + try accumulator.accept(fieldEntries: $0, info: info) + } + } + + private func execute( + selections: [Selection], + on object: Source.RawObjectData, + info: ObjectExecutionInfo, + accumulator: Accumulator + ) -> [PossiblyDeferred] { do { let groupedFields = try groupFields(selections, on: object, info: info) info.fulfilledFragments = groupedFields.fulfilledFragments + info.deferredFragments = [] var fieldEntries: [PossiblyDeferred] = [] fieldEntries.reserveCapacity(groupedFields.count) @@ -241,13 +313,43 @@ final class GraphQLExecutor { accumulator: accumulator) fieldEntries.append(fieldEntry) } - - return compactLazilyEvaluateAll(fieldEntries).map { - try accumulator.accept(fieldEntries: $0, info: info) + + if executionSource.shouldAttemptDeferredFragmentExecution { + for deferredFragment in groupedFields.deferredFragments { + guard let fragmentType = groupedFields.cachedFragmentIdentifierTypes[deferredFragment] else { + info.deferredFragments.insert(deferredFragment) + continue + } + + do { + let deferredFragmentFieldEntries = try lazilyEvaluateAll( + execute( + selections: fragmentType.__selections, + on: object, + info: info, + accumulator: accumulator + ) + ) + .get() + .compactMap { PossiblyDeferred.immediate(.success($0)) } + + fieldEntries.append(contentsOf: deferredFragmentFieldEntries) + info.fulfilledFragments.insert(deferredFragment) + + } catch { + info.deferredFragments.insert(deferredFragment) + continue + } + } + + } else { + info.deferredFragments = groupedFields.deferredFragments } + return fieldEntries + } catch { - return .immediate(.failure(error)) + return [.immediate(.failure(error))] } } diff --git a/Sources/Apollo/GraphQLQueryWatcher.swift b/Sources/Apollo/GraphQLQueryWatcher.swift index cc9eb3bca9..7c62776aaa 100644 --- a/Sources/Apollo/GraphQLQueryWatcher.swift +++ b/Sources/Apollo/GraphQLQueryWatcher.swift @@ -7,20 +7,27 @@ import ApolloAPI /// /// NOTE: The store retains the watcher while subscribed. You must call `cancel()` on your query watcher when you no longer need results. Failure to call `cancel()` before releasing your reference to the returned watcher will result in a memory leak. public final class GraphQLQueryWatcher: Cancellable, ApolloStoreSubscriber { - weak var client: ApolloClientProtocol? + weak var client: (any ApolloClientProtocol)? public let query: Query + + /// Determines if the watcher should perform a network fetch when it's watched objects have + /// changed, but reloading them from the cache fails. Defaults to `true`. + /// + /// If set to `false`, the watcher will not receive updates if the cache load fails. + public let refetchOnFailedUpdates: Bool + let resultHandler: GraphQLResultHandler private let callbackQueue: DispatchQueue private let contextIdentifier = UUID() - private let context: RequestContext? + private let context: (any RequestContext)? private class WeakFetchTaskContainer { - weak var cancellable: Cancellable? + weak var cancellable: (any Cancellable)? var cachePolicy: CachePolicy? - fileprivate init(_ cancellable: Cancellable?, _ cachePolicy: CachePolicy?) { + fileprivate init(_ cancellable: (any Cancellable)?, _ cachePolicy: CachePolicy?) { self.cancellable = cancellable self.cachePolicy = cachePolicy } @@ -35,16 +42,20 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo /// - Parameters: /// - client: The client protocol to pass in. /// - query: The query to watch. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Defaults to `true`. /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. /// - callbackQueue: The queue for the result handler. Defaults to the main queue. /// - resultHandler: The result handler to call with changes. - public init(client: ApolloClientProtocol, + public init(client: any ApolloClientProtocol, query: Query, - context: RequestContext? = nil, + refetchOnFailedUpdates: Bool = true, + context: (any RequestContext)? = nil, callbackQueue: DispatchQueue = .main, resultHandler: @escaping GraphQLResultHandler) { self.client = client self.query = query + self.refetchOnFailedUpdates = refetchOnFailedUpdates self.resultHandler = resultHandler self.callbackQueue = callbackQueue self.context = context @@ -120,7 +131,7 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo self.resultHandler(result) } case .failure: - if self.fetching.cachePolicy != .returnCacheDataDontFetch { + if self.refetchOnFailedUpdates && self.fetching.cachePolicy != .returnCacheDataDontFetch { // If the cache fetch is not successful, for instance if the data is missing, refresh from the server. self.fetch(cachePolicy: .fetchIgnoringCacheData) } diff --git a/Sources/Apollo/GraphQLResponse.swift b/Sources/Apollo/GraphQLResponse.swift index ff4dee1774..fb43caa18d 100644 --- a/Sources/Apollo/GraphQLResponse.swift +++ b/Sources/Apollo/GraphQLResponse.swift @@ -3,24 +3,42 @@ import ApolloAPI #endif import Foundation - -/// Represents a GraphQL response received from a server. +/// Represents a complete GraphQL response received from a server. public final class GraphQLResponse { + private let base: AnyGraphQLResponse + + public init( + operation: Operation, + body: JSONObject + ) where Operation.Data == Data { + self.base = AnyGraphQLResponse( + body: body, + rootKey: CacheReference.rootCacheReference(for: Operation.operationType), + variables: operation.__variables + ) + } - public let body: JSONObject - - private let rootKey: CacheReference - private let variables: GraphQLOperation.Variables? - - public init(operation: Operation, body: JSONObject) where Operation.Data == Data { - self.body = body - rootKey = CacheReference.rootCacheReference(for: Operation.operationType) - variables = operation.__variables + /// Parses the response into a `GraphQLResult` and a `RecordSet` depending on the cache policy. The result can be + /// sent to a completion block for a request and the `RecordSet` can be merged into a local cache. + /// + /// - Returns: A tuple of a `GraphQLResult` and an optional `RecordSet`. + /// + /// - Parameter cachePolicy: Used to determine whether a cache `RecordSet` is returned. A cache policy that does + /// not read or write to the cache will return a `nil` cache `RecordSet`. + public func parseResult(withCachePolicy cachePolicy: CachePolicy) throws -> (GraphQLResult, RecordSet?) { + switch cachePolicy { + case .fetchIgnoringCacheCompletely: + // There is no cache, so we don't need to get any info on dependencies. Use fast parsing. + return (try parseResultFast(), nil) + + default: + return try parseResult() + } } - /// Parses a response into a `GraphQLResult` and a `RecordSet`. - /// The result can be sent to a completion block for a request. - /// The `RecordSet` can be merged into a local cache. + /// Parses a response into a `GraphQLResult` and a `RecordSet`. The result can be sent to a completion block for a + /// request and the `RecordSet` can be merged into a local cache. + /// /// - Returns: A `GraphQLResult` and a `RecordSet`. public func parseResult() throws -> (GraphQLResult, RecordSet?) { let accumulator = zip( @@ -81,24 +99,30 @@ public final class GraphQLResponse { let data = try execute(with: accumulator) return makeResult(data: data, dependentKeys: nil, resultContext: GraphQLResultMetadata()) } + + private func makeResult(data: Data?, dependentKeys: Set?) -> GraphQLResult { + return GraphQLResult( + data: data, + extensions: base.parseExtensions(), + errors: base.parseErrors(), + source: .server, + dependentKeys: dependentKeys + ) + } } // MARK: - Equatable Conformance extension GraphQLResponse: Equatable where Data: Equatable { public static func == (lhs: GraphQLResponse, rhs: GraphQLResponse) -> Bool { - lhs.body == rhs.body && - lhs.rootKey == rhs.rootKey && - lhs.variables?._jsonEncodableObject._jsonValue == rhs.variables?._jsonEncodableObject._jsonValue + lhs.base == rhs.base } } // MARK: - Hashable Conformance -extension GraphQLResponse: Hashable where Data: Hashable { +extension GraphQLResponse: Hashable { public func hash(into hasher: inout Hasher) { - hasher.combine(body) - hasher.combine(rootKey) - hasher.combine(variables?._jsonEncodableObject._jsonValue) + hasher.combine(base) } } diff --git a/Sources/Apollo/GraphQLResult.swift b/Sources/Apollo/GraphQLResult.swift index c2056487d2..bb597179e3 100644 --- a/Sources/Apollo/GraphQLResult.swift +++ b/Sources/Apollo/GraphQLResult.swift @@ -30,12 +30,18 @@ extension GraphQLResultMetadata: Hashable { /// Represents the result of a GraphQL operation. public struct GraphQLResult { + /// Represents source of data + public enum Source: Hashable { + case cache + case server + } + /// The typed result data, or `nil` if an error was encountered that prevented a valid response. public let data: Data? /// A list of errors, or `nil` if the operation completed without encountering any errors. public let errors: [GraphQLError]? /// A dictionary which services can use however they see fit to provide additional information to clients. - public var extensions: [String: AnyHashable]? + public let extensions: [String: AnyHashable]? /// Metadata of this result. public let metadata: GraphQLResultMetadata @@ -49,18 +55,77 @@ public struct GraphQLResult { let dependentKeys: Set? - public init(data: Data?, - extensions: [String: AnyHashable]?, - errors: [GraphQLError]?, - source: Source, - dependentKeys: Set?, - metadata: GraphQLResultMetadata) { - self.data = data - self.extensions = extensions - self.errors = errors - self.source = source - self.dependentKeys = dependentKeys - self.metadata = metadata +public init(data: Data?, + extensions: [String: AnyHashable]?, + errors: [GraphQLError]?, + source: Source, + dependentKeys: Set?, + metadata: GraphQLResultMetadata) { + self.data = data + self.extensions = extensions + self.errors = errors + self.source = source + self.dependentKeys = dependentKeys + self.metadata = metadata +} + + func merging(_ incrementalResult: IncrementalGraphQLResult) throws -> GraphQLResult { + let mergedDataDict = try merge( + incrementalResult.data?.__data, + into: self.data?.__data + ) { currentDataDict, incrementalDataDict in + try currentDataDict.merging(incrementalDataDict, at: incrementalResult.path) + } + var mergedData: Data? = nil + if let mergedDataDict { + mergedData = Data(_dataDict: mergedDataDict) + } + + let mergedErrors = try merge( + incrementalResult.errors, + into: self.errors + ) { currentErrors, incrementalErrors in + currentErrors + incrementalErrors + } + + let mergedExtensions = try merge( + incrementalResult.extensions, + into: self.extensions + ) { currentExtensions, incrementalExtensions in + currentExtensions.merging(incrementalExtensions) { _, new in new } + } + + let mergedDependentKeys = try merge( + incrementalResult.dependentKeys, + into: self.dependentKeys + ) { currentDependentKeys, incrementalDependentKeys in + currentDependentKeys.union(incrementalDependentKeys) + } + + return GraphQLResult( + data: mergedData, + extensions: mergedExtensions, + errors: mergedErrors, + source: source, + dependentKeys: mergedDependentKeys + ) + } + + fileprivate func merge( + _ newValue: T?, + into currentValue: T?, + onMerge: (_ currentValue: T, _ newValue: T) throws -> T + ) throws -> T? { + switch (currentValue, newValue) { + case let (currentValue, nil): + return currentValue + + case let (.some(currentValue), .some(newValue)): + return try onMerge(currentValue, newValue) + + case let (nil, newValue): + return newValue + } } } @@ -79,23 +144,23 @@ extension GraphQLResult: Equatable where Data: Equatable { extension GraphQLResult: Hashable where Data: Hashable {} extension GraphQLResult { - + /// Converts a ``GraphQLResult`` into a basic JSON dictionary for use. /// /// - Returns: A `[String: Any]` JSON dictionary representing the ``GraphQLResult``. public func asJSONDictionary() -> [String: Any] { var dict: [String: Any] = [:] - if let data { dict["data"] = convert(value: data.__data) } + if let data { dict["data"] = JSONConverter.convert(data) } if let errors { dict["errors"] = errors.map { $0.asJSONDictionary() } } if let extensions { dict["extensions"] = extensions } return dict } - + private func convert(value: Any) -> Any { var val: Any = value if let value = value as? DataDict { val = value._data - } else if let value = value as? CustomScalarType { + } else if let value = value as? (any CustomScalarType) { val = value._jsonValue } if let dict = val as? [String: Any] { diff --git a/Sources/Apollo/GraphQLResultAccumulator.swift b/Sources/Apollo/GraphQLResultAccumulator.swift index ebe9a32024..aaae32b256 100644 --- a/Sources/Apollo/GraphQLResultAccumulator.swift +++ b/Sources/Apollo/GraphQLResultAccumulator.swift @@ -2,9 +2,8 @@ import ApolloAPI #endif -import Foundation - -protocol GraphQLResultAccumulator: AnyObject { +@_spi(Execution) +public protocol GraphQLResultAccumulator: AnyObject { associatedtype PartialResult associatedtype FieldEntry associatedtype ObjectResult diff --git a/Sources/Apollo/GraphQLSelectionSetMapper.swift b/Sources/Apollo/GraphQLSelectionSetMapper.swift index 39a8bda861..ab16032a11 100644 --- a/Sources/Apollo/GraphQLSelectionSetMapper.swift +++ b/Sources/Apollo/GraphQLSelectionSetMapper.swift @@ -3,25 +3,22 @@ import ApolloAPI #endif import Foundation -/// An accumulator that converts executed data to the correct values to create a `SelectionSet`. -final class GraphQLSelectionSetMapper: GraphQLResultAccumulator { +/// An accumulator that maps executed data to create a `SelectionSet`. +@_spi(Execution) +public final class GraphQLSelectionSetMapper: GraphQLResultAccumulator { - let requiresCacheKeyComputation: Bool = false + let dataDictMapper: DataDictMapper - let handleMissingValues: HandleMissingValues + public init(dataDictMapper: DataDictMapper) { + self.dataDictMapper = dataDictMapper + } - enum HandleMissingValues { - case disallow - case allowForOptionalFields - /// Using this option will result in an unsafe `SelectionSet` that will crash - /// when a required field that has missing data is accessed. - case allowForAllFields + public var requiresCacheKeyComputation: Bool { + dataDictMapper.requiresCacheKeyComputation } - init( - handleMissingValues: HandleMissingValues = .disallow - ) { - self.handleMissingValues = handleMissingValues + public var handleMissingValues: DataDictMapper.HandleMissingValues { + dataDictMapper.handleMissingValues } func accept(scalar: AnyHashable, firstReceivedAt: Date, info: FieldExecutionInfo) throws -> AnyHashable? { @@ -60,30 +57,31 @@ final class GraphQLSelectionSetMapper: GraphQLResultAccumulator } } - func accept(list: [AnyHashable?], info: FieldExecutionInfo) -> AnyHashable? { + public func accept(list: [AnyHashable?], info: FieldExecutionInfo) -> AnyHashable? { return list } - func accept(childObject: DataDict, info: FieldExecutionInfo) throws -> AnyHashable? { + public func accept(childObject: DataDict, info: FieldExecutionInfo) throws -> AnyHashable? { return childObject } - func accept(fieldEntry: AnyHashable?, info: FieldExecutionInfo) -> (key: String, value: AnyHashable)? { + public func accept(fieldEntry: AnyHashable?, info: FieldExecutionInfo) -> (key: String, value: AnyHashable)? { guard let fieldEntry = fieldEntry else { return nil } return (info.responseKeyForField, fieldEntry) } - - func accept( + + public func accept( fieldEntries: [(key: String, value: AnyHashable)], info: ObjectExecutionInfo ) throws -> DataDict { return DataDict( data: .init(fieldEntries, uniquingKeysWith: { (_, last) in last }), - fulfilledFragments: info.fulfilledFragments + fulfilledFragments: info.fulfilledFragments, + deferredFragments: info.deferredFragments ) } - func finish(rootValue: DataDict, info: ObjectExecutionInfo) -> T { + public func finish(rootValue: DataDict, info: ObjectExecutionInfo) -> T { return T.init(_dataDict: rootValue) } } diff --git a/Sources/Apollo/HTTPRequest.swift b/Sources/Apollo/HTTPRequest.swift index a9e7bc8cf3..ad39f98619 100644 --- a/Sources/Apollo/HTTPRequest.swift +++ b/Sources/Apollo/HTTPRequest.swift @@ -22,7 +22,7 @@ open class HTTPRequest: Hashable { public let contextIdentifier: UUID? /// [optional] A context that is being passed through the request chain. - public let context: RequestContext? + public let context: (any RequestContext)? /// Designated Initializer /// @@ -44,7 +44,7 @@ open class HTTPRequest: Hashable { clientVersion: String, additionalHeaders: [String: String], cachePolicy: CachePolicy = .default, - context: RequestContext? = nil) { + context: (any RequestContext)? = nil) { self.graphQLEndpoint = graphQLEndpoint self.operation = operation self.contextIdentifier = contextIdentifier diff --git a/Sources/Apollo/HTTPResponse.swift b/Sources/Apollo/HTTPResponse.swift index 42c82f08e0..2b2cb27f7a 100644 --- a/Sources/Apollo/HTTPResponse.swift +++ b/Sources/Apollo/HTTPResponse.swift @@ -12,22 +12,31 @@ public class HTTPResponse { /// The raw data received from the URL loading system public var rawData: Data - /// [optional] The data as parsed into a `GraphQLResult`, which can eventually be returned to the UI. Will be nil if not yet parsed. + /// [optional] The data as parsed into a `GraphQLResult`, which can eventually be returned to the UI. Will be nil + /// if not yet parsed. public var parsedResponse: GraphQLResult? - /// [optional] The data as parsed into a `GraphQLResponse` for legacy caching purposes. If you're not using the `JSONResponseParsingInterceptor`, you probably shouldn't be using this property. - /// **NOTE:** This property will be removed when the transition to the Swift Codegen is complete. - public var legacyResponse: GraphQLResponse? = nil - + /// [optional] The data as parsed into a `GraphQLResponse` for legacy caching purposes. If you're not using the + /// `JSONResponseParsingInterceptor`, you probably shouldn't be using this property. + @available(*, deprecated, message: "Do not use. This property will be removed in a future version.") + public var legacyResponse: GraphQLResponse? { _legacyResponse } + var _legacyResponse: GraphQLResponse? = nil + + /// A set of cache records from the response + public var cacheRecords: RecordSet? + /// 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 init(response: HTTPURLResponse, - rawData: Data, - parsedResponse: GraphQLResult?) { + /// - parsedResponse: [optional] The response parsed into the `ParsedValue` type. Will be nil if not yet parsed, + /// or if parsing failed. + public init( + response: HTTPURLResponse, + rawData: Data, + parsedResponse: GraphQLResult? + ) { self.httpResponse = response self.rawData = rawData self.parsedResponse = parsedResponse @@ -41,7 +50,8 @@ extension HTTPResponse: Equatable where Operation.Data: Equatable { lhs.httpResponse == rhs.httpResponse && lhs.rawData == rhs.rawData && lhs.parsedResponse == rhs.parsedResponse && - lhs.legacyResponse == rhs.legacyResponse + lhs._legacyResponse == rhs._legacyResponse && + lhs.cacheRecords == rhs.cacheRecords } } @@ -52,6 +62,7 @@ extension HTTPResponse: Hashable where Operation.Data: Hashable { hasher.combine(httpResponse) hasher.combine(rawData) hasher.combine(parsedResponse) - hasher.combine(legacyResponse) + hasher.combine(_legacyResponse) + hasher.combine(cacheRecords) } } diff --git a/Sources/Apollo/IncrementalGraphQLResponse.swift b/Sources/Apollo/IncrementalGraphQLResponse.swift new file mode 100644 index 0000000000..6aff7faa28 --- /dev/null +++ b/Sources/Apollo/IncrementalGraphQLResponse.swift @@ -0,0 +1,165 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +/// Represents an incremental GraphQL response received from a server. +final class IncrementalGraphQLResponse { + public enum ResponseError: Error, LocalizedError, Equatable { + case missingPath + case missingLabel + case missingDeferredSelectionSetType(String, String) + + public var errorDescription: String? { + switch self { + case .missingPath: + return "Incremental responses must have a 'path' key." + + case .missingLabel: + return "Incremental responses must have a 'label' key." + + case let .missingDeferredSelectionSetType(label, path): + return "The operation does not have a deferred selection set for label '\(label)' at field path '\(path)'." + } + } + } + + private let base: AnyGraphQLResponse + + public init(operation: Operation, body: JSONObject) throws { + guard let path = body["path"] as? [JSONValue] else { + throw ResponseError.missingPath + } + + let rootKey = try CacheReference.rootCacheReference(for: Operation.operationType, path: path) + + self.base = AnyGraphQLResponse( + body: body, + rootKey: rootKey, + variables: operation.__variables + ) + } + + /// Parses the response into a `IncrementalGraphQLResult` and a `RecordSet` depending on the cache policy. The result + /// can be used to merge into a partial result and the `RecordSet` can be merged into a local cache. + /// + /// - Returns: A tuple of a `IncrementalGraphQLResult` and an optional `RecordSet`. + /// + /// - Parameter cachePolicy: Used to determine whether a cache `RecordSet` is returned. A cache policy that does + /// not read or write to the cache will return a `nil` cache `RecordSet`. + func parseIncrementalResult( + withCachePolicy cachePolicy: CachePolicy + ) throws -> (IncrementalGraphQLResult, RecordSet?) { + switch cachePolicy { + case .fetchIgnoringCacheCompletely: + // There is no cache, so we don't need to get any info on dependencies. Use fast parsing. + return (try parseIncrementalResultFast(), nil) + + default: + return try parseIncrementalResult() + } + } + + private func parseIncrementalResult() throws -> (IncrementalGraphQLResult, RecordSet?) { + let accumulator = zip( + DataDictMapper(), + ResultNormalizerFactory.networkResponseDataNormalizer(), + GraphQLDependencyTracker() + ) + + var cacheKeys: RecordSet? = nil + let result = try makeResult { deferrableSelectionSetType in + let executionResult = try base.execute( + selectionSet: deferrableSelectionSetType, + in: Operation.self, + with: accumulator + ) + cacheKeys = executionResult?.1 + + return (executionResult?.0, executionResult?.2) + } + + return (result, cacheKeys) + } + + private func parseIncrementalResultFast() throws -> IncrementalGraphQLResult { + let accumulator = DataDictMapper() + let result = try makeResult { deferrableSelectionSetType in + let executionResult = try base.execute( + selectionSet: deferrableSelectionSetType, + in: Operation.self, + with: accumulator + ) + + return (executionResult, nil) + } + + return result + } + + fileprivate func makeResult( + executor: ((any Deferrable.Type) throws -> (data: DataDict?, dependentKeys: Set?)) + ) throws -> IncrementalGraphQLResult { + guard let path = base.body["path"] as? [JSONValue] else { + throw ResponseError.missingPath + } + guard let label = base.body["label"] as? String else { + throw ResponseError.missingLabel + } + + let pathComponents: [PathComponent] = path.compactMap(PathComponent.init) + let fieldPath = pathComponents.fieldPath + + guard let selectionSetType = Operation.deferredSelectionSetType( + for: Operation.self, + withLabel: label, + atFieldPath: fieldPath + ) as? (any Deferrable.Type) else { + throw ResponseError.missingDeferredSelectionSetType(label, fieldPath.joined(separator: ".")) + } + + let executionResult = try executor(selectionSetType) + let selectionSet: (any SelectionSet)? + + if let data = executionResult.data { + selectionSet = selectionSetType.init(_dataDict: data) + } else { + selectionSet = nil + } + + return IncrementalGraphQLResult( + label: label, + path: pathComponents, + data: selectionSet, + extensions: base.parseExtensions(), + errors: base.parseErrors(), + dependentKeys: executionResult.dependentKeys + ) + } +} + +extension CacheReference { + fileprivate static func rootCacheReference( + for operationType: GraphQLOperationType, + path: [JSONValue] + ) throws -> CacheReference { + var keys: [String] = [rootCacheReference(for: operationType).key] + for component in path { + keys.append(try String(_jsonValue: component)) + } + + return CacheReference(keys.joined(separator: ".")) + } +} + +extension [PathComponent] { + fileprivate var fieldPath: [String] { + return self.compactMap({ pathComponent in + if case let .field(name) = pathComponent { + return name + } + + return nil + }) + } +} diff --git a/Sources/Apollo/IncrementalGraphQLResult.swift b/Sources/Apollo/IncrementalGraphQLResult.swift new file mode 100644 index 0000000000..5f44c6d3d1 --- /dev/null +++ b/Sources/Apollo/IncrementalGraphQLResult.swift @@ -0,0 +1,41 @@ +#if !COCOAPODS +import ApolloAPI +#endif + +/// Represents an incremental result received as part of a deferred incremental response. +/// +/// This is not a type exposed to users as a final result, it is an intermediate result that is +/// merged into a final result. +struct IncrementalGraphQLResult { + /// This is the same label identifier passed to the `@defer` directive associated with the + /// response. + let label: String + /// Allows for the association to a particular field in a GraphQL result. This will be a list of + /// path segments starting at the root of the response and ending with the field to be associated + /// with. + let path: [PathComponent] + /// The typed result data, or `nil` if an error was encountered that prevented a valid response. + let data: (any SelectionSet)? + /// A list of errors, or `nil` if the operation completed without encountering any errors. + let errors: [GraphQLError]? + /// A dictionary which services can use however they see fit to provide additional information to clients. + let extensions: [String: AnyHashable]? + + let dependentKeys: Set? + + init( + label: String, + path: [PathComponent], + data: (any SelectionSet)?, + extensions: [String: AnyHashable]?, + errors: [GraphQLError]?, + dependentKeys: Set? + ) { + self.label = label + self.path = path + self.data = data + self.extensions = extensions + self.errors = errors + self.dependentKeys = dependentKeys + } +} diff --git a/Sources/Apollo/IncrementalJSONResponseParsingInterceptor.swift b/Sources/Apollo/IncrementalJSONResponseParsingInterceptor.swift new file mode 100644 index 0000000000..219bdbde76 --- /dev/null +++ b/Sources/Apollo/IncrementalJSONResponseParsingInterceptor.swift @@ -0,0 +1,142 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +/// An interceptor which parses JSON response data into a `GraphQLResult` and attaches it to the +/// `HTTPResponse`. +public struct IncrementalJSONResponseParsingInterceptor: ApolloInterceptor { + + public enum ParsingError: Error, LocalizedError { + case noResponseToParse + case couldNotParseToJSON(data: Data) + case mismatchedCurrentResultType + case couldNotParseIncrementalJSON(json: JSONValue) + + public var errorDescription: String? { + switch self { + case .noResponseToParse: + return "The JSON response parsing interceptor was called before a response was received. Double-check the order of your interceptors." + + case .couldNotParseToJSON(let data): + var errorStrings = [String]() + errorStrings.append("Could not parse data to JSON format.") + if let dataString = String(bytes: data, encoding: .utf8) { + errorStrings.append("Data received as a String was:") + errorStrings.append(dataString) + } else { + errorStrings.append("Data of count \(data.count) also could not be parsed into a String.") + } + + return errorStrings.joined(separator: " ") + + case .mismatchedCurrentResultType: + return "Partial result type operation does not match incremental result type operation." + + case let .couldNotParseIncrementalJSON(json): + return "Could not parse incremental values - got \(json)." + } + } + } + + public var id: String = UUID().uuidString + private let resultStorage = ResultStorage() + + private class ResultStorage { + var currentResult: Any? + var currentCacheRecords: RecordSet? + } + + public init() { } + + public func interceptAsync( + chain: any RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, any Error>) -> Void + ) { + guard let createdResponse = response else { + chain.handleErrorAsync( + ParsingError.noResponseToParse, + request: request, + response: response, + completion: completion + ) + return + } + + do { + guard + let body = try? JSONSerializationFormat.deserialize(data: createdResponse.rawData) as? JSONObject + else { + throw ParsingError.couldNotParseToJSON(data: createdResponse.rawData) + } + + let parsedResult: GraphQLResult + let parsedCacheRecords: RecordSet? + + if let currentResult = resultStorage.currentResult { + guard var currentResult = currentResult as? GraphQLResult else { + throw ParsingError.mismatchedCurrentResultType + } + + guard let incrementalItems = body["incremental"] as? [JSONObject] else { + throw ParsingError.couldNotParseIncrementalJSON(json: body) + } + + var currentCacheRecords = resultStorage.currentCacheRecords ?? RecordSet() + + for item in incrementalItems { + let incrementalResponse = try IncrementalGraphQLResponse( + operation: request.operation, + body: item + ) + let (incrementalResult, incrementalCacheRecords) = try incrementalResponse.parseIncrementalResult( + withCachePolicy: request.cachePolicy + ) + currentResult = try currentResult.merging(incrementalResult) + + if let incrementalCacheRecords { + currentCacheRecords.merge(records: incrementalCacheRecords) + } + } + + createdResponse._legacyResponse = nil + + parsedResult = currentResult + parsedCacheRecords = currentCacheRecords + + } else { + let graphQLResponse = GraphQLResponse(operation: request.operation, body: body) + createdResponse._legacyResponse = graphQLResponse + + let (result, cacheRecords) = try graphQLResponse.parseResult(withCachePolicy: request.cachePolicy) + + parsedResult = result + parsedCacheRecords = cacheRecords + } + + createdResponse.parsedResponse = parsedResult + createdResponse.cacheRecords = parsedCacheRecords + + resultStorage.currentResult = parsedResult + resultStorage.currentCacheRecords = parsedCacheRecords + + chain.proceedAsync( + request: request, + response: createdResponse, + interceptor: self, + completion: completion + ) + + } catch { + chain.handleErrorAsync( + error, + request: request, + response: createdResponse, + completion: completion + ) + } + } + +} diff --git a/Sources/Apollo/InterceptorProvider.swift b/Sources/Apollo/InterceptorProvider.swift index c2ec81ff88..f4aaa0cb0f 100644 --- a/Sources/Apollo/InterceptorProvider.swift +++ b/Sources/Apollo/InterceptorProvider.swift @@ -15,14 +15,14 @@ public protocol InterceptorProvider { /// Provides an additional error interceptor for any additional handling of errors /// before returning to the UI, such as logging. /// - Parameter operation: The operation to provide an additional error interceptor for - func additionalErrorInterceptor(for operation: Operation) -> ApolloErrorInterceptor? + func additionalErrorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? } /// MARK: - Default Implementation public extension InterceptorProvider { - func additionalErrorInterceptor(for operation: Operation) -> ApolloErrorInterceptor? { + func additionalErrorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? { return nil } } diff --git a/Sources/Apollo/InterceptorRequestChain.swift b/Sources/Apollo/InterceptorRequestChain.swift index df6315e78f..60b4cc9654 100644 --- a/Sources/Apollo/InterceptorRequestChain.swift +++ b/Sources/Apollo/InterceptorRequestChain.swift @@ -7,7 +7,7 @@ import ApolloAPI final public class InterceptorRequestChain: Cancellable, RequestChain { public enum ChainError: Error, LocalizedError { - case invalidIndex(chain: RequestChain, index: Int) + case invalidIndex(chain: any RequestChain, index: Int) case noInterceptors case unknownInterceptor(id: String) @@ -31,7 +31,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { @Atomic public var isCancelled: Bool = false /// Something which allows additional error handling to occur when some kind of error has happened. - public var additionalErrorHandler: ApolloErrorInterceptor? + public var additionalErrorHandler: (any ApolloErrorInterceptor)? /// Creates a chain with the given interceptor array. /// @@ -59,7 +59,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { /// - completion: The completion closure to call when the request has completed. public func kickoff( request: HTTPRequest, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { assert(self.currentIndex == 0, "The interceptor index should be zero when calling this method") @@ -91,7 +91,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { public func proceedAsync( request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { let nextIndex = self.currentIndex + 1 @@ -116,7 +116,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { request: HTTPRequest, response: HTTPResponse?, interceptor: any ApolloInterceptor, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { guard let currentIndex = interceptorIndexes[interceptor.id] else { self.handleErrorAsync( @@ -142,7 +142,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { interceptorIndex: Int, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { guard !self.isCancelled else { // Do not proceed, this chain has been cancelled. @@ -192,7 +192,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { // If an interceptor adheres to `Cancellable`, it should have its in-flight work cancelled as well. for interceptor in self.interceptors { - if let cancellableInterceptor = interceptor as? Cancellable { + if let cancellableInterceptor = interceptor as? (any Cancellable) { cancellableInterceptor.cancel() } } @@ -205,7 +205,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { /// - completion: The completion closure to call when the request has completed. public func retry( request: HTTPRequest, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { guard !self.isCancelled else { // Don't retry something that's been cancelled. @@ -225,10 +225,10 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { /// - response: The response, as far as it has been constructed. /// - completion: The completion closure to call when work is complete. public func handleErrorAsync( - _ error: Error, + _ error: any Error, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { guard !self.isCancelled else { return @@ -264,7 +264,7 @@ final public class InterceptorRequestChain: Cancellable, RequestChain { public func returnValueAsync( for request: HTTPRequest, value: GraphQLResult, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { guard !self.isCancelled else { return diff --git a/Sources/Apollo/JSONConverter.swift b/Sources/Apollo/JSONConverter.swift new file mode 100644 index 0000000000..6012eaa110 --- /dev/null +++ b/Sources/Apollo/JSONConverter.swift @@ -0,0 +1,52 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +public enum JSONConverter { + + /// Converts a ``SelectionSet`` into a basic JSON dictionary for use. + /// + /// - Returns: A `[String: Any]` JSON dictionary representing the ``SelectionSet``. + public static func convert(_ selectionSet: some SelectionSet) -> [String: Any] { + selectionSet.__data._data.mapValues(convert(value:)) + } + + static func convert(_ dataDict: DataDict) -> [String: Any] { + dataDict._data.mapValues(convert(value:)) + } + + /// Converts a ``GraphQLResult`` into a basic JSON dictionary for use. + /// + /// - Returns: A `[String: Any]` JSON dictionary representing the ``GraphQLResult``. + public static func convert(_ result: GraphQLResult) -> [String: Any] { + result.asJSONDictionary() + } + + /// Converts a ``GraphQLError`` into a basic JSON dictionary for use. + /// + /// - Returns: A `[String: Any]` JSON dictionary representing the ``GraphQLError``. + public static func convert(_ error: GraphQLError) -> [String: Any] { + var dict: [String: Any] = [:] + if let message = error["message"] { dict["message"] = message } + if let locations = error["locations"] { dict["locations"] = locations } + if let path = error["path"] { dict["path"] = path } + if let extensions = error["extensions"] { dict["extensions"] = extensions } + return dict + } + + private static func convert(value: Any) -> Any { + var val: Any = value + if let value = value as? DataDict { + val = value._data + } else if let value = value as? any CustomScalarType { + val = value._jsonValue + } + if let dict = val as? [String: Any] { + return dict.mapValues(convert) + } else if let arr = val as? [Any] { + return arr.map(convert) + } + return val + } +} diff --git a/Sources/Apollo/JSONRequest.swift b/Sources/Apollo/JSONRequest.swift index 9c54529a72..5e51727a47 100644 --- a/Sources/Apollo/JSONRequest.swift +++ b/Sources/Apollo/JSONRequest.swift @@ -6,7 +6,7 @@ import ApolloAPI /// A request which sends JSON related to a GraphQL operation. open class JSONRequest: HTTPRequest { - public let requestBodyCreator: RequestBodyCreator + public let requestBodyCreator: any RequestBodyCreator public let autoPersistQueries: Bool public let useGETForQueries: Bool @@ -50,11 +50,11 @@ open class JSONRequest: HTTPRequest { clientVersion: String, additionalHeaders: [String: String] = [:], cachePolicy: CachePolicy = .default, - context: RequestContext? = nil, + context: (any RequestContext)? = nil, autoPersistQueries: Bool = false, useGETForQueries: Bool = false, useGETForPersistedQueryRetry: Bool = false, - requestBodyCreator: RequestBodyCreator = ApolloRequestBodyCreator() + requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator() ) { self.autoPersistQueries = autoPersistQueries self.useGETForQueries = useGETForQueries @@ -98,7 +98,8 @@ open class JSONRequest: HTTPRequest { if let urlForGet = transformer.createGetURL() { request.url = urlForGet request.httpMethod = GraphQLHTTPMethod.GET.rawValue - + request.cachePolicy = requestCachePolicy + // GET requests shouldn't have a content-type since they do not provide actual content. request.allHTTPHeaderFields?.removeValue(forKey: "Content-Type") } else { @@ -150,6 +151,22 @@ open class JSONRequest: HTTPRequest { return body } + /// Convert the Apollo iOS cache policy into a matching cache policy for URLRequest. + private var requestCachePolicy: URLRequest.CachePolicy { + switch cachePolicy { + case .returnCacheDataElseFetch: + return .returnCacheDataElseLoad + case .fetchIgnoringCacheData: + return .reloadIgnoringLocalCacheData + case .fetchIgnoringCacheCompletely: + return .reloadIgnoringLocalAndRemoteCacheData + case .returnCacheDataDontFetch: + return .returnCacheDataDontLoad + case .returnCacheDataAndFetch: + return .reloadRevalidatingCacheData + } + } + // MARK: - Equtable/Hashable Conformance public static func == (lhs: JSONRequest, rhs: JSONRequest) -> Bool { diff --git a/Sources/Apollo/JSONResponseParsingInterceptor.swift b/Sources/Apollo/JSONResponseParsingInterceptor.swift index 6e35ce917c..16c8fc31b9 100644 --- a/Sources/Apollo/JSONResponseParsingInterceptor.swift +++ b/Sources/Apollo/JSONResponseParsingInterceptor.swift @@ -34,10 +34,10 @@ public struct JSONResponseParsingInterceptor: ApolloInterceptor { public init() { } public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) { guard let createdResponse = response else { chain.handleErrorAsync( @@ -57,11 +57,12 @@ public struct JSONResponseParsingInterceptor: ApolloInterceptor { } let graphQLResponse = GraphQLResponse(operation: request.operation, body: body) - createdResponse.legacyResponse = graphQLResponse + createdResponse._legacyResponse = graphQLResponse - - let result = try parseResult(from: graphQLResponse, cachePolicy: request.cachePolicy) + let (result, cacheRecords) = try graphQLResponse.parseResult(withCachePolicy: request.cachePolicy) createdResponse.parsedResponse = result + createdResponse.cacheRecords = cacheRecords + chain.proceedAsync( request: request, response: createdResponse, @@ -79,18 +80,4 @@ public struct JSONResponseParsingInterceptor: ApolloInterceptor { } } - private func parseResult( - from response: GraphQLResponse, - cachePolicy: CachePolicy - ) throws -> GraphQLResult { - switch cachePolicy { - case .fetchIgnoringCacheCompletely: - // There is no cache, so we don't need to get any info on dependencies. Use fast parsing. - return try response.parseResultFast() - default: - let (parsedResult, _) = try response.parseResult() - return parsedResult - } - } - } diff --git a/Sources/Apollo/JSONSerializationFormat.swift b/Sources/Apollo/JSONSerializationFormat.swift index 7f62161ef1..b87a714f6a 100644 --- a/Sources/Apollo/JSONSerializationFormat.swift +++ b/Sources/Apollo/JSONSerializationFormat.swift @@ -4,7 +4,7 @@ import ApolloAPI #endif public final class JSONSerializationFormat { - public class func serialize(value: JSONEncodable) throws -> Data { + public class func serialize(value: any JSONEncodable) throws -> Data { return try JSONSerialization.sortedData(withJSONObject: value._jsonValue) } diff --git a/Sources/Apollo/MaxRetryInterceptor.swift b/Sources/Apollo/MaxRetryInterceptor.swift index ed25961abb..4adc90bfc8 100644 --- a/Sources/Apollo/MaxRetryInterceptor.swift +++ b/Sources/Apollo/MaxRetryInterceptor.swift @@ -30,10 +30,10 @@ public class MaxRetryInterceptor: ApolloInterceptor { } public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) { + completion: @escaping (Result, any Error>) -> Void) { guard self.hitCount <= self.maxRetries else { let error = RetryError.hitMaxRetryCount( count: self.maxRetries, diff --git a/Sources/Apollo/MultipartResponseDeferParser.swift b/Sources/Apollo/MultipartResponseDeferParser.swift index f39a9e05e7..e0be7df81f 100644 --- a/Sources/Apollo/MultipartResponseDeferParser.swift +++ b/Sources/Apollo/MultipartResponseDeferParser.swift @@ -4,14 +4,91 @@ import ApolloAPI #endif struct MultipartResponseDeferParser: MultipartResponseSpecificationParser { + public enum ParsingError: Swift.Error, LocalizedError, Equatable { + case unsupportedContentType(type: String) + case cannotParseChunkData + case cannotParsePayloadData + + public var errorDescription: String? { + switch self { + + 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 .cannotParsePayloadData: + return "The payload data could not be parsed." + } + } + } + + private enum DataLine { + case contentHeader(type: String) + case json(object: JSONObject) + case unknown + + init(_ value: String) { + self = Self.parse(value) + } + + private static func parse(_ dataLine: String) -> DataLine { + var contentTypeHeader: StaticString { "content-type:" } + + if dataLine.starts(with: contentTypeHeader.description) { + let contentType = (dataLine + .components(separatedBy: ":").last ?? dataLine + ).trimmingCharacters(in: .whitespaces) + + return .contentHeader(type: contentType) + } + + if + let data = dataLine.data(using: .utf8), + let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + { + return .json(object: jsonObject) + } + + return .unknown + } + } + 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 + static func parse(_ chunk: String) -> Result { + for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) { + switch DataLine(dataLine.trimmingCharacters(in: .newlines)) { + case let .contentHeader(type): + guard type == "application/json" else { + return .failure(ParsingError.unsupportedContentType(type: type)) + } + + case let .json(object): + guard object.isPartialResponse || object.isIncrementalResponse else { + return .failure(ParsingError.cannotParsePayloadData) + } + + guard let serialized: Data = try? JSONSerializationFormat.serialize(value: object) else { + return .failure(ParsingError.cannotParsePayloadData) + } + + return .success(serialized) + + case .unknown: + return .failure(ParsingError.cannotParseChunkData) + } + } + + return .success(nil) + } +} + +fileprivate extension JSONObject { + var isPartialResponse: Bool { + self.keys.contains("data") && self.keys.contains("hasNext") + } + + var isIncrementalResponse: Bool { + self.keys.contains("incremental") && self.keys.contains("hasNext") } } diff --git a/Sources/Apollo/MultipartResponseParsingInterceptor.swift b/Sources/Apollo/MultipartResponseParsingInterceptor.swift index 0c20e274b6..c2d681618c 100644 --- a/Sources/Apollo/MultipartResponseParsingInterceptor.swift +++ b/Sources/Apollo/MultipartResponseParsingInterceptor.swift @@ -9,6 +9,7 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { public enum ParsingError: Error, LocalizedError, Equatable { case noResponseToParse case cannotParseResponse + case cannotParseResponseData public var errorDescription: String? { switch self { @@ -16,12 +17,15 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { return "There is no response to parse. Check the order of your interceptors." case .cannotParseResponse: return "The response data could not be parsed." + case .cannotParseResponseData: + return "The response data could not be parsed." } } } - private static let responseParsers: [String: MultipartResponseSpecificationParser.Type] = [ - MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self + private static let responseParsers: [String: any MultipartResponseSpecificationParser.Type] = [ + MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self, + MultipartResponseDeferParser.protocolSpec: MultipartResponseDeferParser.self, ] public var id: String = UUID().uuidString @@ -29,10 +33,10 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { public init() { } public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation { guard let response else { @@ -71,36 +75,47 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor { return } - let dataHandler: ((Data) -> Void) = { data in - let response = HTTPResponse( - response: response.httpResponse, - rawData: data, - parsedResponse: nil - ) - - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - } - - let errorHandler: ((Error) -> Void) = { parserError in + guard let dataString = String(data: response.rawData, encoding: .utf8) else { chain.handleErrorAsync( - parserError, + ParsingError.cannotParseResponseData, request: request, response: response, completion: completion ) + return } - parser.parse( - data: response.rawData, - boundary: boundary, - dataHandler: dataHandler, - errorHandler: errorHandler - ) + for chunk in dataString.components(separatedBy: "--\(boundary)") { + if chunk.isEmpty || chunk.isBoundaryMarker { continue } + + switch parser.parse(chunk) { + case let .success(data): + // Some chunks can be successfully parsed but do not require to be passed on to the next + // interceptor, such as an HTTP subscription heartbeat message. + if let data { + let response = HTTPResponse( + response: response.httpResponse, + rawData: data, + parsedResponse: nil + ) + + chain.proceedAsync( + request: request, + response: response, + interceptor: self, + completion: completion + ) + } + + case let .failure(parserError): + chain.handleErrorAsync( + parserError, + request: request, + response: response, + completion: completion + ) + } + } } } @@ -111,11 +126,20 @@ protocol MultipartResponseSpecificationParser { /// 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) - ) + /// Called to process each chunk in a multipart response. + /// + /// The return value is a `Result` type that indicates whether the chunk was successfully parsed + /// or not. It is possible to return `.success` with a `nil` data value. This should only happen + /// when the chunk was successfully parsed but there is no action to take on the message, such as + /// a heartbeat message. Successful results with a `nil` data value will not be returned to the + /// user. + static func parse(_ chunk: String) -> Result +} + +extension MultipartResponseSpecificationParser { + static var dataLineSeparator: StaticString { "\r\n\r\n" } +} + +fileprivate extension String { + var isBoundaryMarker: Bool { self == "--" } } diff --git a/Sources/Apollo/MultipartResponseSubscriptionParser.swift b/Sources/Apollo/MultipartResponseSubscriptionParser.swift index dec316522f..d16b14b278 100644 --- a/Sources/Apollo/MultipartResponseSubscriptionParser.swift +++ b/Sources/Apollo/MultipartResponseSubscriptionParser.swift @@ -5,16 +5,15 @@ import ApolloAPI struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser { public enum ParsingError: Swift.Error, LocalizedError, Equatable { - case cannotParseResponseData case unsupportedContentType(type: String) case cannotParseChunkData case irrecoverableError(message: String?) case cannotParsePayloadData + case cannotParseErrorData 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: @@ -23,107 +22,107 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser return "An irrecoverable error occured: \(message ?? "unknown")." case .cannotParsePayloadData: return "The payload data could not be parsed." + case .cannotParseErrorData: + return "The error data could not be parsed." } } } - private enum ChunkedDataLine { + private enum DataLine { 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 + init(_ value: String) { + self = Self.parse(value) } - for chunk in dataString.components(separatedBy: "--\(boundary)") { - if chunk.isEmpty || chunk.isBoundaryPrefix { continue } + private static func parse(_ dataLine: String) -> DataLine { + var contentTypeHeader: StaticString { "content-type:" } + var heartbeat: StaticString { "{}" } - 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 + if dataLine == heartbeat.description { + return .heartbeat + } - case let .contentHeader(type): - guard type == "application/json" else { - errorHandler(ParsingError.unsupportedContentType(type: type)) - return - } + if dataLine.lowercased().starts(with: contentTypeHeader.description) { + let contentType = (dataLine + .components(separatedBy: ":").last ?? dataLine + ).trimmingCharacters(in: .whitespaces) - case let .json(object): - if let errors = object["errors"] as? [JSONObject] { - let message = errors.first?["message"] as? String + return .contentHeader(type: contentType) + } - errorHandler(ParsingError.irrecoverableError(message: message)) - return - } + if + let data = dataLine.data(using: .utf8), + let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + { + return .json(object: jsonObject) + } - guard let payload = object["payload"] else { - errorHandler(ParsingError.cannotParsePayloadData) - return - } + return .unknown + } + } + + static let protocolSpec: String = "subscriptionSpec=1.0" + + static func parse(_ chunk: String) -> Result { + for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) { + switch DataLine(dataLine.trimmingCharacters(in: .newlines)) { + case .heartbeat: + // Periodically sent by the router - noop + break + + case let .contentHeader(type): + guard type == "application/json" else { + return .failure(ParsingError.unsupportedContentType(type: type)) + } - if payload is NSNull { - // `payload` can be null such as in the case of a transport error - continue + case let .json(object): + if let errors = object.errors, !(errors is NSNull) { + guard + let errors = errors as? [JSONObject], + let message = errors.first?["message"] as? String + else { + return .failure(ParsingError.cannotParseErrorData) } + return .failure(ParsingError.irrecoverableError(message: message)) + } + + if let payload = object.payload, !(payload is NSNull) { guard let payload = payload as? JSONObject, let data: Data = try? JSONSerializationFormat.serialize(value: payload) else { - errorHandler(ParsingError.cannotParsePayloadData) - return + return .failure(ParsingError.cannotParsePayloadData) } - dataHandler(data) - - case .unknown: - errorHandler(ParsingError.cannotParseChunkData) + return .success(data) } - } - } - } - - /// 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) - ) - } + // 'errors' is optional because valid payloads don't have transport errors. + // `errors` can be null because it's taken to be the same as optional. + // `payload` is optional because the heartbeat message does not contain a payload field. + // `payload` can be null such as in the case of a transport error or future use (TBD). + return .success(nil) - if - let data = dataLine.data(using: .utf8), - let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject - { - return .json(object: jsonObject) + case .unknown: + return .failure(ParsingError.cannotParseChunkData) + } } - return .unknown + return .success(nil) } } -fileprivate extension String { - var isBoundaryPrefix: Bool { self == "--" } +fileprivate extension JSONObject { + var errors: JSONValue? { + self["errors"] + } + + var payload: JSONValue? { + self["payload"] + } } diff --git a/Sources/Apollo/NetworkFetchInterceptor.swift b/Sources/Apollo/NetworkFetchInterceptor.swift index a477cf6e2b..4b7636b3d1 100644 --- a/Sources/Apollo/NetworkFetchInterceptor.swift +++ b/Sources/Apollo/NetworkFetchInterceptor.swift @@ -18,10 +18,10 @@ public class NetworkFetchInterceptor: ApolloInterceptor, Cancellable { } public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) { + completion: @escaping (Result, any Error>) -> Void) { let urlRequest: URLRequest do { @@ -36,7 +36,8 @@ public class NetworkFetchInterceptor: ApolloInterceptor, Cancellable { return } - let task = self.client.sendRequest(urlRequest) { [weak self] result in + let taskDescription = "\(Operation.operationType) \(Operation.operationName)" + let task = self.client.sendRequest(urlRequest, taskDescription: taskDescription) { [weak self] result in guard let self = self else { return } diff --git a/Sources/Apollo/NetworkTransport.swift b/Sources/Apollo/NetworkTransport.swift index 6b0cd953e8..e594f9462f 100644 --- a/Sources/Apollo/NetworkTransport.swift +++ b/Sources/Apollo/NetworkTransport.swift @@ -21,9 +21,9 @@ public protocol NetworkTransport: AnyObject { func send(operation: Operation, cachePolicy: CachePolicy, contextIdentifier: UUID?, - context: RequestContext?, + context: (any RequestContext)?, callbackQueue: DispatchQueue, - completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable + completionHandler: @escaping (Result, any Error>) -> Void) -> any Cancellable /// The name of the client to send as a header value. var clientName: String { get } @@ -108,7 +108,7 @@ public protocol UploadingNetworkTransport: NetworkTransport { func upload( operation: Operation, files: [GraphQLFile], - context: RequestContext?, + context: (any RequestContext)?, callbackQueue: DispatchQueue, - completionHandler: @escaping (Result,Error>) -> Void) -> Cancellable + completionHandler: @escaping (Result,any Error>) -> Void) -> any Cancellable } diff --git a/Sources/Apollo/PathComponent.swift b/Sources/Apollo/PathComponent.swift new file mode 100644 index 0000000000..ada3748d5d --- /dev/null +++ b/Sources/Apollo/PathComponent.swift @@ -0,0 +1,22 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +/// Represents a path in a GraphQL query. +public enum PathComponent: Equatable { + /// A String value for a field in a GraphQL query + case field(String) + /// An Int value for an index in a GraphQL List + case index(Int) + + init?(_ value: JSONValue) { + if let string = value as? String { + self = .field(string) + } else if let int = value as? Int { + self = .index(int) + } else { + return nil + } + } +} diff --git a/Sources/Apollo/PossiblyDeferred.swift b/Sources/Apollo/PossiblyDeferred.swift index 45d6bd2e0d..b706399d9a 100644 --- a/Sources/Apollo/PossiblyDeferred.swift +++ b/Sources/Apollo/PossiblyDeferred.swift @@ -42,9 +42,10 @@ extension Sequence { /// A possibly deferred value that represents either an immediate success or failure value, or a deferred /// value that is evaluated lazily when needed by invoking a throwing closure. -enum PossiblyDeferred { +@_spi(Execution) +public enum PossiblyDeferred { /// An immediate success or failure value, represented as a `Result` instance. - case immediate(Result) + case immediate(Result) /// A deferred value that will be lazily evaluated by invoking the associated throwing closure. case deferred(() throws -> Value) @@ -134,7 +135,7 @@ enum PossiblyDeferred { /// instance. /// - Returns: A `PossiblyDeferred` instance with the result of evaluating `transform` /// as the new failure value if this instance represents a failure. - func mapError(_ transform: @escaping (Error) -> Error) -> PossiblyDeferred { + func mapError(_ transform: @escaping (any Error) -> any Error) -> PossiblyDeferred { switch self { case .immediate(let result): return .immediate(result.mapError(transform)) diff --git a/Sources/Apollo/RequestChain.swift b/Sources/Apollo/RequestChain.swift index a0034f6328..363c26c94c 100644 --- a/Sources/Apollo/RequestChain.swift +++ b/Sources/Apollo/RequestChain.swift @@ -5,41 +5,41 @@ import ApolloAPI public protocol RequestChain: Cancellable { func kickoff( request: HTTPRequest, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation @available(*, deprecated, renamed: "proceedAsync(request:response:interceptor:completion:)") func proceedAsync( request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation func proceedAsync( request: HTTPRequest, response: HTTPResponse?, interceptor: any ApolloInterceptor, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation func cancel() func retry( request: HTTPRequest, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation func handleErrorAsync( - _ error: Error, + _ error: any Error, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation func returnValueAsync( for request: HTTPRequest, value: GraphQLResult, - completion: @escaping (Result, Error>) -> Void + completion: @escaping (Result, any Error>) -> Void ) where Operation : GraphQLOperation var isCancelled: Bool { get } diff --git a/Sources/Apollo/RequestChainNetworkTransport.swift b/Sources/Apollo/RequestChainNetworkTransport.swift index fd9199526a..0405e1394b 100644 --- a/Sources/Apollo/RequestChainNetworkTransport.swift +++ b/Sources/Apollo/RequestChainNetworkTransport.swift @@ -8,7 +8,7 @@ import ApolloAPI open class RequestChainNetworkTransport: NetworkTransport { /// The interceptor provider to use when constructing a request chain - let interceptorProvider: InterceptorProvider + let interceptorProvider: any InterceptorProvider /// The GraphQL endpoint URL to use. public let endpointURL: URL @@ -40,7 +40,7 @@ open class RequestChainNetworkTransport: NetworkTransport { /// The `RequestBodyCreator` object used to build your `URLRequest`. /// /// Defaults to an ``ApolloRequestBodyCreator`` initialized with the default configuration. - public var requestBodyCreator: RequestBodyCreator + public var requestBodyCreator: any RequestBodyCreator /// Designated initializer /// @@ -52,11 +52,11 @@ open class RequestChainNetworkTransport: NetworkTransport { /// - requestBodyCreator: The `RequestBodyCreator` object to use to build your `URLRequest`. Defaults to the provided `ApolloRequestBodyCreator` implementation. /// - useGETForQueries: Pass `true` if you want to use `GET` instead of `POST` for queries, for example to take advantage of a CDN. Defaults to `false`. /// - useGETForPersistedQueryRetry: Pass `true` to use `GET` instead of `POST` for a retry of a persisted query. Defaults to `false`. - public init(interceptorProvider: InterceptorProvider, + public init(interceptorProvider: any InterceptorProvider, endpointURL: URL, additionalHeaders: [String: String] = [:], autoPersistQueries: Bool = false, - requestBodyCreator: RequestBodyCreator = ApolloRequestBodyCreator(), + requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator(), useGETForQueries: Bool = false, useGETForPersistedQueryRetry: Bool = false) { self.interceptorProvider = interceptorProvider @@ -83,7 +83,7 @@ open class RequestChainNetworkTransport: NetworkTransport { for operation: Operation, cachePolicy: CachePolicy, contextIdentifier: UUID? = nil, - context: RequestContext? = nil + context: (any RequestContext)? = nil ) -> HTTPRequest { let request = JSONRequest( operation: operation, @@ -105,12 +105,11 @@ open class RequestChainNetworkTransport: NetworkTransport { name: "Accept", value: "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/json" ) - } - if Operation.hasDeferredFragments { + } else { request.addHeader( name: "Accept", - value: "multipart/mixed;boundary=\"graphql\";\(MultipartResponseDeferParser.protocolSpec),application/json" + value: "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/json" ) } @@ -126,9 +125,9 @@ open class RequestChainNetworkTransport: NetworkTransport { operation: Operation, cachePolicy: CachePolicy = .default, contextIdentifier: UUID? = nil, - context: RequestContext? = nil, + context: (any RequestContext)? = nil, callbackQueue: DispatchQueue = .main, - completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + completionHandler: @escaping (Result, any Error>) -> Void) -> any Cancellable { let chain = makeChain(operation: operation, callbackQueue: callbackQueue) let request = self.constructRequest( @@ -144,7 +143,7 @@ open class RequestChainNetworkTransport: NetworkTransport { private func makeChain( operation: Operation, callbackQueue: DispatchQueue = .main - ) -> RequestChain { + ) -> any RequestChain { let interceptors = self.interceptorProvider.interceptors(for: operation) let chain = InterceptorRequestChain(interceptors: interceptors, callbackQueue: callbackQueue) chain.additionalErrorHandler = self.interceptorProvider.additionalErrorInterceptor(for: operation) @@ -168,7 +167,7 @@ extension RequestChainNetworkTransport: UploadingNetworkTransport { public func constructUploadRequest( for operation: Operation, with files: [GraphQLFile], - context: RequestContext? = nil, + context: (any RequestContext)? = nil, manualBoundary: String? = nil) -> HTTPRequest { UploadRequest(graphQLEndpoint: self.endpointURL, @@ -185,9 +184,9 @@ extension RequestChainNetworkTransport: UploadingNetworkTransport { public func upload( operation: Operation, files: [GraphQLFile], - context: RequestContext?, + context: (any RequestContext)?, callbackQueue: DispatchQueue = .main, - completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + completionHandler: @escaping (Result, any Error>) -> Void) -> any Cancellable { let request = self.constructUploadRequest(for: operation, with: files, context: context) let chain = makeChain(operation: operation, callbackQueue: callbackQueue) diff --git a/Sources/Apollo/ResponseCodeInterceptor.swift b/Sources/Apollo/ResponseCodeInterceptor.swift index 2c94ff7cd1..b4d1837c24 100644 --- a/Sources/Apollo/ResponseCodeInterceptor.swift +++ b/Sources/Apollo/ResponseCodeInterceptor.swift @@ -50,10 +50,10 @@ public struct ResponseCodeInterceptor: ApolloInterceptor { public init() {} public func interceptAsync( - chain: RequestChain, + chain: any RequestChain, request: HTTPRequest, response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void) { + completion: @escaping (Result, any Error>) -> Void) { guard response?.httpResponse.isSuccessful == true else { diff --git a/Sources/Apollo/SelectionSet+JSONInitializer.swift b/Sources/Apollo/SelectionSet+JSONInitializer.swift index ff0837cefb..5853c96c80 100644 --- a/Sources/Apollo/SelectionSet+JSONInitializer.swift +++ b/Sources/Apollo/SelectionSet+JSONInitializer.swift @@ -34,3 +34,37 @@ extension RootSelectionSet { } } + +extension Deferrable { + + /// Initializes a `Deferrable` `SelectionSet` with a raw JSON response object. + /// + /// The process of converting a JSON response into `SelectionSetData` is done by using a + /// `GraphQLExecutor` with a`GraphQLSelectionSetMapper` to parse, validate, and transform + /// the JSON response data into the format expected by the `Deferrable` `SelectionSet`. + /// + /// - Parameters: + /// - data: A dictionary representing a JSON response object for a GraphQL object. + /// - operation: The operation which contains `data`. + /// - variables: [Optional] The operation variables that would be used to obtain + /// the given JSON response data. + init( + data: JSONObject, + in operation: any GraphQLOperation.Type, + variables: GraphQLOperation.Variables? = nil + ) throws { + let accumulator = GraphQLSelectionSetMapper( + handleMissingValues: .allowForOptionalFields + ) + let executor = GraphQLExecutor(executionSource: NetworkResponseExecutionSource()) + + self = try executor.execute( + selectionSet: Self.self, + in: operation, + on: data, + variables: variables, + accumulator: accumulator + ) + } + +} diff --git a/Sources/Apollo/URLSessionClient.swift b/Sources/Apollo/URLSessionClient.swift index a77a2f28fa..5f10bc532b 100644 --- a/Sources/Apollo/URLSessionClient.swift +++ b/Sources/Apollo/URLSessionClient.swift @@ -14,7 +14,7 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat case noHTTPResponse(request: URLRequest?) case sessionBecameInvalidWithoutUnderlyingError case dataForRequestNotFound(request: URLRequest?) - case networkError(data: Data, response: HTTPURLResponse?, underlying: Error) + case networkError(data: Data, response: HTTPURLResponse?, underlying: any Error) case sessionInvalidated case missingMultipartBoundary case cannotParseBoundaryData @@ -40,10 +40,10 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat } /// A completion block to be called when the raw task has completed, with the raw information from the session - public typealias RawCompletion = (Data?, HTTPURLResponse?, Error?) -> Void + public typealias RawCompletion = (Data?, HTTPURLResponse?, (any Error)?) -> Void /// A completion block returning a result. On `.success` it will contain a tuple with non-nil `Data` and its corresponding `HTTPURLResponse`. On `.failure` it will contain an error. - public typealias Completion = (Result<(Data, HTTPURLResponse), Error>) -> Void + public typealias Completion = (Result<(Data, HTTPURLResponse), any Error>) -> Void @Atomic private var tasks: [Int: TaskData] = [:] @@ -152,7 +152,7 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat sendRequest( request, taskDescription: nil, - rawTaskCompletionHandler: nil, + rawTaskCompletionHandler: rawTaskCompletionHandler, completion: completion ) } @@ -169,7 +169,7 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat // MARK: - URLSessionDelegate - open func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + open func urlSession(_ session: URLSession, didBecomeInvalidWithError error: (any Error)?) { let finalError = error ?? URLSessionClientError.sessionBecameInvalidWithoutUnderlyingError for task in self.tasks.values { task.completionBlock(.failure(finalError)) @@ -212,7 +212,7 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat open func urlSession(_ session: URLSession, task: URLSessionTask, - didCompleteWithError error: Error?) { + didCompleteWithError error: (any Error)?) { defer { self.clear(task: task.taskIdentifier) } diff --git a/Sources/Apollo/UploadRequest.swift b/Sources/Apollo/UploadRequest.swift index d2aacdfb59..761a375fcd 100644 --- a/Sources/Apollo/UploadRequest.swift +++ b/Sources/Apollo/UploadRequest.swift @@ -6,7 +6,7 @@ import ApolloAPI /// A request class allowing for a multipart-upload request. open class UploadRequest: HTTPRequest { - public let requestBodyCreator: RequestBodyCreator + public let requestBodyCreator: any RequestBodyCreator public let files: [GraphQLFile] public let manualBoundary: String? @@ -31,8 +31,8 @@ open class UploadRequest: HTTPRequest { additionalHeaders: [String: String] = [:], files: [GraphQLFile], manualBoundary: String? = nil, - context: RequestContext? = nil, - requestBodyCreator: RequestBodyCreator = ApolloRequestBodyCreator()) { + context: (any RequestContext)? = nil, + requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator()) { self.requestBodyCreator = requestBodyCreator self.files = files self.manualBoundary = manualBoundary @@ -79,7 +79,7 @@ open class UploadRequest: HTTPRequest { var variables = fields["variables"] as? JSONEncodableDictionary ?? JSONEncodableDictionary() for fieldName in fieldsForFiles { if let value = variables[fieldName], - let arrayValue = value as? [JSONEncodable] { + let arrayValue = value as? [any JSONEncodable] { let arrayOfNils: [NSNull?] = arrayValue.map { _ in NSNull() } variables.updateValue(arrayOfNils, forKey: fieldName) } else { diff --git a/Sources/ApolloAPI/DataDict.swift b/Sources/ApolloAPI/DataDict.swift index 539ca45f52..5cd254f561 100644 --- a/Sources/ApolloAPI/DataDict.swift +++ b/Sources/ApolloAPI/DataDict.swift @@ -44,6 +44,10 @@ public struct DataDict: Hashable { _storage.fulfilledFragments } + /// The set of fragments that have not yet been fulfilled and will be delivered in a future + /// response. + /// + /// Each `ObjectIdentifier` in the set corresponds to a specific `SelectionSet` type. @inlinable public var _deferredFragments: Set { _storage.deferredFragments } diff --git a/Sources/ApolloAPI/Deferred.swift b/Sources/ApolloAPI/Deferred.swift index e9310c8d11..49a74bef14 100644 --- a/Sources/ApolloAPI/Deferred.swift +++ b/Sources/ApolloAPI/Deferred.swift @@ -4,7 +4,7 @@ public protocol Deferrable: SelectionSet { } /// fulfilled value as well as the fulfilled state through the projected value. @propertyWrapper public struct Deferred { - public enum State { + public enum State: Equatable { /// 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. diff --git a/Sources/ApolloAPI/GraphQLOperation.swift b/Sources/ApolloAPI/GraphQLOperation.swift index 8cd6ca5d1d..068c118028 100644 --- a/Sources/ApolloAPI/GraphQLOperation.swift +++ b/Sources/ApolloAPI/GraphQLOperation.swift @@ -53,28 +53,51 @@ public struct OperationDefinition: Sendable { } } +/// A unique identifier used as a key to map a deferred selection set type to an incremental +/// response label and path. +public struct DeferredFragmentIdentifier: Hashable { + let label: String + let fieldPath: [String] + + public init(label: String, fieldPath: [String]) { + self.label = label + self.fieldPath = fieldPath + } +} + +// MARK: - GraphQLOperation + public protocol GraphQLOperation: AnyObject, Hashable { - typealias Variables = [String: GraphQLOperationVariableValue] + typealias Variables = [String: any GraphQLOperationVariableValue] static var operationName: String { get } static var operationType: GraphQLOperationType { get } static var operationDocument: OperationDocument { get } - static var hasDeferredFragments: Bool { get } + + static var deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]? { get } var __variables: Variables? { get } associatedtype Data: RootSelectionSet } +// MARK: Static Extensions + public extension GraphQLOperation { - var __variables: Variables? { + static var deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]? { return nil } - /// `True` if any selection set, or nested selection set, within the operation contains any - /// fragment marked with the `@defer` directive. + static func deferredSelectionSetType( + for operation: T.Type, + withLabel label: String, + atFieldPath fieldPath: [String] + ) -> (any SelectionSet.Type)? { + return T.deferredFragments?[DeferredFragmentIdentifier(label: label, fieldPath: fieldPath)] + } + static var hasDeferredFragments: Bool { - false + return !(deferredFragments?.isEmpty ?? true) } static var definition: OperationDefinition? { @@ -88,22 +111,36 @@ public extension GraphQLOperation { static func ==(lhs: Self, rhs: Self) -> Bool { lhs.__variables?._jsonEncodableValue?._jsonValue == rhs.__variables?._jsonEncodableValue?._jsonValue } +} + +// MARK: Instance Extensions + +public extension GraphQLOperation { + var __variables: Variables? { + return nil + } func hash(into hasher: inout Hasher) { hasher.combine(__variables?._jsonEncodableValue?._jsonValue) } } +// MARK: - GraphQLQuery + public protocol GraphQLQuery: GraphQLOperation {} public extension GraphQLQuery { @inlinable static var operationType: GraphQLOperationType { return .query } } +// MARK: - GraphQLMutation + public protocol GraphQLMutation: GraphQLOperation {} public extension GraphQLMutation { @inlinable static var operationType: GraphQLOperationType { return .mutation } } +// MARK: - GraphQLSubscription + public protocol GraphQLSubscription: GraphQLOperation {} public extension GraphQLSubscription { @inlinable static var operationType: GraphQLOperationType { return .subscription } @@ -116,10 +153,10 @@ public protocol GraphQLOperationVariableValue { } extension Array: GraphQLOperationVariableValue -where Element: GraphQLOperationVariableValue & Hashable {} +where Element: GraphQLOperationVariableValue & JSONEncodable & Hashable {} extension Dictionary: GraphQLOperationVariableValue -where Key == String, Value == GraphQLOperationVariableValue { +where Key == String, Value == any GraphQLOperationVariableValue { @inlinable public var _jsonEncodableValue: (any JSONEncodable)? { _jsonEncodableObject } @inlinable public var _jsonEncodableObject: JSONEncodableDictionary { compactMapValues { $0._jsonEncodableValue } diff --git a/Sources/ApolloAPI/JSONStandardTypeConversions.swift b/Sources/ApolloAPI/JSONStandardTypeConversions.swift index 6805b4ca3c..39079643e2 100644 --- a/Sources/ApolloAPI/JSONStandardTypeConversions.swift +++ b/Sources/ApolloAPI/JSONStandardTypeConversions.swift @@ -147,14 +147,8 @@ extension JSONObject: JSONDecodable { } } -extension Array: JSONEncodable { +extension Array: JSONEncodable where Element: JSONEncodable { @inlinable public var _jsonValue: JSONValue { - return map { element -> JSONValue in - if case let element as JSONEncodable = element { - return element._jsonValue - } else { - fatalError("Array is only JSONEncodable if Element is") - } - } + map(\._jsonValue) } } diff --git a/Sources/ApolloAPI/LocalCacheMutation.swift b/Sources/ApolloAPI/LocalCacheMutation.swift index f09fed6c8a..91dccd631e 100644 --- a/Sources/ApolloAPI/LocalCacheMutation.swift +++ b/Sources/ApolloAPI/LocalCacheMutation.swift @@ -39,8 +39,6 @@ public extension MutableSelectionSet where Fragments: FragmentContainer { yield &f self.__data._data = f.__data._data } - @available(*, unavailable, message: "mutate properties of the fragment instead.") - set { preconditionFailure("") } } } diff --git a/Sources/ApolloAPI/ObjectData.swift b/Sources/ApolloAPI/ObjectData.swift index 17f0a81e23..b057b92668 100644 --- a/Sources/ApolloAPI/ObjectData.swift +++ b/Sources/ApolloAPI/ObjectData.swift @@ -8,11 +8,11 @@ public protocol _ObjectData_Transformer { /// sources, using a `_transformer` to ensure the raw data from different sources (which may be in /// different formats) can be consumed with a consistent API. public struct ObjectData { - public let _transformer: _ObjectData_Transformer + public let _transformer: any _ObjectData_Transformer public let _rawData: [String: AnyHashable] public init( - _transformer: _ObjectData_Transformer, + _transformer: any _ObjectData_Transformer, _rawData: [String: AnyHashable] ) { self._transformer = _transformer @@ -22,18 +22,18 @@ public struct ObjectData { @inlinable public subscript(_ key: String) -> (any ScalarType)? { guard let rawValue = _rawData[key] else { return nil } var value: AnyHashable = rawValue - - // Attempting cast to `Int` to ensure we always use `Int` vs `Int32` or `Int64` for consistency and ScalarType casting, - // also need to attempt `Bool` cast first to ensure a bool doesn't get inadvertently converted to `Int` - switch value { - case let boolVal as Bool: + + // This check is based on AnyHashable using a canonical representation of the type-erased value so + // instances wrapping the same value of any type compare as equal. Therefore while Int(1) and Int(0) + // might be representable as Bool they will never equal Bool(true) nor Bool(false). + if let boolVal = value as? Bool, value.isCanonicalBool { value = boolVal - case let intVal as Int: - value = intVal - default: - break + + // Cast to `Int` to ensure we always use `Int` vs `Int32` or `Int64` for consistency and ScalarType casting + } else if let intValue = value as? Int { + value = intValue } - + return _transformer.transform(value) } @@ -55,11 +55,11 @@ public struct ObjectData { /// This type wraps data from different sources, using a `_transformer` to ensure the raw data from /// different sources (which may be in different formats) can be consumed with a consistent API. public struct ListData { - public let _transformer: _ObjectData_Transformer + public let _transformer: any _ObjectData_Transformer public let _rawData: [AnyHashable] public init( - _transformer: _ObjectData_Transformer, + _transformer: any _ObjectData_Transformer, _rawData: [AnyHashable] ) { self._transformer = _transformer @@ -69,17 +69,17 @@ public struct ListData { @inlinable public subscript(_ key: Int) -> (any ScalarType)? { var value: AnyHashable = _rawData[key] - // Attempting cast to `Int` to ensure we always use `Int` vs `Int32` or `Int64` for consistency and ScalarType casting, - // also need to attempt `Bool` cast first to ensure a bool doesn't get inadvertently converted to `Int` - switch value { - case let boolVal as Bool: + // This check is based on AnyHashable using a canonical representation of the type-erased value so + // instances wrapping the same value of any type compare as equal. Therefore while Int(1) and Int(0) + // might be representable as Bool they will never equal Bool(true) nor Bool(false). + if let boolVal = value as? Bool, value.isCanonicalBool { value = boolVal - case let intVal as Int: - value = intVal - default: - break + + // Cast to `Int` to ensure we always use `Int` vs `Int32` or `Int64` for consistency and ScalarType casting + } else if let intValue = value as? Int { + value = intValue } - + return _transformer.transform(value) } @@ -93,3 +93,12 @@ public struct ListData { return _transformer.transform(_rawData[key]) } } + +extension AnyHashable { + fileprivate static let boolTrue = AnyHashable(true) + fileprivate static let boolFalse = AnyHashable(false) + + @usableFromInline var isCanonicalBool: Bool { + self == Self.boolTrue || self == Self.boolFalse + } +} diff --git a/Sources/ApolloAPI/SchemaMetadata.swift b/Sources/ApolloAPI/SchemaMetadata.swift index bc27667bff..39ace54fb1 100644 --- a/Sources/ApolloAPI/SchemaMetadata.swift +++ b/Sources/ApolloAPI/SchemaMetadata.swift @@ -6,7 +6,7 @@ public protocol SchemaMetadata { /// A ``SchemaConfiguration`` that provides custom configuration for the generated GraphQL schema. - static var configuration: SchemaConfiguration.Type { get } + static var configuration: any SchemaConfiguration.Type { get } /// Maps each object in a `GraphQLResponse` to the ``Object`` type representing the /// response object. diff --git a/Sources/ApolloAPI/SchemaTypes/InputObject.swift b/Sources/ApolloAPI/SchemaTypes/InputObject.swift index cb8389dfe9..36c6f477dc 100644 --- a/Sources/ApolloAPI/SchemaTypes/InputObject.swift +++ b/Sources/ApolloAPI/SchemaTypes/InputObject.swift @@ -22,9 +22,9 @@ extension InputObject { /// A structure that wraps the underlying data dictionary used by `InputObject`s. public struct InputDict: GraphQLOperationVariableValue, Hashable { - private var data: [String: GraphQLOperationVariableValue] + private var data: [String: any GraphQLOperationVariableValue] - public init(_ data: [String: GraphQLOperationVariableValue] = [:]) { + public init(_ data: [String: any GraphQLOperationVariableValue] = [:]) { self.data = data } diff --git a/Sources/ApolloAPI/Selection+Conditions.swift b/Sources/ApolloAPI/Selection+Conditions.swift index f19756c22e..63eff2bf29 100644 --- a/Sources/ApolloAPI/Selection+Conditions.swift +++ b/Sources/ApolloAPI/Selection+Conditions.swift @@ -114,7 +114,7 @@ fileprivate extension Array where Element == Selection.Condition { } // MARK: Conditions - Individual -fileprivate extension Selection.Condition { +public extension Selection.Condition { func evaluate(with variables: GraphQLOperation.Variables?) -> Bool { switch self { case let .value(value): diff --git a/Sources/ApolloAPI/Selection.swift b/Sources/ApolloAPI/Selection.swift index 46c62c4e37..f8cc3390cc 100644 --- a/Sources/ApolloAPI/Selection.swift +++ b/Sources/ApolloAPI/Selection.swift @@ -6,7 +6,7 @@ public enum Selection { /// 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?) + case deferred(if: Condition? = nil, any Deferrable.Type, label: String) /// A group of selections that have `@include/@skip` directives. case conditional(Conditions, [Selection]) @@ -60,7 +60,7 @@ public enum Selection { @inlinable static public func field( _ name: String, alias: String? = nil, - _ type: OutputTypeConvertible.Type, + _ type: any OutputTypeConvertible.Type, arguments: [String: InputValue]? = nil ) -> Selection { .field(.init(name, alias: alias, type: type._asOutputType, arguments: arguments)) diff --git a/Sources/ApolloAPI/SelectionSet.swift b/Sources/ApolloAPI/SelectionSet.swift index d8ceca55da..c64f2b6d5f 100644 --- a/Sources/ApolloAPI/SelectionSet.swift +++ b/Sources/ApolloAPI/SelectionSet.swift @@ -25,7 +25,7 @@ public protocol RootSelectionSet: SelectionSet, SelectionSetEntityValue, OutputT /// from the fragment's parent `RootSelectionSet` that will be selected. This includes fields from /// the parent selection set, as well as any other child selections sets that are compatible with /// the `InlineFragment`'s `__parentType` and the operation's inclusion condition. -public protocol InlineFragment: SelectionSet { +public protocol InlineFragment: SelectionSet, Deferrable { associatedtype RootEntityType: RootSelectionSet } @@ -46,7 +46,7 @@ public protocol CompositeInlineFragment: CompositeSelectionSet, InlineFragment { } // MARK: - SelectionSet -public protocol SelectionSet: Hashable { +public protocol SelectionSet: Hashable, CustomDebugStringConvertible { associatedtype Schema: SchemaMetadata /// A type representing all of the fragments the `SelectionSet` can be converted to. @@ -59,7 +59,7 @@ public protocol SelectionSet: Hashable { /// The GraphQL type for the `SelectionSet`. /// /// This may be a concrete type (`Object`) or an abstract type (`Interface`, or `Union`). - static var __parentType: ParentType { get } + static var __parentType: any ParentType { get } /// The data of the underlying GraphQL object represented by the generated selection set. var __data: DataDict { get } @@ -117,6 +117,10 @@ extension SelectionSet { @inlinable public static func ==(lhs: Self, rhs: Self) -> Bool { return lhs.__data == rhs.__data } + + public var debugDescription: String { + return "\(self.__data._data as AnyObject)" + } } extension SelectionSet where Fragments: FragmentContainer { diff --git a/Sources/ApolloSQLite/JournalMode.swift b/Sources/ApolloSQLite/JournalMode.swift new file mode 100644 index 0000000000..b94ff19ad6 --- /dev/null +++ b/Sources/ApolloSQLite/JournalMode.swift @@ -0,0 +1,20 @@ +import Foundation + +public enum JournalMode: String { + /// The rollback journal is deleted at the conclusion of each transaction. This is the default behaviour. + case delete = "DELETE" + /// Commits transactions by truncating the rollback journal to zero-length instead of deleting it. + case truncate = "TRUNCATE" + /// Prevents the rollback journal from being deleted at the end of each transaction. Instead, the header + /// of the journal is overwritten with zeros. + case persist = "PERSIST" + /// Stores the rollback journal in volatile RAM. This saves disk I/O but at the expense of database + /// safety and integrity. + case memory = "MEMORY" + /// Uses a write-ahead log instead of a rollback journal to implement transactions. The WAL journaling + /// mode is persistent; after being set it stays in effect across multiple database connections and after + /// closing and reopening the database. + case wal = "WAL" + /// Disables the rollback journal completely + case off = "OFF" +} diff --git a/Sources/ApolloSQLite/SQLiteDatabase.swift b/Sources/ApolloSQLite/SQLiteDatabase.swift index 82c0f82d2a..05fb2f76bb 100644 --- a/Sources/ApolloSQLite/SQLiteDatabase.swift +++ b/Sources/ApolloSQLite/SQLiteDatabase.swift @@ -10,15 +10,15 @@ public struct DatabaseRow { } public protocol SQLiteDatabase { - + init(fileURL: URL) throws func createRecordsTableIfNeeded() throws func selectRawRows(forKeys keys: Set) throws -> [DatabaseRow] - func addOrUpdateRecordString(_ recordString: String, for cacheKey: CacheKey) throws - + func addOrUpdate(records: [(cacheKey: CacheKey, recordString: String)]) throws + func deleteRecord(for cacheKey: CacheKey) throws func deleteRecords(matching pattern: CacheKey) throws @@ -31,6 +31,17 @@ public protocol SQLiteDatabase { func readSchemaVersion() throws -> Int64? + @available(*, deprecated, renamed: "addOrUpdate(records:)") + func addOrUpdateRecordString(_ recordString: String, for cacheKey: CacheKey) throws + +} + +extension SQLiteDatabase { + + public func addOrUpdateRecordString(_ recordString: String, for cacheKey: CacheKey) throws { + try addOrUpdate(records: [(cacheKey, recordString)]) + } + } public extension SQLiteDatabase { diff --git a/Sources/ApolloSQLite/SQLiteDotSwiftDatabase.swift b/Sources/ApolloSQLite/SQLiteDotSwiftDatabase.swift index d48ac3753d..96bb41b6dc 100644 --- a/Sources/ApolloSQLite/SQLiteDotSwiftDatabase.swift +++ b/Sources/ApolloSQLite/SQLiteDotSwiftDatabase.swift @@ -9,42 +9,42 @@ public final class SQLiteDotSwiftDatabase: SQLiteDatabase { private var db: Connection! private let records: Table - private let keyColumn: Expression - private let recordColumn: Expression + private let keyColumn: SQLite.Expression + private let recordColumn: SQLite.Expression - private var lastReceivedAt = Expression("lastReceivedAt") - private let version = Expression("version") + private var lastReceivedAt = SQLite.Expression("lastReceivedAt") + private let version = SQLite.Expression("version") public init(fileURL: URL) throws { self.records = Table(Self.tableName) - self.keyColumn = Expression(Self.keyColumnName) - self.recordColumn = Expression(Self.recordColumName) + self.keyColumn = SQLite.Expression(Self.keyColumnName) + self.recordColumn = SQLite.Expression(Self.recordColumName) self.db = try Connection(.uri(fileURL.absoluteString), readonly: false) } public init(connection: Connection) { self.records = Table(Self.tableName) - self.keyColumn = Expression(Self.keyColumnName) - self.recordColumn = Expression(Self.recordColumName) + self.keyColumn = SQLite.Expression(Self.keyColumnName) + self.recordColumn = SQLite.Expression(Self.recordColumName) self.db = connection } public func createRecordsTableIfNeeded() throws { try self.db.run(self.records.create(ifNotExists: true) { table in - table.column(Expression(Self.idColumnName), primaryKey: .autoincrement) + table.column(SQLite.Expression(Self.idColumnName), primaryKey: .autoincrement) table.column(keyColumn, unique: true) - table.column(Expression(Self.recordColumName)) + table.column(SQLite.Expression(Self.recordColumName)) }) try self.db.run(self.records.createIndex(keyColumn, unique: true, ifNotExists: true)) } public func setUpDatabase() throws { try self.db.run(self.records.create(ifNotExists: true) { table in - table.column(Expression(Self.idColumnName), primaryKey: .autoincrement) + table.column(SQLite.Expression(Self.idColumnName), primaryKey: .autoincrement) table.column(keyColumn, unique: true) - table.column(Expression(Self.recordColumName)) + table.column(SQLite.Expression(Self.recordColumName)) }) - try self.db.run(self.records.createIndex(Expression(Self.idColumnName), unique: true, ifNotExists: true)) + try self.db.run(self.records.createIndex(SQLite.Expression(Self.idColumnName), unique: true, ifNotExists: true)) try self.runSchemaMigrationsIfNeeded() } @@ -59,11 +59,17 @@ public final class SQLiteDotSwiftDatabase: SQLiteDatabase { return DatabaseRow(cacheKey: key, storedInfo: record, lastReceivedAt: lastReceivedAt) } } - - public func addOrUpdateRecordString(_ recordString: String, for cacheKey: CacheKey) throws { - try self.db.run(self.records.insert(or: .replace, self.keyColumn <- cacheKey, self.recordColumn <- recordString)) + + public func addOrUpdate(records: [(cacheKey: CacheKey, recordString: String)]) throws { + guard !records.isEmpty else { return } + + let setters = records.map { + [self.keyColumn <- $0.cacheKey, self.recordColumn <- $0.recordString] + } + + try self.db.run(self.records.insertMany(or: .replace, setters)) } - + public func deleteRecord(for cacheKey: CacheKey) throws { let query = self.records.filter(keyColumn == cacheKey) try self.db.run(query.delete()) @@ -116,7 +122,7 @@ public final class SQLiteDotSwiftDatabase: SQLiteDatabase { try self.db.run(self.records.insert( or: .replace, self.keyColumn <- key, - Expression(Self.recordColumName) <- serializedRecord, + SQLite.Expression(Self.recordColumName) <- serializedRecord, self.lastReceivedAt <- lastReceivedAt )) } @@ -125,4 +131,10 @@ public final class SQLiteDotSwiftDatabase: SQLiteDatabase { extension SQLiteDotSwiftDatabase { private static var schemaVersion: Int64 { 1 } + /// Sets the journal mode for the current database. + /// + /// - Parameter mode: The journal mode controls how the journal file is stored and processed. + public func setJournalMode(mode: JournalMode) throws { + try self.db.run("PRAGMA journal_mode = \(mode.rawValue)") + } } diff --git a/Sources/ApolloSQLite/SQLiteNormalizedCache.swift b/Sources/ApolloSQLite/SQLiteNormalizedCache.swift index 114d43ab4b..14826ae576 100644 --- a/Sources/ApolloSQLite/SQLiteNormalizedCache.swift +++ b/Sources/ApolloSQLite/SQLiteNormalizedCache.swift @@ -13,7 +13,7 @@ public final class SQLiteNormalizedCache { private let shouldVacuumOnClear: Bool - let database: SQLiteDatabase + let database: any SQLiteDatabase /// Designated initializer /// @@ -22,16 +22,16 @@ public final class SQLiteNormalizedCache { /// - shouldVacuumOnClear: If the database should also be `VACCUM`ed on clear to remove all traces of info. Defaults to `false` since this involves a performance hit, but this should be used if you are storing any Personally Identifiable Information in the cache. /// - initialRecords: A set of records to initialize the database with. /// - Throws: Any errors attempting to open or create the database. - - convenience public init(fileURL: URL, databaseType: SQLiteDatabase.Type = SQLiteDotSwiftDatabase.self, shouldVacuumOnClear: Bool = false, initialRecords: RecordSet? = nil) throws { - try self.init(database: databaseType.init(fileURL: fileURL), - shouldVacuumOnClear: shouldVacuumOnClear, - initialRecords: initialRecords - ) + public init(fileURL: URL, + databaseType: any SQLiteDatabase.Type = SQLiteDotSwiftDatabase.self, + shouldVacuumOnClear: Bool = false) throws { + self.database = try databaseType.init(fileURL: fileURL) + self.shouldVacuumOnClear = shouldVacuumOnClear + try self.database.createRecordsTableIfNeeded() } - public init(database: SQLiteDatabase, - shouldVacuumOnClear: Bool = false, initialRecords: RecordSet? = nil) throws { + public init(database: any SQLiteDatabase, + shouldVacuumOnClear: Bool = false) throws { self.database = database self.shouldVacuumOnClear = shouldVacuumOnClear try self.database.setUpDatabase() @@ -74,12 +74,22 @@ public final class SQLiteNormalizedCache { var recordSet = RecordSet(rows: try self.selectRows(for: records.keys)) let changedFieldKeys = recordSet.merge(records: records) let changedRecordKeys = changedFieldKeys.map { self.recordCacheKey(forFieldCacheKey: $0) } - try changedRecordKeys.forEach { recordKey in - guard let serializedRecord = try recordSet[recordKey]?.record.serialized() else { return } - try self.database.insert(recordKey, row: recordSet[recordKey], serializedRecord: serializedRecord) - } - return changedFieldKeys + let serializedRecords = try Set(changedRecordKeys) + .compactMap { recordKey -> (CacheKey, String)? in + if let recordFields = recordSet[recordKey]?.fields { + let recordData = try SQLiteSerialization.serialize(fields: recordFields) + guard let recordString = String(data: recordData, encoding: .utf8) else { + assertionFailure("Serialization should yield UTF-8 data") + return nil + } + return (recordKey, recordString) + } + return nil + } + + try self.database.addOrUpdate(records: serializedRecords) + return Set(changedFieldKeys) } fileprivate func selectRows(for keys: Set) throws -> [RecordRow] { diff --git a/Sources/ApolloTestSupport/TestMock.swift b/Sources/ApolloTestSupport/TestMock.swift index 8ca2db9819..3826efd9c5 100644 --- a/Sources/ApolloTestSupport/TestMock.swift +++ b/Sources/ApolloTestSupport/TestMock.swift @@ -1,7 +1,7 @@ #if !COCOAPODS -@_exported @testable import ApolloAPI +@_exported import ApolloAPI #endif -@testable import Apollo +@_spi(Execution) import Apollo import Foundation @dynamicMemberLookup @@ -97,7 +97,7 @@ public class Mock: AnyMock, Hashable { public var _selectionSetMockData: JSONObject { _data.mapValues { - if let mock = $0.base as? AnyMock { + if let mock = $0.base as? (any AnyMock) { return mock._selectionSetMockData } if let mockArray = $0 as? Array { @@ -157,11 +157,11 @@ public protocol MockFieldValue { } extension Interface: MockFieldValue { - public typealias MockValueCollectionType = Array + public typealias MockValueCollectionType = Array } extension Union: MockFieldValue { - public typealias MockValueCollectionType = Array + public typealias MockValueCollectionType = Array } extension Optional: MockFieldValue where Wrapped: MockFieldValue { @@ -194,7 +194,7 @@ fileprivate extension Array { private func _unsafelyConvertToSelectionSetData(element: Any) -> AnyHashable? { switch element { - case let element as AnyMock: + case let element as any AnyMock: return element._selectionSetMockData case let innerArray as Array: diff --git a/Sources/ApolloTestSupport/TestMockSelectionSetMapper.swift b/Sources/ApolloTestSupport/TestMockSelectionSetMapper.swift index 81e67dd720..756baa613b 100644 --- a/Sources/ApolloTestSupport/TestMockSelectionSetMapper.swift +++ b/Sources/ApolloTestSupport/TestMockSelectionSetMapper.swift @@ -1,4 +1,4 @@ -@testable import Apollo +@_spi(Execution) import Apollo import Foundation /// An accumulator that converts data from a `Mock` to the correct values to create a `SelectionSet`. diff --git a/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift b/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift index 9e4c182c9a..38e87a300a 100644 --- a/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift +++ b/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift @@ -132,13 +132,13 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock /// Responds to callback about new messages coming in over the WebSocket /// and also connection/disconnect messages. - public weak var delegate: WebSocketClientDelegate? + public weak var delegate: (any WebSocketClientDelegate)? // Where the callback is executed. It defaults to the main UI thread queue. public var callbackQueue = DispatchQueue.main public var onConnect: (() -> Void)? - public var onDisconnect: ((Error?) -> Void)? + public var onDisconnect: (((any Error)?) -> Void)? public var onText: ((String) -> Void)? public var onData: ((Data) -> Void)? public var onPong: ((Data?) -> Void)? @@ -152,7 +152,7 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock public var enableCompression = true #if os(Linux) #else - public var security: SSLTrustValidator? + public var security: (any SSLTrustValidator)? public var enabledSSLCipherSuites: [SSLCipherSuite]? #endif @@ -172,7 +172,7 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock /// Note: Will return `false` from the getter and no-op the setter for implementations that do not conform to `SOCKSProxyable`. public var enableSOCKSProxy: Bool { get { - guard let stream = stream as? SOCKSProxyable else { + guard let stream = stream as? (any SOCKSProxyable) else { // If it's not proxyable, then the proxy can't be enabled return false } @@ -181,7 +181,7 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock } set { - guard var stream = stream as? SOCKSProxyable else { + guard var stream = stream as? (any SOCKSProxyable) else { // If it's not proxyable, there's nothing to do here. return } @@ -203,7 +203,7 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock var compressor:Compressor? = nil } - private var stream: WebSocketStream + private var stream: any WebSocketStream private var connected = false private var isConnecting = false private let mutex = NSLock() @@ -534,14 +534,14 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock processInputStream() } - public func streamDidError(error: Error?) { + public func streamDidError(error: (any Error)?) { disconnectStream(error) } /** Disconnect the stream object and notifies the delegate. */ - private func disconnectStream(_ error: Error?, runDelegate: Bool = true) { + private func disconnectStream(_ error: (any Error)?, runDelegate: Bool = true) { if error == nil { writeQueue.waitUntilAllOperationsAreFinished() } else { @@ -1105,7 +1105,7 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock /** Used to preform the disconnect delegate */ - private func doDisconnect(_ error: Error?) { + private func doDisconnect(_ error: (any Error)?) { guard !didDisconnect else { return } didDisconnect = true isConnecting = false diff --git a/Sources/ApolloWebSocket/DefaultImplementation/WebSocketStream.swift b/Sources/ApolloWebSocket/DefaultImplementation/WebSocketStream.swift index 88f978a28e..95047f5e70 100644 --- a/Sources/ApolloWebSocket/DefaultImplementation/WebSocketStream.swift +++ b/Sources/ApolloWebSocket/DefaultImplementation/WebSocketStream.swift @@ -12,7 +12,7 @@ import Foundation protocol WebSocketStreamDelegate: AnyObject { func newBytesInStream() - func streamDidError(error: Error?) + func streamDidError(error: (any Error)?) } public protocol SOCKSProxyable { @@ -24,13 +24,13 @@ public protocol SOCKSProxyable { // This protocol is to allow custom implemention of the underlining stream. // This way custom socket libraries (e.g. linux) can be used protocol WebSocketStream { - var delegate: WebSocketStreamDelegate? { get set } + var delegate: (any WebSocketStreamDelegate)? { get set } func connect(url: URL, port: Int, timeout: TimeInterval, ssl: SSLSettings, - completion: @escaping ((Error?) -> Void)) + completion: @escaping (((any Error)?) -> Void)) func write(data: Data) -> Int func read() -> Data? @@ -46,12 +46,12 @@ class FoundationStream : NSObject, WebSocketStream, StreamDelegate, SOCKSProxyab private let workQueue = DispatchQueue(label: "com.apollographql.websocket", attributes: []) private var inputStream: InputStream? private var outputStream: OutputStream? - weak var delegate: WebSocketStreamDelegate? + weak var delegate: (any WebSocketStreamDelegate)? let BUFFER_MAX = 4096 var enableSOCKSProxy = false - func connect(url: URL, port: Int, timeout: TimeInterval, ssl: SSLSettings, completion: @escaping ((Error?) -> Void)) { + func connect(url: URL, port: Int, timeout: TimeInterval, ssl: SSLSettings, completion: @escaping (((any Error)?) -> Void)) { var readStream: Unmanaged? var writeStream: Unmanaged? let h = url.host! as NSString diff --git a/Sources/ApolloWebSocket/OperationMessage.swift b/Sources/ApolloWebSocket/OperationMessage.swift index 001b091c0f..a2d369ff2d 100644 --- a/Sources/ApolloWebSocket/OperationMessage.swift +++ b/Sources/ApolloWebSocket/OperationMessage.swift @@ -115,12 +115,12 @@ struct ParseHandler { let type: String? let id: String? let payload: JSONObject? - let error: Error? + let error: (any Error)? init(_ type: String?, _ id: String?, _ payload: JSONObject?, - _ error: Error?) { + _ error: (any Error)?) { self.type = type self.id = id self.payload = payload diff --git a/Sources/ApolloWebSocket/SplitNetworkTransport.swift b/Sources/ApolloWebSocket/SplitNetworkTransport.swift index c35f362e2b..ea4c921e40 100644 --- a/Sources/ApolloWebSocket/SplitNetworkTransport.swift +++ b/Sources/ApolloWebSocket/SplitNetworkTransport.swift @@ -6,8 +6,8 @@ import ApolloAPI /// A network transport that sends subscriptions using one `NetworkTransport` and other requests using another `NetworkTransport`. Ideal for sending subscriptions via a web socket but everything else via HTTP. public class SplitNetworkTransport { - private let uploadingNetworkTransport: UploadingNetworkTransport - private let webSocketNetworkTransport: NetworkTransport + private let uploadingNetworkTransport: any UploadingNetworkTransport + private let webSocketNetworkTransport: any NetworkTransport public var clientName: String { let httpName = self.uploadingNetworkTransport.clientName @@ -34,7 +34,7 @@ public class SplitNetworkTransport { /// - Parameters: /// - uploadingNetworkTransport: An `UploadingNetworkTransport` to use for non-subscription requests. Should generally be a `RequestChainNetworkTransport` or something similar. /// - webSocketNetworkTransport: A `NetworkTransport` to use for subscription requests. Should generally be a `WebSocketTransport` or something similar. - public init(uploadingNetworkTransport: UploadingNetworkTransport, webSocketNetworkTransport: NetworkTransport) { + public init(uploadingNetworkTransport: any UploadingNetworkTransport, webSocketNetworkTransport: any NetworkTransport) { self.uploadingNetworkTransport = uploadingNetworkTransport self.webSocketNetworkTransport = webSocketNetworkTransport } @@ -47,9 +47,9 @@ extension SplitNetworkTransport: NetworkTransport { public func send(operation: Operation, cachePolicy: CachePolicy, contextIdentifier: UUID? = nil, - context: RequestContext? = nil, + context: (any RequestContext)? = nil, callbackQueue: DispatchQueue = .main, - completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + completionHandler: @escaping (Result, any Error>) -> Void) -> any Cancellable { if Operation.operationType == .subscription { return webSocketNetworkTransport.send(operation: operation, cachePolicy: cachePolicy, @@ -75,9 +75,9 @@ extension SplitNetworkTransport: UploadingNetworkTransport { public func upload( operation: Operation, files: [GraphQLFile], - context: RequestContext?, + context: (any RequestContext)?, callbackQueue: DispatchQueue = .main, - completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + completionHandler: @escaping (Result, any Error>) -> Void) -> any Cancellable { return uploadingNetworkTransport.upload(operation: operation, files: files, context: context, diff --git a/Sources/ApolloWebSocket/WebSocketClient.swift b/Sources/ApolloWebSocket/WebSocketClient.swift index 91b0e887c9..8f73fe568d 100644 --- a/Sources/ApolloWebSocket/WebSocketClient.swift +++ b/Sources/ApolloWebSocket/WebSocketClient.swift @@ -12,7 +12,7 @@ public protocol WebSocketClient: AnyObject { /// /// - Note: The `WebSocketTransport` will set itself as the delgate for the client. Consumers /// should set themselves as the delegate for the `WebSocketTransport` to observe events. - var delegate: WebSocketClientDelegate? { get set } + var delegate: (any WebSocketClientDelegate)? { get set } /// `DispatchQueue` where the websocket client should call all delegate callbacks. var callbackQueue: DispatchQueue { get set } @@ -38,23 +38,23 @@ public protocol WebSocketClientDelegate: AnyObject { /// The websocket client has started a connection to the server. /// - Parameter socket: The `WebSocketClient` that sent the delegate event. - func websocketDidConnect(socket: WebSocketClient) + func websocketDidConnect(socket: any WebSocketClient) /// The websocket client has disconnected from the server. /// - Parameters: /// - socket: The `WebSocketClient` that sent the delegate event. /// - error: An optional error if an error occured. - func websocketDidDisconnect(socket: WebSocketClient, error: Error?) + func websocketDidDisconnect(socket: any WebSocketClient, error: (any Error)?) /// The websocket client received message text from the server /// - Parameters: /// - socket: The `WebSocketClient` that sent the delegate event. /// - text: The text received from the server. - func websocketDidReceiveMessage(socket: WebSocketClient, text: String) + func websocketDidReceiveMessage(socket: any WebSocketClient, text: String) /// The websocket client received data from the server /// - Parameters: /// - socket: The `WebSocketClient` that sent the delegate event. /// - data: The data received from the server. - func websocketDidReceiveData(socket: WebSocketClient, data: Data) + func websocketDidReceiveData(socket: any WebSocketClient, data: Data) } diff --git a/Sources/ApolloWebSocket/WebSocketError.swift b/Sources/ApolloWebSocket/WebSocketError.swift index f9655ecaa6..a314e80fa0 100644 --- a/Sources/ApolloWebSocket/WebSocketError.swift +++ b/Sources/ApolloWebSocket/WebSocketError.swift @@ -35,7 +35,7 @@ public struct WebSocketError: Error, LocalizedError { public let payload: JSONObject? /// The underlying error, or nil if one was not returned - public let error: Error? + public let error: (any Error)? /// The kind of problem which occurred. public let kind: ErrorKind diff --git a/Sources/ApolloWebSocket/WebSocketTask.swift b/Sources/ApolloWebSocket/WebSocketTask.swift index b2557fda5f..01369803a5 100644 --- a/Sources/ApolloWebSocket/WebSocketTask.swift +++ b/Sources/ApolloWebSocket/WebSocketTask.swift @@ -16,7 +16,7 @@ final class WebSocketTask: Cancellable { /// - Parameter completionHandler: A completion handler to fire when the operation has a result. init(_ ws: WebSocketTransport, _ operation: Operation, - _ completionHandler: @escaping (_ result: Result) -> Void) { + _ completionHandler: @escaping (_ result: Result) -> Void) { sequenceNumber = ws.sendHelper(operation: operation, resultHandler: completionHandler) transport = ws } diff --git a/Sources/ApolloWebSocket/WebSocketTransport.swift b/Sources/ApolloWebSocket/WebSocketTransport.swift index 91d67c51df..9405ae153b 100644 --- a/Sources/ApolloWebSocket/WebSocketTransport.swift +++ b/Sources/ApolloWebSocket/WebSocketTransport.swift @@ -9,13 +9,13 @@ import Foundation public protocol WebSocketTransportDelegate: AnyObject { func webSocketTransportDidConnect(_ webSocketTransport: WebSocketTransport) func webSocketTransportDidReconnect(_ webSocketTransport: WebSocketTransport) - func webSocketTransport(_ webSocketTransport: WebSocketTransport, didDisconnectWithError error:Error?) + func webSocketTransport(_ webSocketTransport: WebSocketTransport, didDisconnectWithError error:(any Error)?) } public extension WebSocketTransportDelegate { func webSocketTransportDidConnect(_ webSocketTransport: WebSocketTransport) {} func webSocketTransportDidReconnect(_ webSocketTransport: WebSocketTransport) {} - func webSocketTransport(_ webSocketTransport: WebSocketTransport, didDisconnectWithError error:Error?) {} + func webSocketTransport(_ webSocketTransport: WebSocketTransport, didDisconnectWithError error:(any Error)?) {} func webSocketTransport(_ webSocketTransport: WebSocketTransport, didReceivePingData: Data?) {} func webSocketTransport(_ webSocketTransport: WebSocketTransport, didReceivePongData: Data?) {} } @@ -24,12 +24,12 @@ public extension WebSocketTransportDelegate { /// A network transport that uses web sockets requests to send GraphQL subscription operations to a server. public class WebSocketTransport { - public weak var delegate: WebSocketTransportDelegate? + public weak var delegate: (any WebSocketTransportDelegate)? - let websocket: WebSocketClient + let websocket: any WebSocketClient let store: ApolloStore? private(set) var config: Configuration - @Atomic var error: Error? + @Atomic var error: (any Error)? let serializationFormat = JSONSerializationFormat.self /// non-private for testing - you should not use this directly @@ -49,7 +49,7 @@ public class WebSocketTransport { private var queue: [Int: String] = [:] - private var subscribers = [String: (Result) -> Void]() + private var subscribers = [String: (Result) -> Void]() private var subscriptions : [String: String] = [:] let processingQueue = DispatchQueue(label: "com.apollographql.WebSocketTransport") @@ -96,10 +96,10 @@ public class WebSocketTransport { /// [optional]The payload to send on connection. Defaults to an empty `JSONEncodableDictionary`. public fileprivate(set) var connectingPayload: JSONEncodableDictionary? /// The `RequestBodyCreator` to use when serializing requests. Defaults to an `ApolloRequestBodyCreator`. - public let requestBodyCreator: RequestBodyCreator + public let requestBodyCreator: any RequestBodyCreator /// The `OperationMessageIdCreator` used to generate a unique message identifier per request. /// Defaults to `ApolloSequencedOperationMessageIdCreator`. - public let operationMessageIdCreator: OperationMessageIdCreator + public let operationMessageIdCreator: any OperationMessageIdCreator /// The designated initializer public init( @@ -110,8 +110,8 @@ public class WebSocketTransport { allowSendingDuplicates: Bool = true, connectOnInit: Bool = true, connectingPayload: JSONEncodableDictionary? = [:], - requestBodyCreator: RequestBodyCreator = ApolloRequestBodyCreator(), - operationMessageIdCreator: OperationMessageIdCreator = ApolloSequencedOperationMessageIdCreator() + requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator(), + operationMessageIdCreator: any OperationMessageIdCreator = ApolloSequencedOperationMessageIdCreator() ) { self.clientName = clientName self.clientVersion = clientVersion @@ -130,7 +130,7 @@ public class WebSocketTransport { /// Note: Will return `false` from the getter and no-op the setter for implementations that do not conform to `SOCKSProxyable`. public var enableSOCKSProxy: Bool { get { - guard let websocket = websocket as? SOCKSProxyable else { + guard let websocket = websocket as? (any SOCKSProxyable) else { // If it's not proxyable, then the proxy can't be enabled return false } @@ -139,7 +139,7 @@ public class WebSocketTransport { } set { - guard var websocket = websocket as? SOCKSProxyable else { + guard var websocket = websocket as? (any SOCKSProxyable) else { // If it's not proxyable, there's nothing to do here. return } @@ -156,7 +156,7 @@ public class WebSocketTransport { /// - config: A `WebSocketTransport.Configuration` object with options for configuring the /// web socket connection. Defaults to a configuration with default values. public init( - websocket: WebSocketClient, + websocket: any WebSocketClient, store: ApolloStore? = nil, config: Configuration = Configuration() ) { @@ -253,7 +253,7 @@ public class WebSocketTransport { } } - private func notifyErrorAllHandlers(_ error: Error) { + private func notifyErrorAllHandlers(_ error: any Error) { for (_, handler) in subscribers { handler(.failure(error)) } @@ -323,7 +323,7 @@ public class WebSocketTransport { self.websocket.delegate = nil } - func sendHelper(operation: Operation, resultHandler: @escaping (_ result: Result) -> Void) -> String? { + func sendHelper(operation: Operation, resultHandler: @escaping (_ result: Result) -> Void) -> String? { let body = config.requestBodyCreator.requestBody(for: operation, sendQueryDocument: true, autoPersistQuery: false) @@ -438,11 +438,11 @@ extension WebSocketTransport: NetworkTransport { operation: Operation, cachePolicy: CachePolicy, contextIdentifier: UUID? = nil, - context: RequestContext? = nil, + context: (any RequestContext)? = nil, callbackQueue: DispatchQueue = .main, - completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + completionHandler: @escaping (Result, any Error>) -> Void) -> any Cancellable { - func callCompletion(with result: Result, Error>) { + func callCompletion(with result: Result, any Error>) { callbackQueue.async { completionHandler(result) } @@ -497,7 +497,7 @@ extension WebSocketTransport: NetworkTransport { extension WebSocketTransport: WebSocketClientDelegate { - public func websocketDidConnect(socket: WebSocketClient) { + public func websocketDidConnect(socket: any WebSocketClient) { self.handleConnection() } @@ -525,7 +525,7 @@ extension WebSocketTransport: WebSocketClientDelegate { self.reconnected = true } - public func websocketDidDisconnect(socket: WebSocketClient, error: Error?) { + public func websocketDidDisconnect(socket: any WebSocketClient, error: (any Error)?) { self.$socketConnectionState.mutate { $0 = .disconnected } if let error = error { handleDisconnection(with: error) @@ -536,7 +536,7 @@ extension WebSocketTransport: WebSocketClientDelegate { } } - private func handleDisconnection(with error: Error) { + private func handleDisconnection(with error: any Error) { // Set state to `.failed`, and grab its previous value. let previousState: SocketConnectionState = self.$socketConnectionState.mutate { socketConnectionState in let previousState = socketConnectionState @@ -590,11 +590,11 @@ extension WebSocketTransport: WebSocketClientDelegate { } } - public func websocketDidReceiveMessage(socket: WebSocketClient, text: String) { + public func websocketDidReceiveMessage(socket: any WebSocketClient, text: String) { self.processMessage(text: text) } - public func websocketDidReceiveData(socket: WebSocketClient, data: Data) { + public func websocketDidReceiveData(socket: any WebSocketClient, data: Data) { self.processMessage(data: data) } diff --git a/scripts/cli-version-check.sh b/scripts/cli-version-check.sh new file mode 100755 index 0000000000..6935cd0d04 --- /dev/null +++ b/scripts/cli-version-check.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +directory=$(dirname "$0") +projectDir="$directory/../CLI" + +APOLLO_VERSION=$(sh "$directory/get-version.sh") +FILE_PATH="$projectDir/apollo-ios-cli.tar.gz" +tar -xf "$FILE_PATH" +CLI_VERSION=$(./apollo-ios-cli --version) + +echo "Comparing Apollo version $APOLLO_VERSION with CLI version $CLI_VERSION" + +if [ "$APOLLO_VERSION" = "$CLI_VERSION" ]; then + echo "Success - matched!" +else + echo "Failed - mismatch!" + exit 1 +fi