diff --git a/Apollo.xcodeproj/project.pbxproj b/Apollo.xcodeproj/project.pbxproj index 3ee16d3282..8d51804890 100644 --- a/Apollo.xcodeproj/project.pbxproj +++ b/Apollo.xcodeproj/project.pbxproj @@ -19,6 +19,19 @@ 9B21FD772422C8CC00998B5C /* TestFileHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B21FD762422C8CC00998B5C /* TestFileHelper.swift */; }; 9B21FD782424305700998B5C /* ExpectedEnumWithDifferentCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B68F05F2416F80C00E97318 /* ExpectedEnumWithDifferentCases.swift */; }; 9B21FD792424305E00998B5C /* ExpectedEnumWithSanitizedCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B68F063241703B200E97318 /* ExpectedEnumWithSanitizedCases.swift */; }; + 9B260BEB245A020300562176 /* ApolloInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BEA245A020300562176 /* ApolloInterceptor.swift */; }; + 9B260BED245A021300562176 /* Parseable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BEC245A021300562176 /* Parseable.swift */; }; + 9B260BEF245A022E00562176 /* FlexibleDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BEE245A022E00562176 /* FlexibleDecoder.swift */; }; + 9B260BF1245A025400562176 /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BF0245A025400562176 /* HTTPRequest.swift */; }; + 9B260BF3245A026F00562176 /* RequestChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BF2245A026F00562176 /* RequestChain.swift */; }; + 9B260BF5245A028D00562176 /* HTTPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BF4245A028D00562176 /* HTTPResponse.swift */; }; + 9B260BF9245A030100562176 /* ResponseCodeInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BF8245A030100562176 /* ResponseCodeInterceptor.swift */; }; + 9B260BFB245A031900562176 /* NetworkFetchInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BFA245A031900562176 /* NetworkFetchInterceptor.swift */; }; + 9B260BFF245A054700562176 /* JSONRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260BFE245A054700562176 /* JSONRequest.swift */; }; + 9B260C01245A059700562176 /* CodableParsingInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260C00245A059700562176 /* CodableParsingInterceptor.swift */; }; + 9B260C04245A090600562176 /* RequestChainNetworkTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260C03245A090600562176 /* RequestChainNetworkTransport.swift */; }; + 9B260C08245A437400562176 /* InterceptorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260C07245A437400562176 /* InterceptorProvider.swift */; }; + 9B260C0A245A532500562176 /* LegacyParsingInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B260C09245A532500562176 /* LegacyParsingInterceptor.swift */; }; 9B2DFBBF24E1FA1A00ED3AE6 /* Apollo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9FC750441D2A532C00458D91 /* Apollo.framework */; }; 9B2DFBC024E1FA1A00ED3AE6 /* Apollo.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9FC750441D2A532C00458D91 /* Apollo.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 9B2DFBC724E1FA4800ED3AE6 /* UploadAPI.h in Headers */ = {isa = PBXBuildFile; fileRef = 9B2DFBC524E1FA3E00ED3AE6 /* UploadAPI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -110,6 +123,10 @@ 9B8C3FB3248DA2FE00707B13 /* URL+Apollo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B8C3FB1248DA2EA00707B13 /* URL+Apollo.swift */; }; 9B8C3FB5248DA3E000707B13 /* URLExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B8C3FB4248DA3E000707B13 /* URLExtensionsTests.swift */; }; 9B95EDC022CAA0B000702BB2 /* GETTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */; }; + 9B96500A24BE62B7003C29C0 /* RequestChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B96500824BE6201003C29C0 /* RequestChainTests.swift */; }; + 9B96500C24BE7239003C29C0 /* LegacyCacheReadInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B96500B24BE7239003C29C0 /* LegacyCacheReadInterceptor.swift */; }; + 9B9BBAF324DB39D70021C30F /* UploadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9BBAF224DB39D70021C30F /* UploadRequest.swift */; }; + 9B9BBAF524DB4F890021C30F /* AutomaticPersistedQueryInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9BBAF424DB4F890021C30F /* AutomaticPersistedQueryInterceptor.swift */; }; 9B9BBB1C24DB760B0021C30F /* UploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9BBB1A24DB75E60021C30F /* UploadTests.swift */; }; 9BA1244A22D8A8EA00BF1D24 /* JSONSerialization+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA1244922D8A8EA00BF1D24 /* JSONSerialization+Sorting.swift */; }; 9BA1245E22DE116B00BF1D24 /* Result+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA1245D22DE116B00BF1D24 /* Result+Helpers.swift */; }; @@ -127,7 +144,12 @@ 9BAEEC15234C132600808306 /* CLIExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BAEEC14234C132600808306 /* CLIExtractorTests.swift */; }; 9BAEEC17234C275600808306 /* ApolloSchemaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BAEEC16234C275600808306 /* ApolloSchemaTests.swift */; }; 9BAEEC19234C297800808306 /* ApolloCodegenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BAEEC18234C297800808306 /* ApolloCodegenTests.swift */; }; + 9BC139A424EDCA6C00876D29 /* InterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC139A224EDCA4400876D29 /* InterceptorTests.swift */; }; + 9BC139A624EDCAD900876D29 /* BlindRetryingTestInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC139A524EDCAD900876D29 /* BlindRetryingTestInterceptor.swift */; }; + 9BC139A824EDCE4F00876D29 /* RetryToCountThenSucceedInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC139A724EDCE4F00876D29 /* RetryToCountThenSucceedInterceptor.swift */; }; 9BC2D9D3233C6EF0007BD083 /* Basher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC2D9D1233C6DC0007BD083 /* Basher.swift */; }; + 9BC742AC24CFB2FF0029282C /* ApolloErrorInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC742AB24CFB2FF0029282C /* ApolloErrorInterceptor.swift */; }; + 9BC742AE24CFB6450029282C /* LegacyCacheWriteInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC742AD24CFB6450029282C /* LegacyCacheWriteInterceptor.swift */; }; 9BCF0CE023FC9CA50031D2A2 /* TestCacheProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BCF0CD923FC9CA50031D2A2 /* TestCacheProvider.swift */; }; 9BCF0CE323FC9CA50031D2A2 /* XCTAssertHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BCF0CDC23FC9CA50031D2A2 /* XCTAssertHelpers.swift */; }; 9BCF0CE423FC9CA50031D2A2 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BCF0CDD23FC9CA50031D2A2 /* MockURLSession.swift */; }; @@ -155,7 +177,8 @@ 9BE071B12368D3F500FA5952 /* Dictionary+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE071B02368D3F500FA5952 /* Dictionary+Helpers.swift */; }; 9BE74D3D23FB4A8E006D354F /* FileFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE74D3C23FB4A8E006D354F /* FileFinder.swift */; }; 9BEDC79E22E5D2CF00549BF6 /* RequestCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEDC79D22E5D2CF00549BF6 /* RequestCreator.swift */; }; - 9BF1A94F22CA5784005292C2 /* HTTPTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF1A94C22CA54F9005292C2 /* HTTPTransportTests.swift */; }; + 9BEEDC2824E351E5001D1294 /* MaxRetryInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEEDC2724E351E5001D1294 /* MaxRetryInterceptor.swift */; }; + 9BEEDC2B24E61995001D1294 /* TestURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEEDC2A24E61995001D1294 /* TestURLs.swift */; }; 9BF1A95122CA6E71005292C2 /* GraphQLGETTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF1A95022CA6E71005292C2 /* GraphQLGETTransformer.swift */; }; 9F19D8441EED568200C57247 /* ResultOrPromise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F19D8431EED568200C57247 /* ResultOrPromise.swift */; }; 9F19D8461EED8D3B00C57247 /* ResultOrPromiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F19D8451EED8D3B00C57247 /* ResultOrPromiseTests.swift */; }; @@ -202,7 +225,6 @@ 9FC9A9C81E2EFE6E0023C4D5 /* CacheKeyForFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC9A9C71E2EFE6E0023C4D5 /* CacheKeyForFieldTests.swift */; }; 9FC9A9CC1E2FD0760023C4D5 /* Record.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC9A9CB1E2FD0760023C4D5 /* Record.swift */; }; 9FC9A9D31E2FD48B0023C4D5 /* GraphQLError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC9A9D21E2FD48B0023C4D5 /* GraphQLError.swift */; }; - 9FCDFD231E33A0D8007519DC /* AsynchronousOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCDFD221E33A0D8007519DC /* AsynchronousOperation.swift */; }; 9FCDFD291E33D0CE007519DC /* GraphQLQueryWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCDFD281E33D0CE007519DC /* GraphQLQueryWatcher.swift */; }; 9FCE2CEE1E6BE2D900E34457 /* NormalizedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCE2CED1E6BE2D800E34457 /* NormalizedCache.swift */; }; 9FCE2D091E6C254700E34457 /* StarWarsAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9FCE2CFA1E6C213D00E34457 /* StarWarsAPI.framework */; }; @@ -210,7 +232,6 @@ 9FE941D01E62C771007CDD89 /* Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE941CF1E62C771007CDD89 /* Promise.swift */; }; 9FEB050D1DB5732300DA3B44 /* JSONSerializationFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEB050C1DB5732300DA3B44 /* JSONSerializationFormat.swift */; }; 9FEC15B41E681DAD00D461B4 /* GroupedSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEC15B31E681DAD00D461B4 /* GroupedSequence.swift */; }; - 9FF33D811E48B98200F608A4 /* HTTPNetworkTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4DAF2D1E48B84B00EBFF0B /* HTTPNetworkTransport.swift */; }; 9FF90A611DDDEB100034C3B6 /* GraphQLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF90A5B1DDDEB100034C3B6 /* GraphQLResponse.swift */; }; 9FF90A651DDDEB100034C3B6 /* GraphQLExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF90A5C1DDDEB100034C3B6 /* GraphQLExecutor.swift */; }; 9FF90A6F1DDDEB420034C3B6 /* InputValueEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF90A6A1DDDEB420034C3B6 /* InputValueEncodingTests.swift */; }; @@ -349,6 +370,13 @@ remoteGlobalIDString = 9B7B6F46233C26D100F32205; remoteInfo = ApolloCodegenLib; }; + 9BEEDC2C24EB6419001D1294 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9FC7503B1D2A532C00458D91 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9F8A95771EC0FC1200304A2D; + remoteInfo = ApolloTestSupport; + }; 9F65B11F1EC106E80090B25F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9FC7503B1D2A532C00458D91 /* Project object */; @@ -450,6 +478,19 @@ 9B1CCDD82360F02C007C9032 /* Bundle+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Helpers.swift"; sourceTree = ""; }; 9B21FD742422C29D00998B5C /* GraphQLFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLFileTests.swift; sourceTree = ""; }; 9B21FD762422C8CC00998B5C /* TestFileHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFileHelper.swift; sourceTree = ""; }; + 9B260BEA245A020300562176 /* ApolloInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApolloInterceptor.swift; sourceTree = ""; }; + 9B260BEC245A021300562176 /* Parseable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parseable.swift; sourceTree = ""; }; + 9B260BEE245A022E00562176 /* FlexibleDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleDecoder.swift; sourceTree = ""; }; + 9B260BF0245A025400562176 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = ""; }; + 9B260BF2245A026F00562176 /* RequestChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestChain.swift; sourceTree = ""; }; + 9B260BF4245A028D00562176 /* HTTPResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponse.swift; sourceTree = ""; }; + 9B260BF8245A030100562176 /* ResponseCodeInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseCodeInterceptor.swift; sourceTree = ""; }; + 9B260BFA245A031900562176 /* NetworkFetchInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFetchInterceptor.swift; sourceTree = ""; }; + 9B260BFE245A054700562176 /* JSONRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONRequest.swift; sourceTree = ""; }; + 9B260C00245A059700562176 /* CodableParsingInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableParsingInterceptor.swift; sourceTree = ""; }; + 9B260C03245A090600562176 /* RequestChainNetworkTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestChainNetworkTransport.swift; sourceTree = ""; }; + 9B260C07245A437400562176 /* InterceptorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptorProvider.swift; sourceTree = ""; }; + 9B260C09245A532500562176 /* LegacyParsingInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyParsingInterceptor.swift; sourceTree = ""; }; 9B2DFBB624E1FA0D00ED3AE6 /* UploadAPI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = UploadAPI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9B2DFBC524E1FA3E00ED3AE6 /* UploadAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UploadAPI.h; sourceTree = ""; }; 9B2DFBC624E1FA3E00ED3AE6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -563,6 +604,10 @@ 9B8C3FB1248DA2EA00707B13 /* URL+Apollo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Apollo.swift"; sourceTree = ""; }; 9B8C3FB4248DA3E000707B13 /* URLExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensionsTests.swift; sourceTree = ""; }; 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GETTransformerTests.swift; sourceTree = ""; }; + 9B96500824BE6201003C29C0 /* RequestChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestChainTests.swift; sourceTree = ""; }; + 9B96500B24BE7239003C29C0 /* LegacyCacheReadInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCacheReadInterceptor.swift; sourceTree = ""; }; + 9B9BBAF224DB39D70021C30F /* UploadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadRequest.swift; sourceTree = ""; }; + 9B9BBAF424DB4F890021C30F /* AutomaticPersistedQueryInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPersistedQueryInterceptor.swift; sourceTree = ""; }; 9B9BBB1624DB74720021C30F /* Apollo-Target-UploadAPI.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "Apollo-Target-UploadAPI.xcconfig"; sourceTree = ""; }; 9B9BBB1A24DB75E60021C30F /* UploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTests.swift; sourceTree = ""; }; 9BA1244922D8A8EA00BF1D24 /* JSONSerialization+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONSerialization+Sorting.swift"; sourceTree = ""; }; @@ -582,8 +627,13 @@ 9BAEEC14234C132600808306 /* CLIExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIExtractorTests.swift; sourceTree = ""; }; 9BAEEC16234C275600808306 /* ApolloSchemaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApolloSchemaTests.swift; sourceTree = ""; }; 9BAEEC18234C297800808306 /* ApolloCodegenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApolloCodegenTests.swift; sourceTree = ""; }; + 9BC139A224EDCA4400876D29 /* InterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptorTests.swift; sourceTree = ""; }; + 9BC139A524EDCAD900876D29 /* BlindRetryingTestInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindRetryingTestInterceptor.swift; sourceTree = ""; }; + 9BC139A724EDCE4F00876D29 /* RetryToCountThenSucceedInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryToCountThenSucceedInterceptor.swift; sourceTree = ""; }; 9BC2D9CE233C3531007BD083 /* Apollo-Target-ApolloCodegen.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Target-ApolloCodegen.xcconfig"; sourceTree = ""; }; 9BC2D9D1233C6DC0007BD083 /* Basher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Basher.swift; sourceTree = ""; }; + 9BC742AB24CFB2FF0029282C /* ApolloErrorInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApolloErrorInterceptor.swift; sourceTree = ""; }; + 9BC742AD24CFB6450029282C /* LegacyCacheWriteInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCacheWriteInterceptor.swift; sourceTree = ""; }; 9BCF0CD923FC9CA50031D2A2 /* TestCacheProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestCacheProvider.swift; sourceTree = ""; }; 9BCF0CDA23FC9CA50031D2A2 /* ApolloTestSupport.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ApolloTestSupport.h; sourceTree = ""; }; 9BCF0CDC23FC9CA50031D2A2 /* XCTAssertHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XCTAssertHelpers.swift; sourceTree = ""; }; @@ -637,7 +687,8 @@ 9BE071B02368D3F500FA5952 /* Dictionary+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Helpers.swift"; sourceTree = ""; }; 9BE74D3C23FB4A8E006D354F /* FileFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileFinder.swift; sourceTree = ""; }; 9BEDC79D22E5D2CF00549BF6 /* RequestCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestCreator.swift; sourceTree = ""; }; - 9BF1A94C22CA54F9005292C2 /* HTTPTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTransportTests.swift; sourceTree = ""; }; + 9BEEDC2724E351E5001D1294 /* MaxRetryInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxRetryInterceptor.swift; sourceTree = ""; }; + 9BEEDC2A24E61995001D1294 /* TestURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLs.swift; sourceTree = ""; }; 9BF1A95022CA6E71005292C2 /* GraphQLGETTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLGETTransformer.swift; sourceTree = ""; }; 9F19D8431EED568200C57247 /* ResultOrPromise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResultOrPromise.swift; sourceTree = ""; }; 9F19D8451EED8D3B00C57247 /* ResultOrPromiseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResultOrPromiseTests.swift; sourceTree = ""; }; @@ -645,7 +696,6 @@ 9F295E301E27534800A24949 /* NormalizeQueryResults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalizeQueryResults.swift; sourceTree = ""; }; 9F295E371E277B2A00A24949 /* GraphQLResultNormalizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLResultNormalizer.swift; sourceTree = ""; }; 9F438D0B1E6C494C007BDC1A /* BatchedLoadTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchedLoadTests.swift; sourceTree = ""; }; - 9F4DAF2D1E48B84B00EBFF0B /* HTTPNetworkTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPNetworkTransport.swift; sourceTree = ""; }; 9F55347A1DE1DB2100E54264 /* ApolloStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApolloStore.swift; sourceTree = ""; }; 9F578D8F1D8D2CB300C0EA36 /* HTTPURLResponse+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "HTTPURLResponse+Helpers.swift"; sourceTree = ""; }; 9F69FFA81D42855900E000B1 /* NetworkTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkTransport.swift; sourceTree = ""; }; @@ -685,7 +735,6 @@ 9FC9A9C71E2EFE6E0023C4D5 /* CacheKeyForFieldTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CacheKeyForFieldTests.swift; sourceTree = ""; }; 9FC9A9CB1E2FD0760023C4D5 /* Record.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Record.swift; sourceTree = ""; }; 9FC9A9D21E2FD48B0023C4D5 /* GraphQLError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLError.swift; sourceTree = ""; }; - 9FCDFD221E33A0D8007519DC /* AsynchronousOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsynchronousOperation.swift; sourceTree = ""; }; 9FCDFD281E33D0CE007519DC /* GraphQLQueryWatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLQueryWatcher.swift; sourceTree = ""; }; 9FCE2CED1E6BE2D800E34457 /* NormalizedCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalizedCache.swift; sourceTree = ""; }; 9FCE2CFA1E6C213D00E34457 /* StarWarsAPI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StarWarsAPI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -880,10 +929,41 @@ 9B64F6752354D219002D1BB5 /* URL+QueryDict.swift */, 9B21FD762422C8CC00998B5C /* TestFileHelper.swift */, 9B4F4540244A2A9200C2CF7D /* HTTPBinAPI.swift */, + 9BC139A524EDCAD900876D29 /* BlindRetryingTestInterceptor.swift */, + 9BC139A724EDCE4F00876D29 /* RetryToCountThenSucceedInterceptor.swift */, ); name = TestHelpers; sourceTree = ""; }; + 9B260BE9245A01B900562176 /* Interceptor */ = { + isa = PBXGroup; + children = ( + 9BC742B024D09F9E0029282C /* Codable */, + 9BC742AF24D09F880029282C /* Legacy */, + 9B260BEA245A020300562176 /* ApolloInterceptor.swift */, + 9BC742AB24CFB2FF0029282C /* ApolloErrorInterceptor.swift */, + 9B260C07245A437400562176 /* InterceptorProvider.swift */, + 9B9BBAF424DB4F890021C30F /* AutomaticPersistedQueryInterceptor.swift */, + 9BEEDC2724E351E5001D1294 /* MaxRetryInterceptor.swift */, + 9B260BFA245A031900562176 /* NetworkFetchInterceptor.swift */, + 9B260BF8245A030100562176 /* ResponseCodeInterceptor.swift */, + ); + name = Interceptor; + sourceTree = ""; + }; + 9B260C02245A07C200562176 /* RequestChain */ = { + isa = PBXGroup; + children = ( + 9B260BF0245A025400562176 /* HTTPRequest.swift */, + 9B260BFE245A054700562176 /* JSONRequest.swift */, + 9B9BBAF224DB39D70021C30F /* UploadRequest.swift */, + 9B260BF4245A028D00562176 /* HTTPResponse.swift */, + 9B260BF2245A026F00562176 /* RequestChain.swift */, + 9B260C03245A090600562176 /* RequestChainNetworkTransport.swift */, + ); + name = RequestChain; + sourceTree = ""; + }; 9B2DFBC424E1FA3E00ED3AE6 /* UploadAPI */ = { isa = PBXGroup; children = ( @@ -1119,6 +1199,24 @@ path = ApolloCodegenTests; sourceTree = ""; }; + 9BC742AF24D09F880029282C /* Legacy */ = { + isa = PBXGroup; + children = ( + 9B96500B24BE7239003C29C0 /* LegacyCacheReadInterceptor.swift */, + 9BC742AD24CFB6450029282C /* LegacyCacheWriteInterceptor.swift */, + 9B260C09245A532500562176 /* LegacyParsingInterceptor.swift */, + ); + name = Legacy; + sourceTree = ""; + }; + 9BC742B024D09F9E0029282C /* Codable */ = { + isa = PBXGroup; + children = ( + 9B260C00245A059700562176 /* CodableParsingInterceptor.swift */, + ); + name = Codable; + sourceTree = ""; + }; 9BCB585D240758B2002F766E /* Extensions */ = { isa = PBXGroup; children = ( @@ -1134,12 +1232,13 @@ 9BCF0CD823FC9CA50031D2A2 /* ApolloTestSupport */ = { isa = PBXGroup; children = ( - 9BCF0CD923FC9CA50031D2A2 /* TestCacheProvider.swift */, 9BCF0CDA23FC9CA50031D2A2 /* ApolloTestSupport.h */, - 9BCF0CDC23FC9CA50031D2A2 /* XCTAssertHelpers.swift */, - 9BCF0CDD23FC9CA50031D2A2 /* MockURLSession.swift */, 9BCF0CDE23FC9CA50031D2A2 /* Info.plist */, + 9BCF0CDD23FC9CA50031D2A2 /* MockURLSession.swift */, 9BCF0CDF23FC9CA50031D2A2 /* MockNetworkTransport.swift */, + 9BCF0CD923FC9CA50031D2A2 /* TestCacheProvider.swift */, + 9BEEDC2A24E61995001D1294 /* TestURLs.swift */, + 9BCF0CDC23FC9CA50031D2A2 /* XCTAssertHelpers.swift */, ); name = ApolloTestSupport; path = Sources/ApolloTestSupport; @@ -1237,6 +1336,8 @@ children = ( 9BDE43D022C6655200FD7C7F /* Cancellable.swift */, 9BE071AE2368D34D00FA5952 /* Matchable.swift */, + 9B260BEC245A021300562176 /* Parseable.swift */, + 9B260BEE245A022E00562176 /* FlexibleDecoder.swift */, ); name = Protocols; sourceTree = ""; @@ -1408,7 +1509,7 @@ 9F8622F91EC2117C00C38162 /* FragmentConstructionAndConversionTests.swift */, 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */, 9B21FD742422C29D00998B5C /* GraphQLFileTests.swift */, - 9BF1A94C22CA54F9005292C2 /* HTTPTransportTests.swift */, + 9BC139A224EDCA4400876D29 /* InterceptorTests.swift */, 9FF90A6A1DDDEB420034C3B6 /* InputValueEncodingTests.swift */, E86D8E03214B32DA0028EFE1 /* JSONTests.swift */, 9F91CF8E1F6C0DB2008DD0BE /* MutatingResultsTests.swift */, @@ -1417,6 +1518,7 @@ 9FE1C6E61E634C8D00C02284 /* PromiseTests.swift */, F16D083B21EF6F7300C458B8 /* QueryFromJSONBuildingTests.swift */, 9FF90A6B1DDDEB420034C3B6 /* ReadFieldValueTests.swift */, + 9B96500824BE6201003C29C0 /* RequestChainTests.swift */, C338DF1622DD9DE9006AF33E /* RequestCreatorTests.swift */, 9F19D8451EED8D3B00C57247 /* ResultOrPromiseTests.swift */, 9B4F4542244A2AD300C2CF7D /* URLSessionClientTests.swift */, @@ -1445,13 +1547,14 @@ 9FC9A9CE1E2FD0CC0023C4D5 /* Network */ = { isa = PBXGroup; children = ( + 9B260C02245A07C200562176 /* RequestChain */, + 9B260BE9245A01B900562176 /* Interceptor */, C377CCA822D798BD00572E03 /* GraphQLFile.swift */, 9BF1A95022CA6E71005292C2 /* GraphQLGETTransformer.swift */, 5AC6CA4222AAF7B200B7C94D /* GraphQLHTTPMethod.swift */, 9BDE43DE22C6708600FD7C7F /* GraphQLHTTPRequestError.swift */, 9BDE43DC22C6705300FD7C7F /* GraphQLHTTPResponseError.swift */, 9FF90A5B1DDDEB100034C3B6 /* GraphQLResponse.swift */, - 9F4DAF2D1E48B84B00EBFF0B /* HTTPNetworkTransport.swift */, C377CCAA22D7992E00572E03 /* MultipartFormData.swift */, 9F69FFA81D42855900E000B1 /* NetworkTransport.swift */, 9BEDC79D22E5D2CF00549BF6 /* RequestCreator.swift */, @@ -1464,7 +1567,6 @@ 9FCDFD211E33A09F007519DC /* Utilities */ = { isa = PBXGroup; children = ( - 9FCDFD221E33A0D8007519DC /* AsynchronousOperation.swift */, 9B1CCDD82360F02C007C9032 /* Bundle+Helpers.swift */, 9BE071AC2368D08700FA5952 /* Collection+Helpers.swift */, 9BE071B02368D3F500FA5952 /* Dictionary+Helpers.swift */, @@ -1765,6 +1867,7 @@ buildRules = ( ); dependencies = ( + 9BEEDC2D24EB6419001D1294 /* PBXTargetDependency */, 9B68354D24634A2000337AE6 /* PBXTargetDependency */, 9BAEEC03234BB8FD00808306 /* PBXTargetDependency */, ); @@ -2296,6 +2399,7 @@ buildActionMask = 2147483647; files = ( 9BCF0CE423FC9CA50031D2A2 /* MockURLSession.swift in Sources */, + 9BEEDC2B24E61995001D1294 /* TestURLs.swift in Sources */, 9BCF0CE023FC9CA50031D2A2 /* TestCacheProvider.swift in Sources */, 9BCF0CE323FC9CA50031D2A2 /* XCTAssertHelpers.swift in Sources */, 9BCF0CE523FC9CA50031D2A2 /* MockNetworkTransport.swift in Sources */, @@ -2328,9 +2432,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9FF33D811E48B98200F608A4 /* HTTPNetworkTransport.swift in Sources */, C377CCAB22D7992E00572E03 /* MultipartFormData.swift in Sources */, + 9B260C08245A437400562176 /* InterceptorProvider.swift in Sources */, + 9B9BBAF324DB39D70021C30F /* UploadRequest.swift in Sources */, 9FCE2CEE1E6BE2D900E34457 /* NormalizedCache.swift in Sources */, + 9B260C0A245A532500562176 /* LegacyParsingInterceptor.swift in Sources */, + 9B96500C24BE7239003C29C0 /* LegacyCacheReadInterceptor.swift in Sources */, 9F8F334C229044A200C0E83B /* Decoding.swift in Sources */, 9FADC84A1E6B0B2300C677E6 /* Locking.swift in Sources */, 9F295E381E277B2A00A24949 /* GraphQLResultNormalizer.swift in Sources */, @@ -2346,22 +2453,32 @@ 9FC9A9BF1E2C27FB0023C4D5 /* GraphQLResult.swift in Sources */, 9FC9A9D31E2FD48B0023C4D5 /* GraphQLError.swift in Sources */, 9FEB050D1DB5732300DA3B44 /* JSONSerializationFormat.swift in Sources */, + 9B260BEB245A020300562176 /* ApolloInterceptor.swift in Sources */, 54DDB0921EA045870009DD99 /* InMemoryNormalizedCache.swift in Sources */, 9FC9A9C51E2D6CE70023C4D5 /* GraphQLSelectionSet.swift in Sources */, + 9B260C01245A059700562176 /* CodableParsingInterceptor.swift in Sources */, 9BDE43DD22C6705300FD7C7F /* GraphQLHTTPResponseError.swift in Sources */, 9B554CC4247DC29A002F452A /* TaskData.swift in Sources */, - 9FCDFD231E33A0D8007519DC /* AsynchronousOperation.swift in Sources */, + 9B9BBAF524DB4F890021C30F /* AutomaticPersistedQueryInterceptor.swift in Sources */, 9BA1244A22D8A8EA00BF1D24 /* JSONSerialization+Sorting.swift in Sources */, + 9B260BF1245A025400562176 /* HTTPRequest.swift in Sources */, 9B708AAD2305884500604A11 /* ApolloClientProtocol.swift in Sources */, C377CCA922D798BD00572E03 /* GraphQLFile.swift in Sources */, + 9BEEDC2824E351E5001D1294 /* MaxRetryInterceptor.swift in Sources */, 9FC9A9CC1E2FD0760023C4D5 /* Record.swift in Sources */, 9FC4B9201D2A6F8D0046A641 /* JSON.swift in Sources */, + 9B260BFB245A031900562176 /* NetworkFetchInterceptor.swift in Sources */, + 9B260BED245A021300562176 /* Parseable.swift in Sources */, 9FEC15B41E681DAD00D461B4 /* GroupedSequence.swift in Sources */, 9F578D901D8D2CB300C0EA36 /* HTTPURLResponse+Helpers.swift in Sources */, + 9B260C04245A090600562176 /* RequestChainNetworkTransport.swift in Sources */, 9F7BA89922927A3700999B3B /* ResponsePath.swift in Sources */, 9FC9A9BD1E2C271C0023C4D5 /* RecordSet.swift in Sources */, 9BF1A95122CA6E71005292C2 /* GraphQLGETTransformer.swift in Sources */, + 9B260BFF245A054700562176 /* JSONRequest.swift in Sources */, + 9B260BF9245A030100562176 /* ResponseCodeInterceptor.swift in Sources */, 9FADC84F1E6B865E00C677E6 /* DataLoader.swift in Sources */, + 9B260BF3245A026F00562176 /* RequestChain.swift in Sources */, 9FF90A611DDDEB100034C3B6 /* GraphQLResponse.swift in Sources */, 9F27D4641D40379500715680 /* JSONStandardTypeConversions.swift in Sources */, 9BEDC79E22E5D2CF00549BF6 /* RequestCreator.swift in Sources */, @@ -2371,10 +2488,14 @@ 9FC750611D2A59C300458D91 /* GraphQLOperation.swift in Sources */, 9BDE43DF22C6708600FD7C7F /* GraphQLHTTPRequestError.swift in Sources */, 9B1CCDD92360F02C007C9032 /* Bundle+Helpers.swift in Sources */, + 9B260BF5245A028D00562176 /* HTTPResponse.swift in Sources */, 5AC6CA4322AAF7B200B7C94D /* GraphQLHTTPMethod.swift in Sources */, + 9B260BEF245A022E00562176 /* FlexibleDecoder.swift in Sources */, 9FE941D01E62C771007CDD89 /* Promise.swift in Sources */, + 9BC742AE24CFB6450029282C /* LegacyCacheWriteInterceptor.swift in Sources */, 9BA1245E22DE116B00BF1D24 /* Result+Helpers.swift in Sources */, 9B4F453F244A27B900C2CF7D /* URLSessionClient.swift in Sources */, + 9BC742AC24CFB2FF0029282C /* ApolloErrorInterceptor.swift in Sources */, 9FC750631D2A59F600458D91 /* ApolloClient.swift in Sources */, 9BA3130E2302BEA5007B7FC5 /* DispatchQueue+Optional.swift in Sources */, 9F86B6901E65533D00B885FF /* GraphQLResponseGenerator.swift in Sources */, @@ -2391,7 +2512,9 @@ 9FC9A9C81E2EFE6E0023C4D5 /* CacheKeyForFieldTests.swift in Sources */, 9F91CF8F1F6C0DB2008DD0BE /* MutatingResultsTests.swift in Sources */, 9B9BBB1C24DB760B0021C30F /* UploadTests.swift in Sources */, + 9BC139A424EDCA6C00876D29 /* InterceptorTests.swift in Sources */, F82E62E122BCD223000C311B /* AutomaticPersistedQueriesTests.swift in Sources */, + 9BC139A824EDCE4F00876D29 /* RetryToCountThenSucceedInterceptor.swift in Sources */, 9B4F4543244A2AD300C2CF7D /* URLSessionClientTests.swift in Sources */, 9F19D8461EED8D3B00C57247 /* ResultOrPromiseTests.swift in Sources */, 9F533AB31E6C4A4200CBE097 /* BatchedLoadTests.swift in Sources */, @@ -2402,6 +2525,8 @@ 9B64F6762354D219002D1BB5 /* URL+QueryDict.swift in Sources */, 9FADC8541E6B86D900C677E6 /* DataLoaderTests.swift in Sources */, 9B21FD772422C8CC00998B5C /* TestFileHelper.swift in Sources */, + 9BC139A624EDCAD900876D29 /* BlindRetryingTestInterceptor.swift in Sources */, + 9B96500A24BE62B7003C29C0 /* RequestChainTests.swift in Sources */, 9B21FD752422C29D00998B5C /* GraphQLFileTests.swift in Sources */, E86D8E05214B32FD0028EFE1 /* JSONTests.swift in Sources */, 9F8622FA1EC2117C00C38162 /* FragmentConstructionAndConversionTests.swift in Sources */, @@ -2411,7 +2536,6 @@ 9F295E311E27534800A24949 /* NormalizeQueryResults.swift in Sources */, 9FF90A731DDDEB420034C3B6 /* ParseQueryResponseTests.swift in Sources */, 9B4F4541244A2A9200C2CF7D /* HTTPBinAPI.swift in Sources */, - 9BF1A94F22CA5784005292C2 /* HTTPTransportTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2519,6 +2643,11 @@ target = 9B7B6F46233C26D100F32205 /* ApolloCodegenLib */; targetProxy = 9BAEEC02234BB8FD00808306 /* PBXContainerItemProxy */; }; + 9BEEDC2D24EB6419001D1294 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9F8A95771EC0FC1200304A2D /* ApolloTestSupport */; + targetProxy = 9BEEDC2C24EB6419001D1294 /* PBXContainerItemProxy */; + }; 9F65B1201EC106E80090B25F /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 9FC750431D2A532C00458D91 /* Apollo */; diff --git a/Package.swift b/Package.swift index c6e8777e70..4d10db10a4 100644 --- a/Package.swift +++ b/Package.swift @@ -103,6 +103,7 @@ let package = Package( .testTarget( name: "ApolloCodegenTests", dependencies: [ + "ApolloTestSupport", "ApolloCodegenLib" ]), .testTarget( diff --git a/Sources/Apollo/ApolloClient.swift b/Sources/Apollo/ApolloClient.swift index 2c8bb3915f..1ffff9f3e8 100644 --- a/Sources/Apollo/ApolloClient.swift +++ b/Sources/Apollo/ApolloClient.swift @@ -13,6 +13,11 @@ public enum CachePolicy { case returnCacheDataDontFetch /// Return data from the cache if available, and always fetch results from the server. case returnCacheDataAndFetch + + /// The current default cache policy. + public static var `default`: CachePolicy { + .returnCacheDataElseFetch + } } /// A handler for operation results. @@ -28,9 +33,6 @@ public class ApolloClient { public let store: ApolloStore // <- conformance to ApolloClientProtocol - private let queue: DispatchQueue - private let operationQueue: OperationQueue - public enum ApolloClientError: Error, LocalizedError { case noUploadTransport @@ -50,69 +52,18 @@ public class ApolloClient { public init(networkTransport: NetworkTransport, store: ApolloStore = ApolloStore(cache: InMemoryNormalizedCache())) { self.networkTransport = networkTransport self.store = store - - queue = DispatchQueue(label: "com.apollographql.ApolloClient") - operationQueue = OperationQueue() - operationQueue.underlyingQueue = queue } /// Creates a client with an HTTP network transport connecting to the specified URL. /// /// - Parameter url: The URL of a GraphQL server to connect to. public convenience init(url: URL) { - self.init(networkTransport: HTTPNetworkTransport(url: url)) - } - - fileprivate func send(operation: Operation, - shouldPublishResultToStore: Bool, - context: UnsafeMutableRawPointer?, - resultHandler: @escaping GraphQLResultHandler) -> Cancellable { - return networkTransport.send(operation: operation) { [weak self] result in - guard let self = self else { - return - } - self.handleOperationResult(shouldPublishResultToStore: shouldPublishResultToStore, - context: context, - result, - resultHandler: resultHandler) - } - } - - private func handleOperationResult(shouldPublishResultToStore: Bool, - context: UnsafeMutableRawPointer?, - _ result: Result, Error>, - resultHandler: @escaping GraphQLResultHandler) { - switch result { - case .failure(let error): - resultHandler(.failure(error)) - case .success(let response): - // If there is no need to publish the result to the store, we can use a fast path. - if !shouldPublishResultToStore { - do { - let result = try response.parseResultFast() - resultHandler(.success(result)) - } catch { - resultHandler(.failure(error)) - } - return - } - - firstly { - try response.parseResult(cacheKeyForObject: self.cacheKeyForObject) - }.andThen { [weak self] (result, records) in - guard let self = self else { - return - } - if let records = records { - self.store.publish(records: records, context: context).catch { error in - preconditionFailure(String(describing: error)) - } - } - resultHandler(.success(result)) - }.catch { error in - resultHandler(.failure(error)) - } - } + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(store: store) + let transport = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: url) + + self.init(networkTransport: transport, store: store) } } @@ -124,180 +75,81 @@ extension ApolloClient: ApolloClientProtocol { get { return self.store.cacheKeyForObject } - set { self.store.cacheKeyForObject = newValue } } - public func clearCache(callbackQueue: DispatchQueue = .main, completion: ((Result) -> Void)? = nil) { + public func clearCache(callbackQueue: DispatchQueue = .main, + completion: ((Result) -> Void)? = nil) { self.store.clearCache(completion: completion) } - + @discardableResult public func fetch(query: Query, cachePolicy: CachePolicy = .returnCacheDataElseFetch, - context: UnsafeMutableRawPointer? = nil, + contextIdentifier: UUID? = nil, queue: DispatchQueue = DispatchQueue.main, resultHandler: GraphQLResultHandler? = nil) -> Cancellable { - let resultHandler = wrapResultHandler(resultHandler, queue: queue) - - // If we don't have to go through the cache, there is no need to create an operation - // and we can return a network task directly - if cachePolicy == .fetchIgnoringCacheData || cachePolicy == .fetchIgnoringCacheCompletely { - return self.send(operation: query, shouldPublishResultToStore: cachePolicy != .fetchIgnoringCacheCompletely, context: context, resultHandler: resultHandler) - } else { - let operation = FetchQueryOperation(client: self, query: query, cachePolicy: cachePolicy, context: context, resultHandler: resultHandler) - self.operationQueue.addOperation(operation) - return operation + return self.networkTransport.send(operation: query, + cachePolicy: cachePolicy, + contextIdentifier: contextIdentifier, + callbackQueue: queue) { result in + resultHandler?(result) } } public func watch(query: Query, cachePolicy: CachePolicy = .returnCacheDataElseFetch, - queue: DispatchQueue = .main, resultHandler: @escaping GraphQLResultHandler) -> GraphQLQueryWatcher { let watcher = GraphQLQueryWatcher(client: self, query: query, - resultHandler: wrapResultHandler(resultHandler, queue: queue)) + resultHandler: resultHandler) watcher.fetch(cachePolicy: cachePolicy) return watcher } @discardableResult public func perform(mutation: Mutation, - context: UnsafeMutableRawPointer? = nil, - queue: DispatchQueue = DispatchQueue.main, + queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler? = nil) -> Cancellable { - return self.send(operation: mutation, - shouldPublishResultToStore: true, - context: context, - resultHandler: wrapResultHandler(resultHandler, queue: queue)) + return self.networkTransport.send(operation: mutation, + cachePolicy: .default, + contextIdentifier: nil, + callbackQueue: queue) { result in + resultHandler?(result) + } } @discardableResult public func upload(operation: Operation, - context: UnsafeMutableRawPointer? = nil, files: [GraphQLFile], queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler? = nil) -> Cancellable { - let wrappedHandler = wrapResultHandler(resultHandler, queue: queue) guard let uploadingTransport = self.networkTransport as? UploadingNetworkTransport else { assertionFailure("Trying to upload without an uploading transport. Please make sure your network transport conforms to `UploadingNetworkTransport`.") - wrappedHandler(.failure(ApolloClientError.noUploadTransport)) + queue.async { + resultHandler?(.failure(ApolloClientError.noUploadTransport)) + } return EmptyCancellable() } - return uploadingTransport.upload(operation: operation, files: files) { [weak self] result in - guard let self = self else { - return - } - self.handleOperationResult(shouldPublishResultToStore: true, - context: context, result, - resultHandler: wrappedHandler) + return uploadingTransport.upload(operation: operation, + files: files, + callbackQueue: queue) { result in + resultHandler?(result) } } - + @discardableResult public func subscribe(subscription: Subscription, queue: DispatchQueue = .main, resultHandler: @escaping GraphQLResultHandler) -> Cancellable { - return self.send(operation: subscription, - shouldPublishResultToStore: true, - context: nil, - resultHandler: wrapResultHandler(resultHandler, queue: queue)) + return self.networkTransport.send(operation: subscription, + cachePolicy: .default, + contextIdentifier: nil, + callbackQueue: queue, + completionHandler: resultHandler) } } -private func wrapResultHandler(_ resultHandler: GraphQLResultHandler?, queue handlerQueue: DispatchQueue) -> GraphQLResultHandler { - guard let resultHandler = resultHandler else { - return { _ in } - } - - return { result in - handlerQueue.async { - resultHandler(result) - } - } -} -private final class FetchQueryOperation: AsynchronousOperation, Cancellable { - weak var client: ApolloClient? - let query: Query - let cachePolicy: CachePolicy - let context: UnsafeMutableRawPointer? - let resultHandler: GraphQLResultHandler - - private var networkTask: Cancellable? - - init(client: ApolloClient, - query: Query, - cachePolicy: CachePolicy, - context: UnsafeMutableRawPointer?, - resultHandler: @escaping GraphQLResultHandler) { - self.client = client - self.query = query - self.cachePolicy = cachePolicy - self.context = context - self.resultHandler = resultHandler - } - - override public func start() { - if isCancelled { - state = .finished - return - } - - state = .executing - - if cachePolicy == .fetchIgnoringCacheData { - fetchFromNetwork() - return - } - - client?.store.load(query: query) { [weak self] result in - guard let self = self else { - return - } - if self.isCancelled { - self.state = .finished - return - } - - switch result { - case .success: - self.resultHandler(result) - - if self.cachePolicy != .returnCacheDataAndFetch { - self.state = .finished - return - } - case .failure: - if self.cachePolicy == .returnCacheDataDontFetch { - self.resultHandler(result) - self.state = .finished - return - } - } - - self.fetchFromNetwork() - } - } - - func fetchFromNetwork() { - networkTask = client?.send(operation: query, - shouldPublishResultToStore: true, - context: context) { [weak self] result in - guard let self = self else { - return - } - self.resultHandler(result) - self.state = .finished - return - } - } - - override public func cancel() { - super.cancel() - networkTask?.cancel() - } -} diff --git a/Sources/Apollo/ApolloClientProtocol.swift b/Sources/Apollo/ApolloClientProtocol.swift index b73b3fa1e8..db4961436b 100644 --- a/Sources/Apollo/ApolloClientProtocol.swift +++ b/Sources/Apollo/ApolloClientProtocol.swift @@ -22,13 +22,13 @@ public protocol ApolloClientProtocol: class { /// - Parameters: /// - query: The query to fetch. /// - cachePolicy: A cache policy that specifies when results should be fetched from the server and when data should be loaded from the local cache. - /// - context: [optional] A context to use for the cache to work with results. Should default to nil. /// - queue: A dispatch queue on which the result handler will be called. Defaults to the main queue. + /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Should default to `nil`. /// - resultHandler: [optional] A closure that is called when query results are available or when an error occurs. /// - Returns: An object that can be used to cancel an in progress fetch. func fetch(query: Query, cachePolicy: CachePolicy, - context: UnsafeMutableRawPointer?, + contextIdentifier: UUID?, queue: DispatchQueue, resultHandler: GraphQLResultHandler?) -> Cancellable @@ -36,26 +36,21 @@ public protocol ApolloClientProtocol: class { /// /// - Parameters: /// - query: The query to fetch. - /// - fetchHTTPMethod: The HTTP Method to be used. /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the local cache. - /// - queue: 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. func watch(query: Query, cachePolicy: CachePolicy, - queue: DispatchQueue, resultHandler: @escaping GraphQLResultHandler) -> GraphQLQueryWatcher /// Performs a mutation by sending it to the server. /// /// - Parameters: /// - mutation: The mutation to perform. - /// - context: [optional] A context to use for the cache to work with results. Should default to nil. /// - queue: A dispatch queue on which the result handler will be called. Defaults to the main queue. /// - 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 mutation. func perform(mutation: Mutation, - context: UnsafeMutableRawPointer?, queue: DispatchQueue, resultHandler: GraphQLResultHandler?) -> Cancellable @@ -63,14 +58,11 @@ public protocol ApolloClientProtocol: class { /// /// - Parameters: /// - operation: The operation to send - /// - context: [optional] A context to use for the cache to work with results. Should default to nil. /// - files: An array of `GraphQLFile` objects to send. /// - queue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - completionHandler: The completion handler to execute when the request completes or errors + /// - completionHandler: The completion handler to execute when the request completes or errors. Note that an error will be returned If your `networkTransport` does not also conform to `UploadingNetworkTransport`. /// - Returns: An object that can be used to cancel an in progress request. - /// - Throws: If your `networkTransport` does not also conform to `UploadingNetworkTransport`. func upload(operation: Operation, - context: UnsafeMutableRawPointer?, files: [GraphQLFile], queue: DispatchQueue, resultHandler: GraphQLResultHandler?) -> Cancellable diff --git a/Sources/Apollo/ApolloErrorInterceptor.swift b/Sources/Apollo/ApolloErrorInterceptor.swift new file mode 100644 index 0000000000..eee485b2a2 --- /dev/null +++ b/Sources/Apollo/ApolloErrorInterceptor.swift @@ -0,0 +1,20 @@ +import Foundation + +/// An error interceptor called to allow further examination of error data when an error occurs in the chain. +public protocol ApolloErrorInterceptor { + + /// Asynchronously handles the receipt of an error at any point in the chain. + /// + /// - Parameters: + /// - error: The received error + /// - chain: The chain the error was received on + /// - request: The request, as far as it was constructed + /// - response: [optional] The response, if 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) +} diff --git a/Sources/Apollo/ApolloInterceptor.swift b/Sources/Apollo/ApolloInterceptor.swift new file mode 100644 index 0000000000..00819d3fec --- /dev/null +++ b/Sources/Apollo/ApolloInterceptor.swift @@ -0,0 +1,16 @@ +/// A protocol to set up a chainable unit of networking work. +public protocol ApolloInterceptor: class { + + /// Called when this interceptor should do its work. + /// + /// - Parameters: + /// - chain: The chain the interceptor is a part of. + /// - request: The request, as far as it has been constructed + /// - response: [optional] The response, if received + /// - completion: The completion block to fire when data needs to be returned to the UI. + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) +} diff --git a/Sources/Apollo/ApolloStore.swift b/Sources/Apollo/ApolloStore.swift index 4a42462769..1cc551a3a8 100644 --- a/Sources/Apollo/ApolloStore.swift +++ b/Sources/Apollo/ApolloStore.swift @@ -1,8 +1,8 @@ -import Dispatch +import Foundation /// A function that returns a cache key for a particular result object. If it returns `nil`, a default cache key based on the field path will be used. public typealias CacheKeyForObject = (_ object: JSONObject) -> JSONValue? -public typealias DidChangeKeysFunc = (Set, UnsafeMutableRawPointer?) -> Void +public typealias DidChangeKeysFunc = (Set, UUID?) -> Void func rootCacheKey(for operation: Operation) -> String { switch operation.operationType { @@ -16,9 +16,16 @@ func rootCacheKey(for operation: Operation) -> Stri } protocol ApolloStoreSubscriber: class { + + /// A callback that can be received by subcribers when keys are changed within the database + /// + /// - Parameters: + /// - store: The store which made the changes + /// - changedKeys: The list of changed keys + /// - contextIdentifier: [optional] A unique identifier for the request that kicked off this change, to assist in de-duping cache hits for watchers. func store(_ store: ApolloStore, didChangeKeys changedKeys: Set, - context: UnsafeMutableRawPointer?) + contextIdentifier: UUID?) } /// The `ApolloStore` class acts as a local cache for normalized GraphQL results. @@ -43,9 +50,9 @@ public final class ApolloStore { queue = DispatchQueue(label: "com.apollographql.ApolloStore", attributes: .concurrent) } - fileprivate func didChangeKeys(_ changedKeys: Set, context: UnsafeMutableRawPointer?) { + fileprivate func didChangeKeys(_ changedKeys: Set, identifier: UUID?) { for subscriber in self.subscribers { - subscriber.store(self, didChangeKeys: changedKeys, context: context) + subscriber.store(self, didChangeKeys: changedKeys, contextIdentifier: identifier) } } @@ -64,13 +71,13 @@ public final class ApolloStore { } } - func publish(records: RecordSet, context: UnsafeMutableRawPointer? = nil) -> Promise { + func publish(records: RecordSet, identifier: UUID? = nil) -> Promise { return Promise { fulfill, reject in queue.async(flags: .barrier) { self.cacheLock.withWriteLock { self.cache.mergePromise(records: records) }.andThen { changedKeys in - self.didChangeKeys(changedKeys, context: context) + self.didChangeKeys(changedKeys, identifier: identifier) fulfill(()) }.wait() } @@ -164,16 +171,16 @@ public final class ApolloStore { } } - func load(query: Query) -> Promise> { + func load(query: Operation) -> Promise> { return withinReadTransactionPromise { transaction in - let mapper = GraphQLSelectionSetMapper() + let mapper = GraphQLSelectionSetMapper() let dependencyTracker = GraphQLDependencyTracker() - return try transaction.execute(selections: Query.Data.selections, + return try transaction.execute(selections: Operation.Data.selections, onObjectWithKey: rootCacheKey(for: query), variables: query.variables, accumulator: zip(mapper, dependencyTracker)) - }.map { (data: Query.Data, dependentKeys: Set) in + }.map { (data: Operation.Data, dependentKeys: Set) in GraphQLResult(data: data, extensions: nil, errors: nil, @@ -187,7 +194,7 @@ public final class ApolloStore { /// - Parameters: /// - query: The query to load results for /// - resultHandler: The completion handler to execute on success or error - public func load(query: Query, resultHandler: @escaping GraphQLResultHandler) { + public func load(query: Operation, resultHandler: @escaping GraphQLResultHandler) { load(query: query).andThen { result in resultHandler(.success(result)) }.catch { error in diff --git a/Sources/Apollo/AsynchronousOperation.swift b/Sources/Apollo/AsynchronousOperation.swift deleted file mode 100644 index 1fabbb2881..0000000000 --- a/Sources/Apollo/AsynchronousOperation.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -class AsynchronousOperation: Operation { - @objc class func keyPathsForValuesAffectingIsExecuting() -> Set { - return ["state"] - } - - @objc class func keyPathsForValuesAffectingIsFinished() -> Set { - return ["state"] - } - - enum State { - case initialized - case ready - case executing - case finished - } - - var state: State = .initialized { - willSet { - willChangeValue(forKey: "state") - } - didSet { - didChangeValue(forKey: "state") - } - } - - override var isAsynchronous: Bool { - return true - } - - override var isReady: Bool { - let ready = super.isReady - if ready { - state = .ready - } - return ready - } - - override var isExecuting: Bool { - return state == .executing - } - - override var isFinished: Bool { - return state == .finished - } -} diff --git a/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift b/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift new file mode 100644 index 0000000000..886e855ab7 --- /dev/null +++ b/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift @@ -0,0 +1,78 @@ +import Foundation + +public class AutomaticPersistedQueryInterceptor: ApolloInterceptor { + + public enum APQError: LocalizedError { + case noParsedResponse + case persistedQueryRetryFailed(operationName: String) + + public var errorDescription: String? { + switch self { + case .noParsedResponse: + return "The Automatic Persisted Query Interceptor was called before a response was received. Double-check the order of your interceptors." + case .persistedQueryRetryFailed(let operationName): + return "Persisted query retry failed for operation \"\(operationName)\"." + } + } + } + + /// Designated initializer + public init() {} + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + guard + let jsonRequest = request as? JSONRequest, + jsonRequest.autoPersistQueries else { + // Not a request that handles APQs, continue along + chain.proceedAsync(request: request, + response: response, + completion: completion) + return + } + + guard let result = response?.parsedResponse else { + // This is in the wrong order - this needs to be parsed before we can check it. + chain.handleErrorAsync(APQError.noParsedResponse, + request: request, + response: response, + completion: completion) + return + } + + guard let errors = result.errors else { + // No errors were returned so no retry is necessary, continue along. + chain.proceedAsync(request: request, + response: response, + completion: completion) + return + } + + let errorMessages = errors.compactMap { $0.message } + guard errorMessages.contains("PersistedQueryNotFound") else { + // The errors were not APQ errors, continue along. + chain.proceedAsync(request: request, + response: response, + completion: completion) + return + } + + guard !jsonRequest.isPersistedQueryRetry else { + // We already retried this and it didn't work. + chain.handleErrorAsync(APQError.persistedQueryRetryFailed(operationName: jsonRequest.operation.operationName), + request: jsonRequest, + response: response, + completion: completion) + return + } + + // We need to retry this query with the full body. + jsonRequest.isPersistedQueryRetry = true + chain.retry(request: jsonRequest, + completion: completion) + } +} diff --git a/Sources/Apollo/CodableParsingInterceptor.swift b/Sources/Apollo/CodableParsingInterceptor.swift new file mode 100644 index 0000000000..cff53482c2 --- /dev/null +++ b/Sources/Apollo/CodableParsingInterceptor.swift @@ -0,0 +1,54 @@ +import Foundation + +public class CodableParsingInterceptor: ApolloInterceptor { + + public enum CodableParsingError: Error, LocalizedError { + case noResponseToParse + + public var errorDescription: String? { + switch self { + case .noResponseToParse: + return "The Codable Parsing Interceptor was called before a response was received to be parsed. Double-check the order of your interceptors." + } + } + } + + let decoder: FlexDecoder + + var isCancelled: Bool = false + + public init(decoder: FlexDecoder) { + self.decoder = decoder + } + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + guard !self.isCancelled else { + return + } + + guard let createdResponse = response else { + chain.handleErrorAsync(CodableParsingError.noResponseToParse, + request: request, + response: response, + completion: completion) + return + } + + do { + let parsedData = try GraphQLResult(from: createdResponse.rawData, decoder: self.decoder) + createdResponse.parsedResponse = parsedData + chain.proceedAsync(request: request, + response: response, + completion: completion) + } catch { + chain.handleErrorAsync(error, + request: request, + response: createdResponse, + completion: completion) + } + } +} diff --git a/Sources/Apollo/FlexibleDecoder.swift b/Sources/Apollo/FlexibleDecoder.swift new file mode 100644 index 0000000000..ff8f1ab135 --- /dev/null +++ b/Sources/Apollo/FlexibleDecoder.swift @@ -0,0 +1,17 @@ +import Foundation + +// Adapted from Combine's `TopLevelDecoder` protocol to allow easy swapping of +// decoders which decode in similar fashions. +public protocol FlexibleDecoder { + associatedtype Input + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable +} + +extension JSONDecoder: FlexibleDecoder { + public typealias Input = Data +} + +extension PropertyListDecoder: FlexibleDecoder { + public typealias Input = Data +} diff --git a/Sources/Apollo/GraphQLHTTPRequestError.swift b/Sources/Apollo/GraphQLHTTPRequestError.swift index dca673e0a6..23200a4d99 100644 --- a/Sources/Apollo/GraphQLHTTPRequestError.swift +++ b/Sources/Apollo/GraphQLHTTPRequestError.swift @@ -2,14 +2,11 @@ import Foundation /// An error which has occurred during the serialization of a request. public enum GraphQLHTTPRequestError: Error, LocalizedError { - case cancelledByDelegate case serializedBodyMessageError case serializedQueryParamsMessageError public var errorDescription: String? { switch self { - case .cancelledByDelegate: - return "The request was cancelled by the HTTPNetworkTransportPreflightDelegate." case .serializedBodyMessageError: return "JSONSerialization error: Error while serializing request's body" case .serializedQueryParamsMessageError: diff --git a/Sources/Apollo/GraphQLQueryWatcher.swift b/Sources/Apollo/GraphQLQueryWatcher.swift index 5d73cb01a2..5135bf9414 100644 --- a/Sources/Apollo/GraphQLQueryWatcher.swift +++ b/Sources/Apollo/GraphQLQueryWatcher.swift @@ -1,4 +1,4 @@ -import Dispatch +import Foundation /// A `GraphQLQueryWatcher` is responsible for watching the store, and calling the result handler with a new result whenever any of the data the previous result depends on changes. /// @@ -8,7 +8,7 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo public let query: Query let resultHandler: GraphQLResultHandler - private var context = 0 + private var contextIdentifier = UUID() private weak var fetching: Cancellable? @@ -41,7 +41,7 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo func fetch(cachePolicy: CachePolicy) { // Cancel anything already in flight before starting a new fetch fetching?.cancel() - fetching = client?.fetch(query: query, cachePolicy: cachePolicy, context: &context, queue: callbackQueue) { [weak self] result in + fetching = client?.fetch(query: query, cachePolicy: cachePolicy, contextIdentifier: self.contextIdentifier, queue: callbackQueue) { [weak self] result in guard let self = self else { return } switch result { @@ -63,10 +63,20 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo func store(_ store: ApolloStore, didChangeKeys changedKeys: Set, - context: UnsafeMutableRawPointer?) { - if context == &self.context { return } + contextIdentifier: UUID?) { + if + let incomingIdentifier = contextIdentifier, + incomingIdentifier == self.contextIdentifier { + // This is from changes to the keys made from the `fetch` method above, + // changes will be returned through that and do not need to be returned + // here as well. + return + } - guard let dependentKeys = dependentKeys else { return } + guard let dependentKeys = dependentKeys else { + // This query has nil dependent keys, so nothing that changed will affect it. + return + } if !dependentKeys.isDisjoint(with: changedKeys) { // First, attempt to reload the query from the cache directly, in order not to interrupt any in-flight server-side fetch. diff --git a/Sources/Apollo/GraphQLResponse.swift b/Sources/Apollo/GraphQLResponse.swift index 32383c263c..a22f1a5bcf 100644 --- a/Sources/Apollo/GraphQLResponse.swift +++ b/Sources/Apollo/GraphQLResponse.swift @@ -1,15 +1,38 @@ +import Foundation + /// Represents a GraphQL response received from a server. -public final class GraphQLResponse { +public final class GraphQLResponse: Parseable { + + public init(from data: Foundation.Data, decoder: T) throws where T : FlexibleDecoder { + // Giant hack to make all this conform to Parseable. + throw ParseableError.unsupportedInitializer + } + public let body: JSONObject - private let rootKey: String - private let variables: GraphQLMap? + private var rootKey: String + private var variables: GraphQLMap? public init(operation: Operation, body: JSONObject) where Operation.Data == Data { self.body = body rootKey = rootCacheKey(for: operation) variables = operation.variables } + + func setupOperation (_ operation: Operation) { + self.rootKey = rootCacheKey(for: operation) + self.variables = operation.variables + } + + public func parseResultWithCompletion(cacheKeyForObject: CacheKeyForObject? = nil, + completion: (Result<(GraphQLResult, RecordSet?), Error>) -> Void) { + do { + let result = try parseResult(cacheKeyForObject: cacheKeyForObject).await() + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } func parseResult(cacheKeyForObject: CacheKeyForObject? = nil) throws -> Promise<(GraphQLResult, RecordSet?)> { let errors: [GraphQLError]? diff --git a/Sources/Apollo/GraphQLResult.swift b/Sources/Apollo/GraphQLResult.swift index 0dd7d31afe..2e2d094bd0 100644 --- a/Sources/Apollo/GraphQLResult.swift +++ b/Sources/Apollo/GraphQLResult.swift @@ -1,5 +1,12 @@ +import Foundation + /// Represents the result of a GraphQL operation. -public struct GraphQLResult { +public struct GraphQLResult: Parseable { + + public init(from data: Foundation.Data, decoder: T) throws { + throw ParseableError.unsupportedInitializer + } + /// 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. @@ -29,3 +36,16 @@ public struct GraphQLResult { self.dependentKeys = dependentKeys } } + +extension GraphQLResult where Data: Decodable { + + public init(from data: Foundation.Data, decoder: T) throws { + // SWIFT CODEGEN: fix this to handle codable better + let data = try decoder.decode(Data.self, from: data) + self.init(data: data, + extensions: nil, + errors: [], + source: .server, + dependentKeys: nil) + } +} diff --git a/Sources/Apollo/HTTPNetworkTransport.swift b/Sources/Apollo/HTTPNetworkTransport.swift deleted file mode 100644 index a6c5396e2e..0000000000 --- a/Sources/Apollo/HTTPNetworkTransport.swift +++ /dev/null @@ -1,475 +0,0 @@ -import Foundation - -/// Empty base protocol to allow multiple sub-protocols to just use a single parameter. -public protocol HTTPNetworkTransportDelegate: class {} - -/// Methods which will be called prior to a request being sent to the server. -public protocol HTTPNetworkTransportPreflightDelegate: HTTPNetworkTransportDelegate { - - /// Called when a request is about to send, to validate that it should be sent. - /// Good for early-exiting if your user is not logged in, for example. - /// - /// - Parameters: - /// - networkTransport: The network transport which wants to send a request - /// - request: The request, BEFORE it has been modified by `willSend` - /// - Returns: True if the request should proceed, false if not. - func networkTransport(_ networkTransport: HTTPNetworkTransport, shouldSend request: URLRequest) -> Bool - - /// Called when a request is about to send. Allows last minute modification of any properties on the request, - /// - /// - /// - Parameters: - /// - networkTransport: The network transport which is about to send a request - /// - request: The request, as an `inout` variable for modification - func networkTransport(_ networkTransport: HTTPNetworkTransport, willSend request: inout URLRequest) -} - -// MARK: - - -/// Methods which will be called after some kind of response has been received to a `URLSessionTask`. -public protocol HTTPNetworkTransportTaskCompletedDelegate: HTTPNetworkTransportDelegate { - - /// A callback to allow hooking in URL session responses for things like logging and examining headers. - /// NOTE: This will call back on whatever thread the URL session calls back on, which is never the main thread. Call `DispatchQueue.main.async` before touching your UI! - /// - /// - Parameters: - /// - networkTransport: The network transport that completed a task - /// - request: The request which was completed by the task - /// - data: [optional] Any data received. Passed through from `URLSession`. - /// - response: [optional] Any response received. Passed through from `URLSession`. - /// - error: [optional] Any error received. Passed through from `URLSession`. - func networkTransport(_ networkTransport: HTTPNetworkTransport, - didCompleteRawTaskForRequest request: URLRequest, - withData data: Data?, - response: URLResponse?, - error: Error?) -} - -// MARK: - - -/// Methods which will be called if an error is receieved at the network level. -public protocol HTTPNetworkTransportRetryDelegate: HTTPNetworkTransportDelegate { - - /// Called when an error has been received after a request has been sent to the server to see if an operation should be retried or not. - /// NOTE: Don't just call the `continueHandler` with `.retry` all the time, or you can potentially wind up in an infinite loop of errors - /// - /// - Parameters: - /// - networkTransport: The network transport which received the error - /// - error: The received error - /// - request: The URLRequest which generated the error - /// - response: [Optional] Any response received when the error was generated - /// - continueHandler: A closure indicating whether the operation should be retried. Asyncrhonous to allow for re-authentication or other async operations to complete. - func networkTransport(_ networkTransport: HTTPNetworkTransport, - receivedError error: Error, - for request: URLRequest, - response: URLResponse?, - continueHandler: @escaping (_ action: HTTPNetworkTransport.ContinueAction) -> Void) -} - -// MARK: - - -/// Methods which will be called after some kind of response has been received and it contains GraphQLErrors. -public protocol HTTPNetworkTransportGraphQLErrorDelegate: HTTPNetworkTransportDelegate { - - /// Called when response contains one or more GraphQL errors. - /// - /// NOTE: The mere presence of a GraphQL error does not necessarily mean a request failed! - /// GraphQL is design to allow partial success/failures to return, so make sure - /// you're validating the *type* of error you're getting in this before deciding whether to retry or not. - /// - /// ALSO NOTE: Don't just call the `retryHandler` with `true` all the time, or you can - /// potentially wind up in an infinite loop of errors - /// - /// - Parameters: - /// - networkTransport: The network transport which received the error - /// - errors: The received GraphQL errors - /// - retryHandler: A closure indicating whether the operation should be retried. Asyncrhonous to allow for re-authentication or other async operations to complete. - func networkTransport(_ networkTransport: HTTPNetworkTransport, - receivedGraphQLErrors errors: [GraphQLError], - retryHandler: @escaping (_ shouldRetry: Bool) -> Void) -} - -// MARK: - - -/// A network transport that uses HTTP POST requests to send GraphQL operations to a server, and that uses `URLSession` as the networking implementation. -public class HTTPNetworkTransport { - - /// The action to take when retrying - public enum ContinueAction { - /// Directly retry the action - case retry - /// Fail with the specified error. - case fail(_ error: Error) - } - - let url: URL - let client: URLSessionClient - let serializationFormat = JSONSerializationFormat.self - let useGETForQueries: Bool - let enableAutoPersistedQueries: Bool - let useGETForPersistedQueryRetry: Bool - private let requestCreator: RequestCreator - private let sendOperationIdentifiers: Bool - - /// A delegate which can conform to any or all of `HTTPNetworkTransportPreflightDelegate`, `HTTPNetworkTransportTaskCompletedDelegate`, and `HTTPNetworkTransportRetryDelegate`. - public weak var delegate: HTTPNetworkTransportDelegate? - - public lazy var clientName = HTTPNetworkTransport.defaultClientName - public lazy var clientVersion = HTTPNetworkTransport.defaultClientVersion - - /// Creates a network transport with the specified server URL and session configuration. - /// - /// - Parameters: - /// - url: The URL of a GraphQL server to connect to. - /// - client: The client to handle URL Session calls. - /// - sendOperationIdentifiers: Whether to send operation identifiers rather than full operation text, for use with servers that support query persistence. Defaults to false. - /// - useGETForQueries: If query operation should be sent using GET instead of POST. Defaults to false. - /// - enableAutoPersistedQueries: Whether to send persistedQuery extension. QueryDocument will be absent at 1st request, retry with QueryDocument if server respond PersistedQueryNotFound or PersistedQueryNotSupport. Defaults to false. - /// - useGETForPersistedQueryRetry: Whether to retry persistedQuery request with HttpGetMethod. Defaults to false. - public init(url: URL, - client: URLSessionClient = URLSessionClient(), - sendOperationIdentifiers: Bool = false, - useGETForQueries: Bool = false, - enableAutoPersistedQueries: Bool = false, - useGETForPersistedQueryRetry: Bool = false, - requestCreator: RequestCreator = ApolloRequestCreator()) { - self.url = url - self.client = client - self.sendOperationIdentifiers = sendOperationIdentifiers - self.useGETForQueries = useGETForQueries - self.enableAutoPersistedQueries = enableAutoPersistedQueries - self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry - self.requestCreator = requestCreator - } - - deinit { - self.client.invalidate() - } - - private func send(operation: Operation, - isPersistedQueryRetry: Bool, - files: [GraphQLFile]?, - completionHandler: @escaping (_ results: Result, Error>) -> Void) -> Cancellable { - let request: URLRequest - do { - request = try self.createRequest(for: operation, - isPersistedQueryRetry: isPersistedQueryRetry, - files: files) - } catch { - completionHandler(.failure(error)) - return EmptyCancellable() - } - - let task = self.client.sendRequest(request, rawTaskCompletionHandler: { [weak self] data, response, error in - self?.rawTaskCompleted(request: request, data: data, response: response, error: error) - }, completion: { [weak self] result in - guard let self = self else { - // None of the rest of this really matters - return - } - - switch result { - case .failure(let error): - self.handleErrorOrRetry(operation: operation, - files: files, - error: error, - for: request, - response: nil, - completionHandler: completionHandler) - case .success(let (data, httpResponse)): - guard httpResponse.apollo.isSuccessful == true else { - let unsuccessfulError = GraphQLHTTPResponseError(body: data, - response: httpResponse, - kind: .errorResponse) - self.handleErrorOrRetry(operation: operation, - files: files, - error: unsuccessfulError, - for: request, - response: httpResponse, - completionHandler: completionHandler) - return - } - - do { - guard let body = try self.serializationFormat.deserialize(data: data) as? JSONObject else { - throw GraphQLHTTPResponseError(body: data, response: httpResponse, kind: .invalidResponse) - } - - let graphQLResponse = GraphQLResponse(operation: operation, body: body) - - if let errors = graphQLResponse.parseErrorsOnlyFast() { - // Handle specific errors from response - self.handleGraphQLErrorsIfNeeded(operation: operation, - files: files, - for: request, - body: body, - errors: errors, - completionHandler: completionHandler) - } else { - completionHandler(.success(graphQLResponse)) - } - } catch let parsingError { - self.handleErrorOrRetry(operation: operation, - files: files, - error: parsingError, - for: request, - response: httpResponse, - completionHandler: completionHandler) - } - } - }) - - // Task is resumed by underlying framework - return task - } - - private func handleGraphQLErrorsOrComplete(operation: Operation, - files: [GraphQLFile]?, - response: GraphQLResponse, - completionHandler: @escaping (_ result: Result, Error>) -> Void) { - guard - let delegate = self.delegate as? HTTPNetworkTransportGraphQLErrorDelegate, - let graphQLErrors = response.parseErrorsOnlyFast(), - graphQLErrors.apollo.isNotEmpty else { - completionHandler(.success(response)) - return - } - - delegate.networkTransport(self, receivedGraphQLErrors: graphQLErrors, retryHandler: { [weak self] shouldRetry in - guard let self = self else { - // None of the rest of this really matters - return - } - - guard shouldRetry else { - completionHandler(.success(response)) - return - } - - _ = self.send(operation: operation, - isPersistedQueryRetry: self.enableAutoPersistedQueries, - files: files, - completionHandler: completionHandler) - }) - } - - private func handleGraphQLErrorsIfNeeded(operation: Operation, - files: [GraphQLFile]?, - for request: URLRequest, - body: JSONObject, - errors: [GraphQLError], - completionHandler: @escaping (_ results: Result, Error>) -> Void) { - - let errorMessages = errors.compactMap { $0.message } - if self.enableAutoPersistedQueries, - errorMessages.contains("PersistedQueryNotFound") { - // We need to retry this with the full body. - _ = self.send(operation: operation, - isPersistedQueryRetry: true, - files: nil, - completionHandler: completionHandler) - } else { - // Pass the response on to the rest of the chain - let response = GraphQLResponse(operation: operation, body: body) - handleGraphQLErrorsOrComplete(operation: operation, files: files, response: response, completionHandler: completionHandler) - } - } - - private func handleErrorOrRetry(operation: Operation, - files: [GraphQLFile]?, - error: Error, - for request: URLRequest, - response: URLResponse?, - completionHandler: @escaping (_ result: Result, Error>) -> Void) { - guard - let delegate = self.delegate, - let retrier = delegate as? HTTPNetworkTransportRetryDelegate else { - completionHandler(.failure(error)) - return - } - - retrier.networkTransport( - self, - receivedError: error, - for: request, - response: response, - continueHandler: { [weak self] (action: HTTPNetworkTransport.ContinueAction) in - guard let self = self else { - // None of the rest of this really matters - return - } - - switch action { - case .retry: - _ = self.send(operation: operation, - isPersistedQueryRetry: self.enableAutoPersistedQueries, - files: files, - completionHandler: completionHandler) - case .fail(let error): - completionHandler(.failure(error)) - } - }) - } - - private func rawTaskCompleted(request: URLRequest, - data: Data?, - response: URLResponse?, - error: Error?) { - guard - let delegate = self.delegate, - let taskDelegate = delegate as? HTTPNetworkTransportTaskCompletedDelegate else { - return - } - - taskDelegate.networkTransport(self, - didCompleteRawTaskForRequest: request, - withData: data, - response: response, - error: error) - } - - private func createRequest(for operation: Operation, - isPersistedQueryRetry: Bool, - files: [GraphQLFile]?) throws -> URLRequest { - let useGetMethod: Bool - let sendQueryDocument: Bool - let autoPersistQueries: Bool - switch operation.operationType { - case .query: - if isPersistedQueryRetry { - useGetMethod = self.useGETForPersistedQueryRetry - sendQueryDocument = true - autoPersistQueries = true - } else { - useGetMethod = self.useGETForQueries || (self.enableAutoPersistedQueries && self.useGETForPersistedQueryRetry) - sendQueryDocument = !self.enableAutoPersistedQueries - autoPersistQueries = self.enableAutoPersistedQueries - } - case .mutation: - useGetMethod = false - if isPersistedQueryRetry { - sendQueryDocument = true - autoPersistQueries = true - } else { - sendQueryDocument = !self.enableAutoPersistedQueries - autoPersistQueries = self.enableAutoPersistedQueries - } - default: - useGetMethod = false - sendQueryDocument = true - autoPersistQueries = false - } - - return try self.createRequest(for: operation, - files: files, - httpMethod: useGetMethod ? .GET : .POST, - sendQueryDocument: sendQueryDocument, - autoPersistQueries: autoPersistQueries) - } - - private func createRequest(for operation: Operation, - files: [GraphQLFile]?, - httpMethod: GraphQLHTTPMethod, - sendQueryDocument: Bool, - autoPersistQueries: Bool) throws -> URLRequest { - let body = self.requestCreator.requestBody(for: operation, - sendOperationIdentifiers: self.sendOperationIdentifiers, - sendQueryDocument: sendQueryDocument, - autoPersistQuery: autoPersistQueries) - var request = URLRequest(url: self.url) - self.addApolloClientHeaders(to: &request) - - // We default to json, but this can be changed below if needed. - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - switch httpMethod { - case .GET: - let transformer = GraphQLGETTransformer(body: body, url: self.url) - if let urlForGet = transformer.createGetURL() { - request = URLRequest(url: urlForGet) - request.httpMethod = GraphQLHTTPMethod.GET.rawValue - } else { - throw GraphQLHTTPRequestError.serializedQueryParamsMessageError - } - case .POST: - do { - if - let files = files, - files.apollo.isNotEmpty { - let formData = try requestCreator.requestMultipartFormData( - for: operation, - files: files, - sendOperationIdentifiers: self.sendOperationIdentifiers, - serializationFormat: self.serializationFormat, - manualBoundary: nil) - - request.setValue("multipart/form-data; boundary=\(formData.boundary)", forHTTPHeaderField: "Content-Type") - request.httpBody = try formData.encode() - } else { - request.httpBody = try serializationFormat.serialize(value: body) - } - - request.httpMethod = GraphQLHTTPMethod.POST.rawValue - } catch { - throw GraphQLHTTPRequestError.serializedBodyMessageError - } - } - - request.setValue(operation.operationName, forHTTPHeaderField: "X-APOLLO-OPERATION-NAME") - - if let operationID = operation.operationIdentifier { - request.setValue(operationID, forHTTPHeaderField: "X-APOLLO-OPERATION-ID") - } - - // If there's a delegate, do a pre-flight check and allow modifications to the request. - if - let delegate = self.delegate, - let preflightDelegate = delegate as? HTTPNetworkTransportPreflightDelegate { - guard preflightDelegate.networkTransport(self, shouldSend: request) else { - throw GraphQLHTTPRequestError.cancelledByDelegate - } - - preflightDelegate.networkTransport(self, willSend: &request) - } - - return request - } -} - -// MARK: - NetworkTransport conformance - -extension HTTPNetworkTransport: NetworkTransport { - - public func send(operation: Operation, completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable { - return send(operation: operation, - isPersistedQueryRetry: false, - files: nil, - completionHandler: completionHandler) - } -} - -// MARK: - UploadingNetworkTransport conformance - -extension HTTPNetworkTransport: UploadingNetworkTransport { - - public func upload(operation: Operation, - files: [GraphQLFile], - completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable { - return send(operation: operation, - isPersistedQueryRetry: false, - files: files, - completionHandler: completionHandler) - } -} - -// MARK: - Equatable conformance - -extension HTTPNetworkTransport: Equatable { - - public static func ==(lhs: HTTPNetworkTransport, rhs: HTTPNetworkTransport) -> Bool { - return lhs.url == rhs.url - && lhs.client == rhs.client - && lhs.sendOperationIdentifiers == rhs.sendOperationIdentifiers - && lhs.useGETForQueries == rhs.useGETForQueries - } -} diff --git a/Sources/Apollo/HTTPRequest.swift b/Sources/Apollo/HTTPRequest.swift new file mode 100644 index 0000000000..480fe8d803 --- /dev/null +++ b/Sources/Apollo/HTTPRequest.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Encapsulation of all information about a request before it hits the network +open class HTTPRequest { + + /// The endpoint to make a GraphQL request to + open var graphQLEndpoint: URL + + /// The GraphQL Operation to execute + open var operation: Operation + + /// Any additional headers you wish to add by default to this request + open var additionalHeaders: [String: String] + + /// The `CachePolicy` to use for this request. + public let cachePolicy: CachePolicy + + /// [optional] A unique identifier for this request, to help with deduping cache hits for watchers. + public let contextIdentifier: UUID? + + /// Designated Initializer + /// + /// - Parameters: + /// - graphQLEndpoint: The endpoint to make a GraphQL request to + /// - operation: The GraphQL Operation to execute + /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Defaults to `nil`. + /// - contentType: The `Content-Type` header's value. Should usually be set for you by a subclass. + /// - clientName: The name of the client to send with the `"apollographql-client-name"` header + /// - clientVersion: The version of the client to send with the `"apollographql-client-version"` header + /// - additionalHeaders: Any additional headers you wish to add by default to this request. + /// - cachePolicy: The `CachePolicy` to use for this request. Defaults to the `.default` policy + public init(graphQLEndpoint: URL, + operation: Operation, + contextIdentifier: UUID? = nil, + contentType: String, + clientName: String, + clientVersion: String, + additionalHeaders: [String: String], + cachePolicy: CachePolicy = .default) { + self.graphQLEndpoint = graphQLEndpoint + self.operation = operation + self.contextIdentifier = contextIdentifier + self.additionalHeaders = additionalHeaders + self.cachePolicy = cachePolicy + + self.addHeader(name: "Content-Type", value: contentType) + self.addHeader(name: "X-APOLLO-OPERATION-NAME", value: self.operation.operationName) + if let operationID = self.operation.operationIdentifier { + self.addHeader(name: "X-APOLLO-OPERATION-ID", value: operationID) + } + + self.addHeader(name: "apollographql-client-version", value: clientVersion) + self.addHeader(name: "apollographql-client-name", value: clientName) + } + + open func addHeader(name: String, value: String) { + self.additionalHeaders[name] = value + } + + open func updateContentType(to contentType: String) { + self.addHeader(name: "Content-Type", value: contentType) + } + + /// Converts this object to a fully fleshed-out `URLRequest` + /// + /// - Throws: Any error in creating the request + /// - Returns: The URL request, ready to send to your server. + open func toURLRequest() throws -> URLRequest { + var request = URLRequest(url: self.graphQLEndpoint) + + for (fieldName, value) in additionalHeaders { + request.addValue(value, forHTTPHeaderField: fieldName) + } + + return request + } +} + +extension HTTPRequest: Equatable { + + public static func == (lhs: HTTPRequest, rhs: HTTPRequest) -> Bool { + lhs.graphQLEndpoint == rhs.graphQLEndpoint + && lhs.contextIdentifier == rhs.contextIdentifier + && lhs.additionalHeaders == rhs.additionalHeaders + && lhs.cachePolicy == rhs.cachePolicy + && lhs.operation.queryDocument == rhs.operation.queryDocument + } +} + +extension HTTPRequest: CustomDebugStringConvertible { + public var debugDescription: String { + var debugStrings = [String]() + debugStrings.append("HTTPRequest details:") + debugStrings.append("Endpoint: \(self.graphQLEndpoint)") + debugStrings.append("Additional Headers: [") + for (key, value) in self.additionalHeaders { + debugStrings.append("\t\(key): \(value),") + } + debugStrings.append("]") + debugStrings.append("Cache Policy: \(self.cachePolicy)") + debugStrings.append("Operation: \(self.operation)") + debugStrings.append("Context identifier: \(String(describing: self.contextIdentifier))") + return debugStrings.joined(separator: "\n\t") + } +} diff --git a/Sources/Apollo/HTTPResponse.swift b/Sources/Apollo/HTTPResponse.swift new file mode 100644 index 0000000000..b80e16f85c --- /dev/null +++ b/Sources/Apollo/HTTPResponse.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Data about a response received by an HTTP request. +public class HTTPResponse { + + /// The `HTTPURLResponse` received from the URL loading system + public var httpResponse: HTTPURLResponse + + /// 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. + public var parsedResponse: GraphQLResult? + + /// [optional] The data as parsed into a `GraphQLResponse` for legacy caching purposes. If you're not using the `LegacyParsingInterceptor`, you probably shouldn't be using this property. + /// **NOTE:** This property will be removed when the transition to a Codable-based Codegen is complete. + public var legacyResponse: GraphQLResponse? = nil + + /// 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?) { + self.httpResponse = response + self.rawData = rawData + self.parsedResponse = parsedResponse + } +} diff --git a/Sources/Apollo/InterceptorProvider.swift b/Sources/Apollo/InterceptorProvider.swift new file mode 100644 index 0000000000..2632743806 --- /dev/null +++ b/Sources/Apollo/InterceptorProvider.swift @@ -0,0 +1,98 @@ +import Foundation + +// MARK: - Basic protocol + +/// A protocol to allow easy creation of an array of interceptors for a given operation. +public protocol InterceptorProvider { + + /// Creates a new array of interceptors when called + /// + /// - Parameter operation: The operation to provide interceptors for + func interceptors(for operation: Operation) -> [ApolloInterceptor] +} + +// MARK: - Default implementation for typescript codegen + +/// The default interceptor provider for typescript-generated code +open class LegacyInterceptorProvider: InterceptorProvider { + + private let client: URLSessionClient + private let store: ApolloStore + private let shouldInvalidateClientOnDeinit: Bool + + /// Designated initializer + /// + /// - Parameters: + /// - client: The `URLSessionClient` to use. Defaults to the default setup. + /// - shouldInvalidateClientOnDeinit: If the passed-in client should be invalidated when this interceptor provider is deinitialized. If you are recreating the `URLSessionClient` every time you create a new provider, you should do this to prevent memory leaks. Defaults to true, since by default we provide a `URLSessionClient` to new instances. + /// - store: The `ApolloStore` to use when reading from or writing to the cache. + public init(client: URLSessionClient = URLSessionClient(), + shouldInvalidateClientOnDeinit: Bool = true, + store: ApolloStore) { + self.client = client + self.shouldInvalidateClientOnDeinit = shouldInvalidateClientOnDeinit + self.store = store + } + + deinit { + if self.shouldInvalidateClientOnDeinit { + self.client.invalidate() + } + } + + open func interceptors(for operation: Operation) -> [ApolloInterceptor] { + return [ + MaxRetryInterceptor(), + LegacyCacheReadInterceptor(store: self.store), + NetworkFetchInterceptor(client: self.client), + ResponseCodeInterceptor(), + LegacyParsingInterceptor(cacheKeyForObject: self.store.cacheKeyForObject), + AutomaticPersistedQueryInterceptor(), + LegacyCacheWriteInterceptor(store: self.store), + ] + } +} + +// MARK: - Default implementation for swift codegen + + +/// The default interceptor proider for code generated with Swift Codegen™ +open class CodableInterceptorProvider: InterceptorProvider { + + private let client: URLSessionClient + private let shouldInvalidateClientOnDeinit: Bool + private let decoder: FlexDecoder + + /// Designated initializer + /// + /// - Parameters: + /// - client: The URLSessionClient to use. Defaults to the default setup. + /// - shouldInvalidateClientOnDeinit: If the passed-in client should be invalidated when this interceptor provider is deinitialized. If you are recreating the `URLSessionClient` every time you create a new provider, you should do this to prevent memory leaks. Defaults to true, since by default we provide a `URLSessionClient` to new instances. + /// - decoder: A `FlexibleDecoder` which can decode `Codable` objects. + public init(client: URLSessionClient = URLSessionClient(), + shouldInvalidateClientOnDeinit: Bool = true, + store: ApolloStore, + decoder: FlexDecoder) { + self.client = client + self.shouldInvalidateClientOnDeinit = shouldInvalidateClientOnDeinit + self.decoder = decoder + } + + deinit { + if self.shouldInvalidateClientOnDeinit { + self.client.invalidate() + } + } + + open func interceptors(for operation: Operation) -> [ApolloInterceptor] { + return [ + MaxRetryInterceptor(), + // Swift Codegen Phase 2: Add Cache Read interceptor + NetworkFetchInterceptor(client: self.client), + ResponseCodeInterceptor(), + AutomaticPersistedQueryInterceptor(), + CodableParsingInterceptor(decoder: self.decoder), + // Swift codegen Phase 2: Add Cache Write interceptor + ] + } +} diff --git a/Sources/Apollo/JSONRequest.swift b/Sources/Apollo/JSONRequest.swift new file mode 100644 index 0000000000..59e7ec2a68 --- /dev/null +++ b/Sources/Apollo/JSONRequest.swift @@ -0,0 +1,117 @@ +import Foundation + +/// A request which sends JSON related to a GraphQL operation. +public class JSONRequest: HTTPRequest { + + public let requestCreator: RequestCreator + + public let autoPersistQueries: Bool + public let useGETForQueries: Bool + public let useGETForPersistedQueryRetry: Bool + public var isPersistedQueryRetry = false + + public let serializationFormat = JSONSerializationFormat.self + + /// Designated initializer + /// + /// - Parameters: + /// - operation: The GraphQL Operation to execute + /// - graphQLEndpoint: The endpoint to make a GraphQL request to + /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Defaults to `nil`. + /// - clientName: The name of the client to send with the `"apollographql-client-name"` header + /// - clientVersion: The version of the client to send with the `"apollographql-client-version"` header + /// - additionalHeaders: Any additional headers you wish to add by default to this request + /// - cachePolicy: The `CachePolicy` to use for this request. + /// - autoPersistQueries: `true` if Auto-Persisted Queries should be used. Defaults to `false`. + /// - useGETForQueries: `true` if Queries should use `GET` instead of `POST` for HTTP requests. Defaults to `false`. + /// - useGETForPersistedQueryRetry: `true` if when an Auto-Persisted query is retried, it should use `GET` instead of `POST` to send the query. Defaults to `false`. + /// - requestCreator: An object conforming to the `RequestCreator` protocol to assist with creating the request body. Defaults to the provided `ApolloRequestCreator` implementation. + public init(operation: Operation, + graphQLEndpoint: URL, + contextIdentifier: UUID? = nil, + clientName: String, + clientVersion: String, + additionalHeaders: [String: String] = [:], + cachePolicy: CachePolicy = .default, + autoPersistQueries: Bool = false, + useGETForQueries: Bool = false, + useGETForPersistedQueryRetry: Bool = false, + requestCreator: RequestCreator = ApolloRequestCreator()) { + self.autoPersistQueries = autoPersistQueries + self.useGETForQueries = useGETForQueries + self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry + self.requestCreator = requestCreator + + super.init(graphQLEndpoint: graphQLEndpoint, + operation: operation, + contextIdentifier: contextIdentifier, + contentType: "application/json", + clientName: clientName, + clientVersion: clientVersion, + additionalHeaders: additionalHeaders, + cachePolicy: cachePolicy) + } + + public var sendOperationIdentifier: Bool { + self.operation.operationIdentifier != nil + } + + public override func toURLRequest() throws -> URLRequest { + var request = try super.toURLRequest() + + let useGetMethod: Bool + let sendQueryDocument: Bool + let autoPersistQueries: Bool + switch operation.operationType { + case .query: + if isPersistedQueryRetry { + useGetMethod = self.useGETForPersistedQueryRetry + sendQueryDocument = true + autoPersistQueries = true + } else { + useGetMethod = self.useGETForQueries || (self.autoPersistQueries && self.useGETForPersistedQueryRetry) + sendQueryDocument = !self.autoPersistQueries + autoPersistQueries = self.autoPersistQueries + } + case .mutation: + useGetMethod = false + if isPersistedQueryRetry { + sendQueryDocument = true + autoPersistQueries = true + } else { + sendQueryDocument = !self.autoPersistQueries + autoPersistQueries = self.autoPersistQueries + } + default: + useGetMethod = false + sendQueryDocument = true + autoPersistQueries = false + } + + let body = self.requestCreator.requestBody(for: operation, + sendOperationIdentifiers: self.sendOperationIdentifier, + sendQueryDocument: sendQueryDocument, + autoPersistQuery: autoPersistQueries) + + let httpMethod: GraphQLHTTPMethod = useGetMethod ? .GET : .POST + switch httpMethod { + case .GET: + let transformer = GraphQLGETTransformer(body: body, url: self.graphQLEndpoint) + if let urlForGet = transformer.createGetURL() { + request = URLRequest(url: urlForGet) + request.httpMethod = GraphQLHTTPMethod.GET.rawValue + } else { + throw GraphQLHTTPRequestError.serializedQueryParamsMessageError + } + case .POST: + do { + request.httpBody = try serializationFormat.serialize(value: body) + request.httpMethod = GraphQLHTTPMethod.POST.rawValue + } catch { + throw GraphQLHTTPRequestError.serializedBodyMessageError + } + } + + return request + } +} diff --git a/Sources/Apollo/LegacyCacheReadInterceptor.swift b/Sources/Apollo/LegacyCacheReadInterceptor.swift new file mode 100644 index 0000000000..4cf8060a48 --- /dev/null +++ b/Sources/Apollo/LegacyCacheReadInterceptor.swift @@ -0,0 +1,100 @@ +import Foundation + +/// An interceptor that reads data from the legacy cache for queries, following the `HTTPRequest`'s `cachePolicy`. +public class LegacyCacheReadInterceptor: ApolloInterceptor { + + private let store: ApolloStore + + /// Designated initializer + /// + /// - Parameter store: The store to use when reading from the cache. + public init(store: ApolloStore) { + self.store = store + } + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + switch request.operation.operationType { + case .mutation, + .subscription: + // Mutations and subscriptions don't need to hit the cache. + chain.proceedAsync(request: request, + response: response, + completion: completion) + case .query: + switch request.cachePolicy { + case .fetchIgnoringCacheCompletely, + .fetchIgnoringCacheData: + // Don't bother with the cache, just keep going + chain.proceedAsync(request: request, + response: response, + completion: completion) + case .returnCacheDataAndFetch: + self.fetchFromCache(for: request, chain: chain) { cacheFetchResult in + switch cacheFetchResult { + case .failure: + // Don't return a cache miss error, just keep going + break + case .success(let graphQLResult): + chain.returnValueAsync(for: request, + value: graphQLResult, + completion: completion) + } + + // In either case, keep going asynchronously + chain.proceedAsync(request: request, + response: response, + completion: completion) + } + case .returnCacheDataElseFetch: + self.fetchFromCache(for: request, chain: chain) { cacheFetchResult in + switch cacheFetchResult { + case .failure: + // Cache miss, proceed to network without returning error + chain.proceedAsync(request: request, + response: response, + completion: completion) + case .success(let graphQLResult): + // Cache hit! We're done. + chain.returnValueAsync(for: request, + value: graphQLResult, + completion: completion) + } + } + case .returnCacheDataDontFetch: + self.fetchFromCache(for: request, chain: chain) { cacheFetchResult in + switch cacheFetchResult { + case .failure(let error): + // Cache miss - don't hit the network, just return the error. + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + case .success(let result): + chain.returnValueAsync(for: request, + value: result, + completion: completion) + } + } + } + } + } + + private func fetchFromCache( + for request: HTTPRequest, + chain: RequestChain, + completion: @escaping (Result, Error>) -> Void) { + + self.store.load(query: request.operation) { loadResult in + guard chain.isNotCancelled else { + return + } + + completion(loadResult) + } + } +} diff --git a/Sources/Apollo/LegacyCacheWriteInterceptor.swift b/Sources/Apollo/LegacyCacheWriteInterceptor.swift new file mode 100644 index 0000000000..ba96ce7950 --- /dev/null +++ b/Sources/Apollo/LegacyCacheWriteInterceptor.swift @@ -0,0 +1,77 @@ +import Foundation + +/// An interceptor which writes data to the legacy cache, following the `HTTPRequest`'s `cachePolicy`. +public class LegacyCacheWriteInterceptor: ApolloInterceptor { + + public enum LegacyCacheWriteError: Error, LocalizedError { + case noResponseToParse + + public var errorDescription: String? { + switch self { + case .noResponseToParse: + return "The Legacy Cache Write Interceptor was called before a response was received to be parsed. Double-check the order of your interceptors." + } + } + } + + public let store: ApolloStore + + /// Designated initializer + /// + /// - Parameter store: The store to use when writing to the cache. + public init(store: ApolloStore) { + self.store = store + } + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + guard request.cachePolicy != .fetchIgnoringCacheCompletely else { + // If we're ignoring the cache completely, we're not writing to it. + chain.proceedAsync(request: request, + response: response, + completion: completion) + return + } + + guard + let createdResponse = response, + let legacyResponse = createdResponse.legacyResponse else { + chain.handleErrorAsync(LegacyCacheWriteError.noResponseToParse, + request: request, + response: response, + completion: completion) + return + } + + firstly { + try legacyResponse.parseResult(cacheKeyForObject: self.store.cacheKeyForObject) + }.andThen { [weak self] (result, records) in + guard let self = self else { + return + } + guard chain.isNotCancelled else { + return + } + + if let records = records { + self.store.publish(records: records, identifier: request.contextIdentifier) + .catch { error in + preconditionFailure(String(describing: error)) + } + } + + chain.proceedAsync(request: request, + response: createdResponse, + completion: completion) + }.catch { error in + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + } + } +} diff --git a/Sources/Apollo/LegacyParsingInterceptor.swift b/Sources/Apollo/LegacyParsingInterceptor.swift new file mode 100644 index 0000000000..9ac6c257ee --- /dev/null +++ b/Sources/Apollo/LegacyParsingInterceptor.swift @@ -0,0 +1,91 @@ +import Foundation + +/// An interceptor which parses code using the legacy parsing system. +public class LegacyParsingInterceptor: ApolloInterceptor { + + public enum LegacyParsingError: Error, LocalizedError { + case noResponseToParse + case couldNotParseToLegacyJSON(data: Data) + + public var errorDescription: String? { + switch self { + case .noResponseToParse: + return "The Codable Parsing Interceptor was called before a response was received to be parsed. Double-check the order of your interceptors." + case .couldNotParseToLegacyJSON(let data): + var errorStrings = [String]() + errorStrings.append("Could not parse data to legacy 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: " ") + } + } + } + + public var cacheKeyForObject: CacheKeyForObject? + + /// Designated Initializer + public init(cacheKeyForObject: CacheKeyForObject? = nil) { + self.cacheKeyForObject = cacheKeyForObject + } + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + guard let createdResponse = response else { + chain.handleErrorAsync(LegacyParsingError.noResponseToParse, + request: request, + response: response, + completion: completion) + return + } + + do { + let deserialized = try? JSONSerializationFormat.deserialize(data: createdResponse.rawData) + let json = deserialized as? JSONObject + guard let body = json else { + throw LegacyParsingError.couldNotParseToLegacyJSON(data: createdResponse.rawData) + } + + let graphQLResponse = GraphQLResponse(operation: request.operation, body: body) + createdResponse.legacyResponse = graphQLResponse + + switch request.cachePolicy { + case .fetchIgnoringCacheCompletely: + // There is no cache, so we don't need to get any info on dependencies. Use fast parsing. + let fastResult = try graphQLResponse.parseResultFast() + createdResponse.parsedResponse = fastResult + chain.proceedAsync(request: request, + response: createdResponse, + completion: completion) + default: + graphQLResponse.parseResultWithCompletion(cacheKeyForObject: self.cacheKeyForObject) { parsingResult in + switch parsingResult { + case .failure(let error): + chain.handleErrorAsync(error, + request: request, + response: createdResponse, + completion: completion) + case .success(let (parsedResult, _)): + createdResponse.parsedResponse = parsedResult + chain.proceedAsync(request: request, + response: createdResponse, + completion: completion) + } + } + } + } catch { + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + } + } +} diff --git a/Sources/Apollo/MaxRetryInterceptor.swift b/Sources/Apollo/MaxRetryInterceptor.swift new file mode 100644 index 0000000000..986690c201 --- /dev/null +++ b/Sources/Apollo/MaxRetryInterceptor.swift @@ -0,0 +1,47 @@ +import Foundation + +/// An interceptor to enforce a maximum number of retries of any `HTTPRequest` +public class MaxRetryInterceptor: ApolloInterceptor { + + private let maxRetries: Int + private var hitCount = 0 + + public enum RetryError: Error, LocalizedError { + case hitMaxRetryCount(count: Int, operationName: String) + + public var errorDescription: String? { + switch self { + case .hitMaxRetryCount(let count, let operationName): + return "The maximum number of retries (\(count)) was hit without success for operation \"\(operationName)\"." + } + } + } + + /// Designated initializer. + /// + /// - Parameter maxRetriesAllowed: How many times a query can be retried, in addition to the initial attempt before + public init(maxRetriesAllowed: Int = 3) { + self.maxRetries = maxRetriesAllowed + } + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + guard self.hitCount <= self.maxRetries else { + let error = RetryError.hitMaxRetryCount(count: self.maxRetries, + operationName: request.operation.operationName) + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + return + } + + self.hitCount += 1 + chain.proceedAsync(request: request, + response: response, + completion: completion) + } +} diff --git a/Sources/Apollo/NetworkFetchInterceptor.swift b/Sources/Apollo/NetworkFetchInterceptor.swift new file mode 100644 index 0000000000..4648a9a02c --- /dev/null +++ b/Sources/Apollo/NetworkFetchInterceptor.swift @@ -0,0 +1,61 @@ +import Foundation + +/// An interceptor which actually fetches data from the network. +public class NetworkFetchInterceptor: ApolloInterceptor, Cancellable { + let client: URLSessionClient + private var currentTask: URLSessionTask? + + /// Designated initializer. + /// + /// - Parameter client: The `URLSessionClient` to use to fetch data + public init(client: URLSessionClient) { + self.client = client + } + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + let urlRequest: URLRequest + do { + urlRequest = try request.toURLRequest() + } catch { + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + return + } + + self.currentTask = self.client.sendRequest(urlRequest) { result in + defer { + self.currentTask = nil + } + + guard chain.isNotCancelled else { + return + } + + switch result { + case .failure(let error): + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + case .success(let (data, httpResponse)): + let response = HTTPResponse(response: httpResponse, + rawData: data, + parsedResponse: nil) + chain.proceedAsync(request: request, + response: response, + completion: completion) + } + } + } + + public func cancel() { + self.currentTask?.cancel() + } +} diff --git a/Sources/Apollo/NetworkTransport.swift b/Sources/Apollo/NetworkTransport.swift index bfb126ee19..f66073be8e 100644 --- a/Sources/Apollo/NetworkTransport.swift +++ b/Sources/Apollo/NetworkTransport.swift @@ -9,9 +9,16 @@ public protocol NetworkTransport: class { /// /// - Parameters: /// - operation: The operation to send. + /// - cachePolicy: The `CachePolicy` to use making this request. + /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Defaults to `nil`. + /// - callbackQueue: The queue to call back on with the results. Should default to `.main`. /// - completionHandler: A closure to call when a request completes. On `success` will contain the response received from the server. On `failure` will contain the error which occurred. /// - Returns: An object that can be used to cancel an in progress request. - func send(operation: Operation, completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable + func send(operation: Operation, + cachePolicy: CachePolicy, + contextIdentifier: UUID?, + callbackQueue: DispatchQueue, + completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable /// The name of the client to send as a header value. var clientName: String { get } @@ -89,7 +96,12 @@ public protocol UploadingNetworkTransport: NetworkTransport { /// - Parameters: /// - operation: The operation to send /// - files: An array of `GraphQLFile` objects to send. + /// - callbackQueue: The queue to call back on with the results. Should default to `.main`. /// - completionHandler: The completion handler to execute when the request completes or errors /// - Returns: An object that can be used to cancel an in progress request. - func upload(operation: Operation, files: [GraphQLFile], completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable + func upload( + operation: Operation, + files: [GraphQLFile], + callbackQueue: DispatchQueue, + completionHandler: @escaping (Result,Error>) -> Void) -> Cancellable } diff --git a/Sources/Apollo/Parseable.swift b/Sources/Apollo/Parseable.swift new file mode 100644 index 0000000000..070abdb0e3 --- /dev/null +++ b/Sources/Apollo/Parseable.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum ParseableError: Error { + case unexpectedType + case unsupportedInitializer + case notYetImplemented +} + +/// A protocol to represent anything that can be decoded by a `FlexibleDecoder` +public protocol Parseable { + + /// Required initializer + /// + /// - Parameters: + /// - data: The data to decode + /// - decoder: The decoder to use to decode it + init(from data: Data, decoder: T) throws +} + +// MARK: - Default implementation for Decodable + +public extension Parseable where Self: Decodable { + + init(from data: Data, decoder: T) throws { + self = try decoder.decode(Self.self, from: data) + } +} diff --git a/Sources/Apollo/RequestChain.swift b/Sources/Apollo/RequestChain.swift new file mode 100644 index 0000000000..9c4cbf798d --- /dev/null +++ b/Sources/Apollo/RequestChain.swift @@ -0,0 +1,192 @@ +import Foundation +#if !COCOAPODS +import ApolloCore +#endif + +/// A chain that allows a single network request to be created and executed. +public class RequestChain: Cancellable { + + public enum ChainError: Error, LocalizedError { + case invalidIndex(chain: RequestChain, index: Int) + case noInterceptors + + public var errorDescription: String? { + switch self { + case .noInterceptors: + return "No interceptors were provided to this chain. This is a developer error." + case .invalidIndex(_, let index): + return "`proceedAsync` was called for index \(index), which is out of bounds of the receiver for this chain. Double-check the order of your interceptors." + } + } + } + + private let interceptors: [ApolloInterceptor] + private var currentIndex: Int + private var callbackQueue: DispatchQueue + private var isCancelled = Atomic(false) + + /// Checks the underlying value of `isCancelled`. Set up like this for better readability in `guard` statements + public var isNotCancelled: Bool { + !self.isCancelled.value + } + + /// Something which allows additional error handling to occur when some kind of error has happened. + public var additionalErrorHandler: ApolloErrorInterceptor? + + /// Creates a chain with the given interceptor array. + /// + /// - Parameters: + /// - interceptors: The array of interceptors to use. + /// - callbackQueue: The `DispatchQueue` to call back on when an error or result occurs. Defauls to `.main`. + public init(interceptors: [ApolloInterceptor], + callbackQueue: DispatchQueue = .main) { + self.interceptors = interceptors + self.callbackQueue = callbackQueue + self.currentIndex = 0 + } + + /// Kicks off the request from the beginning of the interceptor array. + /// + /// - Parameters: + /// - request: The request to send. + /// - completion: The completion closure to call when the request has completed. + public func kickoff( + request: HTTPRequest, + completion: @escaping (Result, Error>) -> Void) { + assert(self.currentIndex == 0, "The interceptor index should be zero when calling this method") + + guard let firstInterceptor = self.interceptors.first else { + handleErrorAsync(ChainError.noInterceptors, + request: request, + response: nil, + completion: completion) + return + } + + firstInterceptor.interceptAsync(chain: self, + request: request, + response: nil, + completion: completion) + } + + /// Proceeds to the next interceptor in the array. + /// + /// - Parameters: + /// - request: The in-progress request object + /// - response: [optional] The in-progress response object, if received yet + /// - completion: The completion closure to call when data has been processed and should be returned to the UI. + public func proceedAsync( + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + guard self.isNotCancelled else { + // Do not proceed, this chain has been cancelled. + return + } + + let nextIndex = self.currentIndex + 1 + if self.interceptors.indices.contains(nextIndex) { + self.currentIndex = nextIndex + let interceptor = self.interceptors[self.currentIndex] + + interceptor.interceptAsync(chain: self, + request: request, + response: response, + completion: completion) + } else { + if let result = response?.parsedResponse { + // We got to the end of the chain with a parsed response. Yay! Return it. + self.returnValueAsync(for: request, + value: result, + completion: completion) + } else { + // We got to the end of the chain and no parsed response is there, there needs to be more processing. + self.handleErrorAsync(ChainError.invalidIndex(chain: self, index: nextIndex), + request: request, + response: response, + completion: completion) + } + } + } + + /// Cancels the entire chain of interceptors. + public func cancel() { + self.isCancelled.value = true + + // 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 { + cancellableInterceptor.cancel() + } + } + } + + /// Restarts the request starting from the first inteceptor. + /// + /// - Parameters: + /// - request: The request to retry + /// - completion: The completion closure to call when the request has completed. + public func retry( + request: HTTPRequest, + completion: @escaping (Result, Error>) -> Void) { + + guard self.isNotCancelled else { + // Don't retry something that's been cancelled. + return + } + + self.currentIndex = 0 + self.kickoff(request: request, completion: completion) + } + + /// Handles the error by returning it on the appropriate queue, or by applying an additional error interceptor if one has been provided. + /// + /// - Parameters: + /// - error: The error to handle + /// - request: The request, as far as it has been constructed. + /// - 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, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + guard self.isNotCancelled else { + return + } + + guard let additionalHandler = self.additionalErrorHandler else { + self.callbackQueue.async { + completion(.failure(error)) + } + return + } + + + additionalHandler.handleErrorAsync(error: error, + chain: self, + request: request, + response: response, + completion: completion) + } + + /// Handles a resulting value by returning it on the appropriate queue. + /// + /// - Parameters: + /// - request: The request, as far as it has been constructed. + /// - value: The value to be returned + /// - completion: The completion closure to call when work is complete. + public func returnValueAsync( + for request: HTTPRequest, + value: GraphQLResult, + completion: @escaping (Result, Error>) -> Void) { + + guard self.isNotCancelled else { + return + } + + self.callbackQueue.async { + completion(.success(value)) + } + } +} diff --git a/Sources/Apollo/RequestChainNetworkTransport.swift b/Sources/Apollo/RequestChainNetworkTransport.swift new file mode 100644 index 0000000000..3c8d9e5f9a --- /dev/null +++ b/Sources/Apollo/RequestChainNetworkTransport.swift @@ -0,0 +1,112 @@ +import Foundation + +/// An implementation of `NetworkTransport` which creates a `RequestChain` object +/// for each item sent through it. +public class RequestChainNetworkTransport: NetworkTransport { + + let interceptorProvider: InterceptorProvider + let endpointURL: URL + + var additionalHeaders: [String: String] + let autoPersistQueries: Bool + let useGETForQueries: Bool + let useGETForPersistedQueryRetry: Bool + + var requestCreator: RequestCreator + + /// Designated initializer + /// + /// - Parameters: + /// - interceptorProvider: The interceptor provider to use when constructing chains for a request + /// - endpointURL: The GraphQL endpoint URL to use. + /// - additionalHeaders: Any additional headers that should be automatically added to every request. Defaults to an empty dictionary. + /// - autoPersistQueries: Pass `true` if Automatic Persisted Queries should be used to send a query hash instead of the full query body by default. Defaults to `false`. + /// - requestCreator: The `RequestCreator` object to use to build your `URLRequest`. Defaults to the providedd `ApolloRequestCreator` 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, + endpointURL: URL, + additionalHeaders: [String: String] = [:], + autoPersistQueries: Bool = false, + requestCreator: RequestCreator = ApolloRequestCreator(), + useGETForQueries: Bool = false, + useGETForPersistedQueryRetry: Bool = false) { + self.interceptorProvider = interceptorProvider + self.endpointURL = endpointURL + + self.additionalHeaders = additionalHeaders + self.autoPersistQueries = autoPersistQueries + self.requestCreator = requestCreator + self.useGETForQueries = useGETForQueries + self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry + } + + private func constructJSONRequest( + for operation: Operation, + cachePolicy: CachePolicy, + contextIdentifier: UUID?) -> JSONRequest { + + JSONRequest(operation: operation, + graphQLEndpoint: self.endpointURL, + contextIdentifier: contextIdentifier, + clientName: self.clientName, + clientVersion: self.clientVersion, + additionalHeaders: additionalHeaders, + cachePolicy: cachePolicy, + autoPersistQueries: self.autoPersistQueries, + useGETForQueries: self.useGETForQueries, + useGETForPersistedQueryRetry: self.useGETForPersistedQueryRetry, + requestCreator: self.requestCreator) + } + + // MARK: - NetworkTransport Conformance + + public var clientName = RequestChainNetworkTransport.defaultClientName + public var clientVersion = RequestChainNetworkTransport.defaultClientVersion + + public func send( + operation: Operation, + cachePolicy: CachePolicy = .default, + contextIdentifier: UUID? = nil, + callbackQueue: DispatchQueue = .main, + completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + + let interceptors = self.interceptorProvider.interceptors(for: operation) + let chain = RequestChain(interceptors: interceptors, callbackQueue: callbackQueue) + let request = self.constructJSONRequest(for: operation, + cachePolicy: cachePolicy, + contextIdentifier: contextIdentifier) + + chain.kickoff(request: request, completion: completionHandler) + return chain + } +} + +extension RequestChainNetworkTransport: UploadingNetworkTransport { + + private func createUploadRequest( + for operation: Operation, + with files: [GraphQLFile]) -> UploadRequest { + + UploadRequest(graphQLEndpoint: self.endpointURL, + operation: operation, + clientName: self.clientName, + clientVersion: self.clientVersion, + files: files, + requestCreator: self.requestCreator) + } + + public func upload( + operation: Operation, + files: [GraphQLFile], + callbackQueue: DispatchQueue = .main, + completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + + let request = self.createUploadRequest(for: operation, with: files) + let interceptors = self.interceptorProvider.interceptors(for: operation) + let chain = RequestChain(interceptors: interceptors, callbackQueue: callbackQueue) + + chain.kickoff(request: request, completion: completionHandler) + return chain + } +} diff --git a/Sources/Apollo/ResponseCodeInterceptor.swift b/Sources/Apollo/ResponseCodeInterceptor.swift new file mode 100644 index 0000000000..25c63a05de --- /dev/null +++ b/Sources/Apollo/ResponseCodeInterceptor.swift @@ -0,0 +1,59 @@ +import Foundation + +/// An interceptor to check the response code returned with a request. +public class ResponseCodeInterceptor: ApolloInterceptor { + + public enum ResponseCodeError: Error, LocalizedError { + case invalidResponseCode(response: HTTPURLResponse?, rawData: Data?) + + public var errorDescription: String? { + switch self { + case .invalidResponseCode(let response, let rawData): + var errorStrings = [String]() + if let code = response?.statusCode { + errorStrings.append("Received a \(code) error.") + } else { + errorStrings.append("Did not receive a valid status code.") + } + + if + let data = rawData, + let dataString = String(bytes: data, encoding: .utf8) { + errorStrings.append("Data returned as a String was:") + errorStrings.append(dataString) + } else { + errorStrings.append("Data was nil or could not be transformed into a string.") + } + + return errorStrings.joined(separator: " ") + } + } + } + + /// Designated initializer + public init() {} + + public func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + + guard response?.httpResponse.apollo.isSuccessful == true else { + let error = ResponseCodeError.invalidResponseCode(response: response?.httpResponse, + + rawData: response?.rawData) + + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + return + } + + chain.proceedAsync(request: request, + response: response, + completion: completion) + } +} diff --git a/Sources/Apollo/UploadRequest.swift b/Sources/Apollo/UploadRequest.swift new file mode 100644 index 0000000000..601cf07992 --- /dev/null +++ b/Sources/Apollo/UploadRequest.swift @@ -0,0 +1,57 @@ +import Foundation + +/// A request class allowing for a multipart-upload request. +public class UploadRequest: HTTPRequest { + + public let requestCreator: RequestCreator + public let files: [GraphQLFile] + public let manualBoundary: String? + + public let serializationFormat = JSONSerializationFormat.self + + /// Designated Initializer + /// + /// - Parameters: + /// - graphQLEndpoint: The endpoint to make a GraphQL request to + /// - operation: The GraphQL Operation to execute + /// - clientName: The name of the client to send with the `"apollographql-client-name"` header + /// - clientVersion: The version of the client to send with the `"apollographql-client-version"` header + /// - additionalHeaders: Any additional headers you wish to add by default to this request. Defaults to an empty dictionary. + /// - files: The array of files to upload for all `Upload` parameters in the mutation. + /// - manualBoundary: [optional] A manual boundary to pass in. A default boundary will be used otherwise. Defaults to nil. + /// - requestCreator: An object conforming to the `RequestCreator` protocol to assist with creating the request body. Defaults to the provided `ApolloRequestCreator` implementation. + public init(graphQLEndpoint: URL, + operation: Operation, + clientName: String, + clientVersion: String, + additionalHeaders: [String: String] = [:], + files: [GraphQLFile], + manualBoundary: String? = nil, + requestCreator: RequestCreator = ApolloRequestCreator()) { + self.requestCreator = requestCreator + self.files = files + self.manualBoundary = manualBoundary + super.init(graphQLEndpoint: graphQLEndpoint, + operation: operation, + contentType: "multipart/form-data", + clientName: clientName, + clientVersion: clientVersion, + additionalHeaders: additionalHeaders) + } + + public override func toURLRequest() throws -> URLRequest { + let shouldSendOperationID = (operation.operationIdentifier != nil) + + let formData = try requestCreator.requestMultipartFormData(for: self.operation, + files: self.files, + sendOperationIdentifiers: shouldSendOperationID, + serializationFormat: self.serializationFormat, + manualBoundary: self.manualBoundary) + self.updateContentType(to: "multipart/form-data; boundary=\(formData.boundary)") + var request = try super.toURLRequest() + request.httpBody = try formData.encode() + request.httpMethod = GraphQLHTTPMethod.POST.rawValue + + return request + } +} diff --git a/Sources/ApolloTestSupport/MockNetworkTransport.swift b/Sources/ApolloTestSupport/MockNetworkTransport.swift index ee1cb8566e..c2be0c91ef 100644 --- a/Sources/ApolloTestSupport/MockNetworkTransport.swift +++ b/Sources/ApolloTestSupport/MockNetworkTransport.swift @@ -1,25 +1,31 @@ -import Apollo +@testable import Apollo import Dispatch -public final class MockNetworkTransport: NetworkTransport { - public var body: JSONObject +public final class MockNetworkTransport: RequestChainNetworkTransport { - public var clientName = "MockNetworkTransport" - public var clientVersion = "mock_version" - - public init(body: JSONObject) { - self.body = body - } + private let mockClient: MockURLSessionClient - public func send(operation: Operation, completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable { - DispatchQueue.global(qos: .default).async { - completionHandler(.success(GraphQLResponse(operation: operation, body: self.body))) - } - return MockTask() + public init(body: JSONObject, store: ApolloStore) { + let testURL = TestURL.mockServer.url + self.mockClient = MockURLSessionClient() + self.mockClient.data = try! JSONSerializationFormat.serialize(value: body) + self.mockClient.response = HTTPURLResponse(url: testURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + let legacyProvider = LegacyInterceptorProvider(client: self.mockClient, + store: store) + super.init(interceptorProvider: legacyProvider, + endpointURL: TestURL.mockServer.url) + } + + public func updateBody(to body: JSONObject) { + self.mockClient.data = try! JSONSerializationFormat.serialize(value: body) } } private final class MockTask: Cancellable { func cancel() { + // no-op } } diff --git a/Sources/ApolloTestSupport/MockURLSession.swift b/Sources/ApolloTestSupport/MockURLSession.swift index a21b03a96b..5effc28f52 100644 --- a/Sources/ApolloTestSupport/MockURLSession.swift +++ b/Sources/ApolloTestSupport/MockURLSession.swift @@ -7,10 +7,11 @@ import Foundation import Apollo +import ApolloCore public final class MockURLSessionClient: URLSessionClient { - public private (set) var lastRequest: URLRequest? + public private (set) var lastRequest: Atomic = Atomic(nil) public var data: Data? public var response: HTTPURLResponse? @@ -19,7 +20,7 @@ public final class MockURLSessionClient: URLSessionClient { public override func sendRequest(_ request: URLRequest, rawTaskCompletionHandler: URLSessionClient.RawCompletion? = nil, completion: @escaping URLSessionClient.Completion) -> URLSessionTask { - self.lastRequest = request + self.lastRequest.value = request rawTaskCompletionHandler?(self.data, self.response, self.error) @@ -47,6 +48,6 @@ public final class MockURLSessionClient: URLSessionClient { private final class URLSessionDataTaskMock: URLSessionDataTask { override func resume() { - + // No-op } } diff --git a/Sources/ApolloTestSupport/TestURLs.swift b/Sources/ApolloTestSupport/TestURLs.swift new file mode 100644 index 0000000000..655f222bf6 --- /dev/null +++ b/Sources/ApolloTestSupport/TestURLs.swift @@ -0,0 +1,25 @@ +import Foundation + +/// URLs used in testing +public enum TestURL { + case mockServer + case starWarsServer + case starWarsWebSocket + case uploadServer + + public var url: URL { + let urlString: String + switch self { + case .starWarsServer: + urlString = "http://localhost:8080/graphql" + case .starWarsWebSocket: + urlString = "ws://localhost:8080/websocket" + case .uploadServer: + urlString = "http://localhost:4000" + case .mockServer: + urlString = "http://localhost/dummy_url" + } + + return URL(string: urlString)! + } +} diff --git a/Sources/ApolloWebSocket/SplitNetworkTransport.swift b/Sources/ApolloWebSocket/SplitNetworkTransport.swift index ccd3009ac5..7b9f6bb28b 100644 --- a/Sources/ApolloWebSocket/SplitNetworkTransport.swift +++ b/Sources/ApolloWebSocket/SplitNetworkTransport.swift @@ -1,14 +1,15 @@ +import Foundation #if !COCOAPODS import Apollo #endif /// 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 httpNetworkTransport: UploadingNetworkTransport + private let uploadingNetworkTransport: UploadingNetworkTransport private let webSocketNetworkTransport: NetworkTransport public var clientName: String { - let httpName = self.httpNetworkTransport.clientName + let httpName = self.uploadingNetworkTransport.clientName let websocketName = self.webSocketNetworkTransport.clientName if httpName == websocketName { return httpName @@ -18,7 +19,7 @@ public class SplitNetworkTransport { } public var clientVersion: String { - let httpVersion = self.httpNetworkTransport.clientVersion + let httpVersion = self.uploadingNetworkTransport.clientVersion let websocketVersion = self.webSocketNetworkTransport.clientVersion if httpVersion == websocketVersion { return httpVersion @@ -30,10 +31,10 @@ public class SplitNetworkTransport { /// Designated initializer /// /// - Parameters: - /// - httpNetworkTransport: An `UploadingNetworkTransport` to use for non-subscription requests. Should generally be a `HTTPNetworkTransport` or something similar. + /// - 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(httpNetworkTransport: UploadingNetworkTransport, webSocketNetworkTransport: NetworkTransport) { - self.httpNetworkTransport = httpNetworkTransport + public init(uploadingNetworkTransport: UploadingNetworkTransport, webSocketNetworkTransport: NetworkTransport) { + self.uploadingNetworkTransport = uploadingNetworkTransport self.webSocketNetworkTransport = webSocketNetworkTransport } } @@ -42,11 +43,23 @@ public class SplitNetworkTransport { extension SplitNetworkTransport: NetworkTransport { - public func send(operation: Operation, completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + public func send(operation: Operation, + cachePolicy: CachePolicy, + contextIdentifier: UUID? = nil, + callbackQueue: DispatchQueue = .main, + completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { if operation.operationType == .subscription { - return webSocketNetworkTransport.send(operation: operation, completionHandler: completionHandler) + return webSocketNetworkTransport.send(operation: operation, + cachePolicy: cachePolicy, + contextIdentifier: contextIdentifier, + callbackQueue: callbackQueue, + completionHandler: completionHandler) } else { - return httpNetworkTransport.send(operation: operation, completionHandler: completionHandler) + return uploadingNetworkTransport.send(operation: operation, + cachePolicy: cachePolicy, + contextIdentifier: contextIdentifier, + callbackQueue: callbackQueue, + completionHandler: completionHandler) } } } @@ -55,11 +68,14 @@ extension SplitNetworkTransport: NetworkTransport { extension SplitNetworkTransport: UploadingNetworkTransport { - public func upload(operation: Operation, - files: [GraphQLFile], - completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable { - return httpNetworkTransport.upload(operation: operation, - files: files, - completionHandler: completionHandler) + public func upload( + operation: Operation, + files: [GraphQLFile], + callbackQueue: DispatchQueue = .main, + completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { + return uploadingNetworkTransport.upload(operation: operation, + files: files, + callbackQueue: callbackQueue, + completionHandler: completionHandler) } } diff --git a/Sources/ApolloWebSocket/WebSocketTransport.swift b/Sources/ApolloWebSocket/WebSocketTransport.swift index 9f8b98adce..f4f020f8b2 100644 --- a/Sources/ApolloWebSocket/WebSocketTransport.swift +++ b/Sources/ApolloWebSocket/WebSocketTransport.swift @@ -342,10 +342,15 @@ public class WebSocketTransport { } } -// MARK: - HTTPNetworkTransport conformance +// MARK: - NetworkTransport conformance extension WebSocketTransport: NetworkTransport { - public func send(operation: Operation, completionHandler: @escaping (_ result: Result,Error>) -> Void) -> Cancellable { + public func send( + operation: Operation, + cachePolicy: CachePolicy, + contextIdentifier: UUID? = nil, + callbackQueue: DispatchQueue = .main, + completionHandler: @escaping (Result, Error>) -> Void) -> Cancellable { if let error = self.error.value { completionHandler(.failure(error)) return EmptyCancellable() @@ -355,7 +360,12 @@ extension WebSocketTransport: NetworkTransport { switch result { case .success(let jsonBody): let response = GraphQLResponse(operation: operation, body: jsonBody) - completionHandler(.success(response)) + do { + let graphQLResult = try response.parseResultFast() + completionHandler(.success(graphQLResult)) + } catch { + completionHandler(.failure(error)) + } case .failure(let error): completionHandler(.failure(error)) } diff --git a/Tests/ApolloCacheDependentTests/FetchQueryTests.swift b/Tests/ApolloCacheDependentTests/FetchQueryTests.swift index fe2444a56a..27441ead47 100644 --- a/Tests/ApolloCacheDependentTests/FetchQueryTests.swift +++ b/Tests/ApolloCacheDependentTests/FetchQueryTests.swift @@ -30,7 +30,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -72,7 +72,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -120,7 +120,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -161,7 +161,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -203,7 +203,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -245,7 +245,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -324,7 +324,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) @@ -364,17 +364,17 @@ class FetchQueryTests: XCTestCase, CacheTesting { let query = HeroNameQuery() - let networkTransport = MockNetworkTransport(body: [ - "data": [ - "hero": [ - "name": "Luke Skywalker", - "__typename": "Human" - ] - ] - ]) - withCache { (cache) in let store = ApolloStore(cache: cache) + let networkTransport = MockNetworkTransport(body: [ + "data": [ + "hero": [ + "name": "Luke Skywalker", + "__typename": "Human" + ] + ] + ], store: store) + let client = ApolloClient(networkTransport: networkTransport, store: store) let expectation = self.expectation(description: "Fetching query") @@ -391,6 +391,8 @@ class FetchQueryTests: XCTestCase, CacheTesting { func testThreadedCache() throws { let cache = InMemoryNormalizedCache() + let store = ApolloStore(cache: cache) + let store2 = ApolloStore(cache: cache) let networkTransport1 = MockNetworkTransport(body: [ "data": [ @@ -404,7 +406,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { ] ] ] - ]) + ], store: store) let networkTransport2 = MockNetworkTransport(body: [ "data": [ @@ -418,10 +420,7 @@ class FetchQueryTests: XCTestCase, CacheTesting { ] ] ] - ]) - - let store = ApolloStore(cache: cache) - let store2 = ApolloStore(cache: cache) + ], store: store2) let client1 = ApolloClient(networkTransport: networkTransport1, store: store) let client2 = ApolloClient(networkTransport: networkTransport2, store: store2) diff --git a/Tests/ApolloCacheDependentTests/SQLiteCacheTests.swift b/Tests/ApolloCacheDependentTests/SQLiteCacheTests.swift index f7650ae728..c559a208d8 100644 --- a/Tests/ApolloCacheDependentTests/SQLiteCacheTests.swift +++ b/Tests/ApolloCacheDependentTests/SQLiteCacheTests.swift @@ -49,3 +49,4 @@ class SQLiteWatchQueryTests: WatchQueryTests { SQLiteTestCacheProvider.self } } + diff --git a/Tests/ApolloCacheDependentTests/StarWarsServerCachingRoundtripTests.swift b/Tests/ApolloCacheDependentTests/StarWarsServerCachingRoundtripTests.swift index 2ee998bc0b..0573ff2f84 100644 --- a/Tests/ApolloCacheDependentTests/StarWarsServerCachingRoundtripTests.swift +++ b/Tests/ApolloCacheDependentTests/StarWarsServerCachingRoundtripTests.swift @@ -40,8 +40,11 @@ class StarWarsServerCachingRoundtripTests: XCTestCase, CacheTesting { private func fetchAndLoadFromStore(query: Query, setupClient: ((ApolloClient) -> Void)? = nil, completionHandler: @escaping (_ data: Query.Data) -> Void) { withCache { (cache) in - let network = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!) let store = ApolloStore(cache: cache) + let provider = LegacyInterceptorProvider(store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: TestURL.starWarsServer.url) + let client = ApolloClient(networkTransport: network, store: store) if let setupClient = setupClient { diff --git a/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift b/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift index 3345bd6d9b..3cd0b252e7 100644 --- a/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift +++ b/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift @@ -5,40 +5,49 @@ import StarWarsAPI protocol TestConfig { - func network() -> HTTPNetworkTransport + func network(store: ApolloStore) -> NetworkTransport } class DefaultConfig: TestConfig { - let transport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!) - func network() -> HTTPNetworkTransport { - return transport + + func transport(with store: ApolloStore) -> NetworkTransport { + let provider = LegacyInterceptorProvider(store: store) + return RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: TestURL.starWarsServer.url) + } + + func network(store: ApolloStore) -> NetworkTransport { + return transport(with: store) } } class APQsConfig: TestConfig { - let transport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!, - enableAutoPersistedQueries: true) - func network() -> HTTPNetworkTransport { - return transport + + func transport(with store: ApolloStore) -> NetworkTransport { + let provider = LegacyInterceptorProvider(store: store) + return RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: TestURL.starWarsServer.url, + autoPersistQueries: true) + } + + func network(store: ApolloStore) -> NetworkTransport { + return transport(with: store) } } -class APQsWithGetMethodConfig: TestConfig, HTTPNetworkTransportRetryDelegate{ +class APQsWithGetMethodConfig: TestConfig { - var alreadyRetried = false - func networkTransport(_ networkTransport: HTTPNetworkTransport, receivedError error: Error, for request: URLRequest, response: URLResponse?, continueHandler: @escaping (HTTPNetworkTransport.ContinueAction) -> Void) { - continueHandler(!alreadyRetried ? .retry : .fail(error)) - alreadyRetried = true + func transport(with store: ApolloStore) -> NetworkTransport { + let provider = LegacyInterceptorProvider(store: store) + return RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: TestURL.starWarsServer.url, + autoPersistQueries: true, + useGETForPersistedQueryRetry: true) } - func network() -> HTTPNetworkTransport { - let transport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!, - enableAutoPersistedQueries: true, - useGETForPersistedQueryRetry: true) - transport.delegate = self - return transport + func network(store: ApolloStore) -> NetworkTransport { + return transport(with: store) } - } class StarWarsServerAPQsGetMethodTests: StarWarsServerTests { @@ -330,7 +339,7 @@ class StarWarsServerTests: XCTestCase, CacheTesting { withCache { (cache) in let store = ApolloStore(cache: cache) - let client = ApolloClient(networkTransport: config.network(), store: store) + let client = ApolloClient(networkTransport: config.network(store: store), store: store) let expectation = self.expectation(description: "Fetching query") @@ -363,7 +372,7 @@ class StarWarsServerTests: XCTestCase, CacheTesting { withCache { (cache) in let store = ApolloStore(cache: cache) - let client = ApolloClient(networkTransport: config.network(), store: store) + let client = ApolloClient(networkTransport: config.network(store: store), store: store) let expectation = self.expectation(description: "Performing mutation") diff --git a/Tests/ApolloCacheDependentTests/WatchQueryTests.swift b/Tests/ApolloCacheDependentTests/WatchQueryTests.swift index 30138520cb..7dde35f46c 100644 --- a/Tests/ApolloCacheDependentTests/WatchQueryTests.swift +++ b/Tests/ApolloCacheDependentTests/WatchQueryTests.swift @@ -20,7 +20,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { ] ] - withCache(initialRecords: initialRecords) { (cache) in + withCache(initialRecords: initialRecords) { cache in + let store = ApolloStore(cache: cache) let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -28,8 +29,7 @@ class WatchQueryTests: XCTestCase, CacheTesting { "__typename": "Droid" ] ] - ]) - let store = ApolloStore(cache: cache) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) var verifyResult: GraphQLResultHandler @@ -68,6 +68,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { watcher.refetch() waitForExpectations(timeout: 5, handler: nil) + + watcher.cancel() } } @@ -88,7 +90,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { "QUERY_ROOT.hero.friends.2": ["__typename": "Human", "name": "Leia Organa"], ] - withCache(initialRecords: initialRecords) { (cache) in + withCache(initialRecords: initialRecords) { cache in + let store = ApolloStore(cache: cache) let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -96,8 +99,7 @@ class WatchQueryTests: XCTestCase, CacheTesting { "__typename": "Droid" ] ] - ]) - let store = ApolloStore(cache: cache) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) let query = HeroAndFriendsNamesQuery() @@ -175,7 +177,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { "QUERY_ROOT.hero.friends.2": ["__typename": "Human", "name": "Leia Organa"], ] - withCache(initialRecords: initialRecords) { (cache) in + withCache(initialRecords: initialRecords) { cache in + let store = ApolloStore(cache: cache) let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -187,8 +190,7 @@ class WatchQueryTests: XCTestCase, CacheTesting { ] ] ] - ]) - let store = ApolloStore(cache: cache) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) let query = HeroAndFriendsNamesQuery() @@ -267,7 +269,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { "QUERY_ROOT.hero.friends.2": ["__typename": "Human", "name": "Leia Organa"], ] - withCache(initialRecords: initialRecords) { (cache) in + withCache(initialRecords: initialRecords) { cache in + let store = ApolloStore(cache: cache) let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -275,9 +278,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { "__typename": "Droid" ] ] - ]) + ], store: store) - let store = ApolloStore(cache: cache) let client = ApolloClient(networkTransport: networkTransport, store: store) let query = HeroAndFriendsNamesQuery() @@ -339,7 +341,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { ] ] - withCache(initialRecords: initialRecords) { (cache) in + withCache(initialRecords: initialRecords) { cache in + let store = ApolloStore(cache: cache) let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -348,8 +351,7 @@ class WatchQueryTests: XCTestCase, CacheTesting { "__typename": "Human" ] ] - ]) - let store = ApolloStore(cache: cache) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) client.store.cacheKeyForObject = { $0["id"] } @@ -411,7 +413,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { "LO": ["__typename": "Human", "id": "LO", "name": "Leia Organa"], ] - withCache(initialRecords: initialRecords) { (cache) in + withCache(initialRecords: initialRecords) { cache in + let store = ApolloStore(cache: cache) let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -424,8 +427,7 @@ class WatchQueryTests: XCTestCase, CacheTesting { ] ] ] - ]) - let store = ApolloStore(cache: cache) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) client.store.cacheKeyForObject = { $0["id"] } @@ -500,9 +502,9 @@ class WatchQueryTests: XCTestCase, CacheTesting { "QUERY_ROOT.hero.friends.2": ["__typename": "Human", "name": "Leia Organa"], ] withCache(initialRecords: initialRecords) { (cache) in - let networkTransport = MockNetworkTransport(body: [:]) - let store = ApolloStore(cache: cache) + let networkTransport = MockNetworkTransport(body: [:], store: store) + let client = ApolloClient(networkTransport: networkTransport, store: store) let query = HeroAndFriendsNamesQuery() @@ -578,6 +580,8 @@ class WatchQueryTests: XCTestCase, CacheTesting { func testWatchedQueryDependentKeysAreUpdated() { withCache { cache in + let store = ApolloStore(cache: cache) + store.cacheKeyForObject = { $0["id"] } let networkTransport = MockNetworkTransport(body: [ "data": [ "hero": [ @@ -593,17 +597,13 @@ class WatchQueryTests: XCTestCase, CacheTesting { ] ] ] - ]) + ], store: store) - let store = ApolloStore(cache: cache) let client = ApolloClient(networkTransport: networkTransport, store: store) - client.store.cacheKeyForObject = { $0["id"] } - let query = HeroAndFriendsNamesWithIDsQuery() let hasPicardFriendExpecation = self.expectation(description: "Has friend named Jean-Luc Picard") let hasHanSoloFriendExpecation = self.expectation(description: "Has friend named Han Solo") let initialFetchExpectation = self.expectation(description: "Initial fetch") - var isInitialFetch = true var expectedDependentKeys = [ "0.__typename", "0.friends", @@ -615,12 +615,13 @@ class WatchQueryTests: XCTestCase, CacheTesting { "QUERY_ROOT.hero", ] - _ = client.watch(query: query) { result in + var fetchCount = 0 + let watcher = client.watch(query: query) { result in defer { - if isInitialFetch { - isInitialFetch = false + if fetchCount == 0 { initialFetchExpectation.fulfill() } + fetchCount += 1 } switch result { case .success(let graphQLResult): @@ -674,7 +675,7 @@ class WatchQueryTests: XCTestCase, CacheTesting { /// Send an update that updates friend #11 on a different query - networkTransport.body = [ + networkTransport.updateBody(to: [ "data": [ "hero": [ "id": "2", @@ -689,12 +690,14 @@ class WatchQueryTests: XCTestCase, CacheTesting { ] ] ] - ] + ]) /// This fetch should trigger our watcher on friend #11 client.fetch(query: HeroAndFriendsNamesWithIDsQuery(episode: .newhope), cachePolicy: .fetchIgnoringCacheData) self.wait(for: [hasHanSoloFriendExpecation], timeout: 1) + + watcher.cancel() } } } diff --git a/Tests/ApolloCodegenTests/ApolloSchemaTests.swift b/Tests/ApolloCodegenTests/ApolloSchemaTests.swift index 2f18b658c1..fb7552e2ad 100644 --- a/Tests/ApolloCodegenTests/ApolloSchemaTests.swift +++ b/Tests/ApolloCodegenTests/ApolloSchemaTests.swift @@ -7,19 +7,18 @@ // import XCTest +import ApolloTestSupport @testable import ApolloCodegenLib class ApolloSchemaTests: XCTestCase { - - private lazy var endpointURL = URL(string: "http://localhost:8080/graphql")! - + func testCreatingOptionsWithDefaultParameters() throws { let sourceRoot = CodegenTestHelper.sourceRootURL() - let options = ApolloSchemaOptions(endpointURL: self.endpointURL, + let options = ApolloSchemaOptions(endpointURL: TestURL.starWarsServer.url, outputFolderURL: sourceRoot) let expectedOutputURL = sourceRoot.appendingPathComponent("schema.json") - XCTAssertEqual(options.endpointURL, self.endpointURL) + XCTAssertEqual(options.endpointURL, TestURL.starWarsServer.url) XCTAssertEqual(options.outputURL, expectedOutputURL) XCTAssertNil(options.apiKey) XCTAssertTrue(options.headers.isEmpty) @@ -41,11 +40,11 @@ class ApolloSchemaTests: XCTestCase { let options = ApolloSchemaOptions(schemaFileName: "different_name", schemaFileType: .schemaDefinitionLanguage, apiKey: apiKey, - endpointURL: self.endpointURL, + endpointURL: TestURL.starWarsServer.url, headers: headers, outputFolderURL: sourceRoot) XCTAssertEqual(options.apiKey, apiKey) - XCTAssertEqual(options.endpointURL, self.endpointURL) + XCTAssertEqual(options.endpointURL, TestURL.starWarsServer.url) XCTAssertEqual(options.headers, headers) let expectedOutputURL = sourceRoot.appendingPathComponent("different_name.graphql") @@ -64,7 +63,7 @@ class ApolloSchemaTests: XCTestCase { func testDownloadingSchemaAsJSON() throws { let testOutputFolderURL = CodegenTestHelper.outputFolderURL() - let options = ApolloSchemaOptions(endpointURL: self.endpointURL, + let options = ApolloSchemaOptions(endpointURL: TestURL.starWarsServer.url, outputFolderURL: testOutputFolderURL) // Delete anything existing at the output URL @@ -98,7 +97,7 @@ class ApolloSchemaTests: XCTestCase { let testOutputFolderURL = CodegenTestHelper.outputFolderURL() let options = ApolloSchemaOptions(schemaFileType: .schemaDefinitionLanguage, - endpointURL: self.endpointURL, + endpointURL: TestURL.starWarsServer.url, outputFolderURL: testOutputFolderURL) // Delete anything existing at the output URL diff --git a/Tests/ApolloSQLiteTests/CachePersistenceTests.swift b/Tests/ApolloSQLiteTests/CachePersistenceTests.swift index bdf5422b76..9334edc352 100644 --- a/Tests/ApolloSQLiteTests/CachePersistenceTests.swift +++ b/Tests/ApolloSQLiteTests/CachePersistenceTests.swift @@ -21,7 +21,7 @@ class CachePersistenceTests: XCTestCase { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) let networkExpectation = self.expectation(description: "Fetching query from network") @@ -77,7 +77,7 @@ class CachePersistenceTests: XCTestCase { "__typename": "Human" ] ] - ]) + ], store: store) let client = ApolloClient(networkTransport: networkTransport, store: store) let networkExpectation = self.expectation(description: "Fetching query from network") diff --git a/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift b/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift index 3c6f08b9be..7132f920e8 100644 --- a/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift +++ b/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift @@ -5,7 +5,7 @@ import StarWarsAPI class AutomaticPersistedQueriesTests: XCTestCase { - private final let endpoint = "http://localhost:8080/graphql" + private final let endpoint = TestURL.starWarsServer.url // MARK: - Helper Methods @@ -231,14 +231,23 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testRequestBody() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, client: mockClient) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery() - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") + let request = try XCTUnwrap(lastRequest, "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "POST") try self.validatePostBody(with: request, @@ -248,15 +257,24 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testRequestBodyWithVariable() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, client: mockClient) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery(episode: .jedi) - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + let request = try XCTUnwrap(lastRequest, "last request should not be nil") + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "POST") - + try validatePostBody(with: request, query: query, queryDocument: true) @@ -265,35 +283,51 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testRequestBodyForAPQsWithVariable() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - enableAutoPersistedQueries: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery(episode: .empire) - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - XCTAssertEqual(request.url?.host, network.url.host) + let request = try XCTUnwrap(lastRequest, "last request should not be nil") + + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "POST") try self.validatePostBody(with: request, query: query, persistedQuery: true) } - + func testMutationRequestBodyForAPQs() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - enableAutoPersistedQueries: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true) + + let expectation = self.expectation(description: "Mutation sent") let mutation = CreateAwesomeReviewMutation() - let _ = network.send(operation: mutation) { _ in } - - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - XCTAssertEqual(request.url?.host, network.url.host) + var lastRequest: URLRequest? + let _ = network.send(operation: mutation) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) + + let request = try XCTUnwrap(lastRequest, "last request should not be nil") + + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "POST") try self.validatePostBody(with: request, @@ -303,16 +337,24 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testQueryStringForAPQsUseGetMethod() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - enableAutoPersistedQueries: true, - useGETForPersistedQueryRetry: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true, + useGETForPersistedQueryRetry: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery() - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + let request = try XCTUnwrap(lastRequest, "last request should not be nil") + XCTAssertEqual(request.url?.host, network.endpointURL.host) try self.validateUrlParams(with: request, query: query, @@ -321,17 +363,25 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testQueryStringForAPQsUseGetMethodWithVariable() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - enableAutoPersistedQueries: true, - useGETForPersistedQueryRetry: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true, + useGETForPersistedQueryRetry: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery(episode: .empire) - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - XCTAssertEqual(request.url?.host, network.url.host) + let request = try XCTUnwrap(lastRequest, "last request should not be nil") + + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "GET") try self.validateUrlParams(with: request, @@ -341,16 +391,24 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testUseGETForQueriesRequest() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - useGETForQueries: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + useGETForQueries: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery() - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") + let request = try XCTUnwrap(lastRequest, "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "GET") try self.validateUrlParams(with: request, @@ -360,14 +418,23 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testNotUseGETForQueriesRequest() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, client: mockClient) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery() - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") + let request = try XCTUnwrap(lastRequest, "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "POST") try self.validatePostBody(with: request, @@ -377,16 +444,24 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testNotUseGETForQueriesAPQsRequest() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - enableAutoPersistedQueries: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery(episode: .empire) - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - XCTAssertEqual(request.url?.host, network.url.host) + let request = try XCTUnwrap(lastRequest, "last request should not be nil") + + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "POST") try self.validatePostBody(with: request, @@ -396,17 +471,25 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testUseGETForQueriesAPQsRequest() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - useGETForQueries: true, - enableAutoPersistedQueries: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true, + useGETForQueries: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery(episode: .empire) - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") + let request = try XCTUnwrap(lastRequest, "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "GET") try self.validateUrlParams(with: request, @@ -416,16 +499,25 @@ class AutomaticPersistedQueriesTests: XCTestCase { func testNotUseGETForQueriesAPQsGETRequest() throws { let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: URL(string: endpoint)!, - client: mockClient, - enableAutoPersistedQueries: true, - useGETForPersistedQueryRetry: true) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(client: mockClient, store: store) + let network = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.endpoint, + autoPersistQueries: true, + useGETForPersistedQueryRetry: true) + + let expectation = self.expectation(description: "Query sent") let query = HeroNameQuery(episode: .empire) - let _ = network.send(operation: query) { _ in } + var lastRequest: URLRequest? + let _ = network.send(operation: query) { _ in + lastRequest = mockClient.lastRequest.value + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 1) + + let request = try XCTUnwrap(lastRequest, "last request should not be nil") - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.url?.host, network.endpointURL.host) XCTAssertEqual(request.httpMethod, "GET") try self.validateUrlParams(with: request, diff --git a/Tests/ApolloTests/BlindRetryingTestInterceptor.swift b/Tests/ApolloTests/BlindRetryingTestInterceptor.swift new file mode 100644 index 0000000000..6e89f372be --- /dev/null +++ b/Tests/ApolloTests/BlindRetryingTestInterceptor.swift @@ -0,0 +1,25 @@ +// +// BlindRetryingTestInterceptor.swift +// ApolloTests +// +// Created by Ellen Shapiro on 8/19/20. +// Copyright © 2020 Apollo GraphQL. All rights reserved. +// + +import Foundation +import Apollo + +// An interceptor which blindly retries every time it receives a request. +class BlindRetryingTestInterceptor: ApolloInterceptor { + var hitCount = 0 + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + self.hitCount += 1 + chain.retry(request: request, + completion: completion) + } +} diff --git a/Tests/ApolloTests/GETTransformerTests.swift b/Tests/ApolloTests/GETTransformerTests.swift index 73d4a95b93..edb3941373 100644 --- a/Tests/ApolloTests/GETTransformerTests.swift +++ b/Tests/ApolloTests/GETTransformerTests.swift @@ -8,11 +8,12 @@ import XCTest @testable import Apollo +import ApolloTestSupport import StarWarsAPI class GETTransformerTests: XCTestCase { private let requestCreator = ApolloRequestCreator() - private lazy var url = URL(string: "http://localhost:8080/graphql")! + private lazy var url = TestURL.starWarsServer.url func testEncodingQueryWithSingleParameter() { let operation = HeroNameQuery(episode: .empire) diff --git a/Tests/ApolloTests/HTTPTransportTests.swift b/Tests/ApolloTests/HTTPTransportTests.swift deleted file mode 100644 index 3ccc3f0a9b..0000000000 --- a/Tests/ApolloTests/HTTPTransportTests.swift +++ /dev/null @@ -1,444 +0,0 @@ -// -// HTTPTransportTests.swift -// ApolloTests -// -// Created by Ellen Shapiro on 7/1/19. -// Copyright © 2019 Apollo GraphQL. All rights reserved. -// - -import XCTest -@testable import Apollo -import ApolloTestSupport -import StarWarsAPI -import ApolloTestSupport - -class HTTPTransportTests: XCTestCase { - - private var updatedHeaders: [String: String]? - private var shouldSend = true - - private var completedRequest: URLRequest? - private var completedData: Data? - private var completedResponse: URLResponse? - private var completedError: Error? - - private var shouldModifyURLInWillSend = false - private var retryCount = 0 - - private var graphQlErrors = [GraphQLError]() - - private lazy var url = URL(string: "http://localhost:8080/graphql")! - private lazy var networkTransport: HTTPNetworkTransport = { - let transport = HTTPNetworkTransport(url: self.url, - useGETForQueries: true) - transport.delegate = self - return transport - }() - - private func validateHeroNameQueryResponse(result: Result, Error>, - expectation: XCTestExpectation, - file: StaticString = #file, - line: UInt = #line) { - defer { - expectation.fulfill() - } - - switch result { - case .success(let graphQLResponse): - guard - let dictionary = graphQLResponse.body as? [String: AnyHashable], - let dataDict = dictionary["data"] as? [String: AnyHashable], - let heroDict = dataDict["hero"] as? [String: AnyHashable], - let name = heroDict["name"] as? String else { - XCTFail("No hero for you!", - file: file, - line: line) - return - } - - XCTAssertEqual(name, - "R2-D2", - file: file, - line: line) - case .failure(let error): - XCTFail("Unexpected response error: \(error)", - file: file, - line: line) - } - } - - func testPreflightDelegateTellingRequestNotToSend() { - self.shouldSend = false - - let expectation = self.expectation(description: "Send operation completed") - let cancellable = self.networkTransport.send(operation: HeroNameQuery(episode: .empire)) { result in - - defer { - expectation.fulfill() - } - - switch result { - case .success: - XCTFail("Expected error not received when telling delegate not to send!") - case .failure(let error): - switch error { - case GraphQLHTTPRequestError.cancelledByDelegate: - // Correct! - break - default: - XCTFail("Expected `cancelledByDelegate`, got \(error)") - } - } - } - - guard (cancellable as? EmptyCancellable) != nil else { - XCTFail("Wrong cancellable type returned!") - cancellable.cancel() - expectation.fulfill() - return - } - - // This should fail without hitting the network. - self.wait(for: [expectation], timeout: 1) - - // The request shouldn't have fired, so all these objects should be nil - XCTAssertNil(self.completedRequest) - XCTAssertNil(self.completedData) - XCTAssertNil(self.completedResponse) - XCTAssertNil(self.completedError) - XCTAssertEqual(self.retryCount, 0) - } - - func testPreflightDelgateModifyingRequest() { - self.updatedHeaders = ["Authorization": "Bearer HelloApollo"] - - let expectation = self.expectation(description: "Send operation completed") - let cancellable = self.networkTransport.send(operation: HeroNameQuery()) { result in - self.validateHeroNameQueryResponse(result: result, expectation: expectation) - } - - guard - let task = cancellable as? URLSessionTask, - let headers = task.currentRequest?.allHTTPHeaderFields else { - cancellable.cancel() - expectation.fulfill() - return - } - - XCTAssertEqual(headers["Authorization"], "Bearer HelloApollo") - - // This will come through after hitting the network. - self.wait(for: [expectation], timeout: 10) - - // We should have everything except an error since the request should have proceeded - XCTAssertNotNil(self.completedRequest) - XCTAssertNotNil(self.completedData) - XCTAssertNotNil(self.completedResponse) - XCTAssertNil(self.completedError) - XCTAssertEqual(self.retryCount, 0) - } - - func testPreflightDelegateNeitherModifyingOrStoppingRequest() { - let expectation = self.expectation(description: "Send operation completed") - let cancellable = self.networkTransport.send(operation: HeroNameQuery()) { result in - self.validateHeroNameQueryResponse(result: result, expectation: expectation) - } - - guard - let task = cancellable as? URLSessionTask, - let headers = task.currentRequest?.allHTTPHeaderFields else { - XCTFail("Couldn't access header fields!") - cancellable.cancel() - expectation.fulfill() - return - } - - XCTAssertNil(headers["Authorization"]) - - // This will come through after hitting the network. - self.wait(for: [expectation], timeout: 10) - - // We should have everything except an error since the request should have proceeded - XCTAssertNotNil(self.completedRequest) - XCTAssertNotNil(self.completedData) - XCTAssertNotNil(self.completedResponse) - XCTAssertNil(self.completedError) - XCTAssertEqual(self.retryCount, 0) - } - - func testRetryDelegateRetriesAfterUnsuccessfulAttempts() { - self.shouldModifyURLInWillSend = true - let expectation = self.expectation(description: "Send operation completed") - - let cancellable = self.networkTransport.send(operation: HeroNameQuery()) { result in - // This should have retried twice - the first time `shouldModifyURLInWillSend` shoud remain the same and it'll fail again. - XCTAssertEqual(self.retryCount, 2) - self.validateHeroNameQueryResponse(result: result, expectation: expectation) - } - - guard - let task = cancellable as? URLSessionTask, - let url = task.currentRequest?.url else { - XCTFail("Couldn't get url!") - cancellable.cancel() - expectation.fulfill() - return - } - - XCTAssertEqual(url, self.url) - - self.wait(for: [expectation], timeout: 10) - } - - func testRetryDelegateReturnsApolloError() throws { - class MockRetryDelegate: HTTPNetworkTransportRetryDelegate { - func networkTransport(_ networkTransport: HTTPNetworkTransport, - receivedError error: Error, - for request: URLRequest, - response: URLResponse?, - continueHandler: @escaping (HTTPNetworkTransport.ContinueAction) -> Void) { - continueHandler(.fail(error)) - } - } - - let mockRetryDelegate = MockRetryDelegate() - - let transport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql_non_existant")!) - transport.delegate = mockRetryDelegate - - let expectationErrorResponse = self.expectation(description: "Send operation completed") - - let _ = transport.send(operation: HeroNameQuery()) { result in - switch result { - case .success: - XCTFail() - expectationErrorResponse.fulfill() - case .failure(let error): - XCTAssertTrue(error is GraphQLHTTPResponseError) - expectationErrorResponse.fulfill() - } - } - - wait(for: [expectationErrorResponse], timeout: 1) - } - - func testRetryDelegateReturnsCustomError() throws { - enum MockError: Error, Equatable { - case customError - } - - class MockRetryDelegate: HTTPNetworkTransportRetryDelegate { - func networkTransport(_ networkTransport: HTTPNetworkTransport, - receivedError error: Error, - for request: URLRequest, - response: URLResponse?, - continueHandler: @escaping (HTTPNetworkTransport.ContinueAction) -> Void) { - continueHandler(.fail(MockError.customError)) - } - } - - let mockRetryDelegate = MockRetryDelegate() - - let transport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql_non_existant")!) - transport.delegate = mockRetryDelegate - - let expectationErrorResponse = self.expectation(description: "Send operation completed") - - let _ = transport.send(operation: HeroNameQuery()) { result in - switch result { - case .success: - XCTFail() - expectationErrorResponse.fulfill() - case .failure(let error): - XCTAssertTrue(error is MockError) - expectationErrorResponse.fulfill() - } - } - - wait(for: [expectationErrorResponse], timeout: 1) - } - - func testEquality() { - let identicalTransport = HTTPNetworkTransport(url: self.url, - client: self.networkTransport.client, - useGETForQueries: true) - XCTAssertEqual(self.networkTransport, identicalTransport) - - let nonIdenticalTransport = HTTPNetworkTransport(url: self.url, - client: self.networkTransport.client) - XCTAssertNotEqual(self.networkTransport, nonIdenticalTransport) - } - - func testErrorDelegateWithErrors() throws { - self.retryCount = 0 - self.graphQlErrors = [] - let query = HeroNameQuery() - // TODO: Replace this with once it is codable https://github.com/apollographql/apollo-ios/issues/467 - let body = ["errors": [["message": "Test graphql error"]]] - - let mockClient = MockURLSessionClient() - mockClient.response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) - mockClient.data = try JSONSerialization.data(withJSONObject: body, options: .prettyPrinted) - let network = HTTPNetworkTransport(url: url, - client: mockClient) - network.delegate = self - let expectation = self.expectation(description: "Send operation completed") - - let _ = network.send(operation: query) { result in - switch result { - case .success: - expectation.fulfill() - case .failure: - break - } - } - - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - XCTAssertEqual(request.url?.host, network.url.host) - XCTAssertEqual(request.httpMethod, "POST") - - XCTAssertEqual(self.graphQlErrors.count, 1) - XCTAssertEqual(retryCount, 1) - wait(for: [expectation], timeout: 1) - } - - func testErrorDelegateWithNoErrors() throws { - self.retryCount = 0 - self.graphQlErrors = [] - let query = HeroNameQuery() - // TODO: Replace this with once it is codable https://github.com/apollographql/apollo-ios/issues/467 - let body = ["errors": []] - - let mockClient = MockURLSessionClient() - mockClient.response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) - mockClient.data = try JSONSerialization.data(withJSONObject: body, options: .prettyPrinted) - let network = HTTPNetworkTransport(url: url, - client: mockClient) - network.delegate = self - let expectation = self.expectation(description: "Send operation completed") - - let _ = network.send(operation: query) { result in - switch result { - case .success: - expectation.fulfill() - case .failure: - break - } - } - - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - XCTAssertEqual(request.url?.host, network.url.host) - XCTAssertEqual(request.httpMethod, "POST") - XCTAssertEqual(self.retryCount, 0) - XCTAssertEqual(self.graphQlErrors.count, 0) - wait(for: [expectation], timeout: 1) - } - - func testClientNameAndVersionHeadersAreSent() throws { - let mockClient = MockURLSessionClient() - let network = HTTPNetworkTransport(url: self.url, - client: mockClient) - let query = HeroNameQuery(episode: .empire) - let _ = network.send(operation: query) { _ in } - - let request = try XCTUnwrap(mockClient.lastRequest, - "last request should not be nil") - - let clientName = try XCTUnwrap(request.value(forHTTPHeaderField: HTTPNetworkTransport.headerFieldNameApolloClientName), - "Client name on last request was nil!") - - XCTAssertFalse(clientName.isEmpty, "Client name was empty!") - XCTAssertEqual(clientName, network.clientName) - - let clientVersion = try XCTUnwrap(request.value(forHTTPHeaderField: HTTPNetworkTransport.headerFieldNameApolloClientVersion), - "Client version on last request was nil!") - - XCTAssertFalse(clientVersion.isEmpty, "Client version was empty!") - XCTAssertEqual(clientVersion, network.clientVersion) - } -} - -// MARK: - HTTPNetworkTransportPreflightDelegate - -extension HTTPTransportTests: HTTPNetworkTransportPreflightDelegate { - func networkTransport(_ networkTransport: HTTPNetworkTransport, shouldSend request: URLRequest) -> Bool { - return self.shouldSend - } - - func networkTransport(_ networkTransport: HTTPNetworkTransport, willSend request: inout URLRequest) { - if self.shouldModifyURLInWillSend { - // This undoes any changes to the URL done by the GET request, which will cause the request to fail. - request.url = self.url - } - - guard let headers = self.updatedHeaders else { - return - } - - headers.forEach { tuple in - let (key, value) = tuple - request.addValue(value, forHTTPHeaderField: key) - } - } -} - -// MARK: - HTTPNetworkTransportTaskCompletedDelegate - -extension HTTPTransportTests: HTTPNetworkTransportTaskCompletedDelegate { - - func networkTransport(_ networkTransport: HTTPNetworkTransport, - didCompleteRawTaskForRequest request: URLRequest, - withData data: Data?, - response: URLResponse?, - error: Error?) { - self.completedRequest = request - self.completedData = data - self.completedResponse = response - self.completedError = error - } -} - -// MARK: - HTTPNetworkTransportRetryDelegate - -extension HTTPTransportTests: HTTPNetworkTransportRetryDelegate { - - func networkTransport(_ networkTransport: HTTPNetworkTransport, - receivedError error: Error, - for request: URLRequest, - response: URLResponse?, - continueHandler: @escaping (HTTPNetworkTransport.ContinueAction) -> Void) { - guard let graphQLError = error as? GraphQLHTTPResponseError else { - continueHandler(.fail(error)) - return - } - - switch graphQLError.kind { - case .errorResponse: - self.retryCount += 1 - if retryCount > 1 { - self.shouldModifyURLInWillSend = false - } - continueHandler(.retry) - case .invalidResponse: - continueHandler(.fail(error)) - case .persistedQueryNotFound, - .persistedQueryNotSupported: - continueHandler(.fail(error)) - } - } -} - -// MARK: - HTTPNetworkTransportGraphQLErrorDelegate - -extension HTTPTransportTests: HTTPNetworkTransportGraphQLErrorDelegate { - func networkTransport(_ networkTransport: HTTPNetworkTransport, receivedGraphQLErrors errors: [GraphQLError], retryHandler: @escaping (Bool) -> Void) { - self.retryCount += 1 - let shouldRetry = retryCount == 2 - self.graphQlErrors = errors - retryHandler(shouldRetry) - } -} diff --git a/Tests/ApolloTests/InterceptorTests.swift b/Tests/ApolloTests/InterceptorTests.swift new file mode 100644 index 0000000000..3f31f2a645 --- /dev/null +++ b/Tests/ApolloTests/InterceptorTests.swift @@ -0,0 +1,281 @@ +// +// InterceptorTests.swift +// Apollo +// +// Created by Ellen Shapiro on 8/19/20. +// Copyright © 2020 Apollo GraphQL. All rights reserved. +// + +import XCTest +import Apollo +import ApolloTestSupport +import StarWarsAPI + +class InterceptorTests: XCTestCase { + + // MARK: - Retry Interceptor + + func testMaxRetryInterceptorErrorsAfterMaximumRetries() { + class TestProvider: InterceptorProvider { + let testInterceptor = BlindRetryingTestInterceptor() + let retryCount = 15 + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + [ + MaxRetryInterceptor(maxRetriesAllowed: self.retryCount), + self.testInterceptor, + NetworkFetchInterceptor(client: MockURLSessionClient()), + ] + } + } + + let testProvider = TestProvider() + let network = RequestChainNetworkTransport(interceptorProvider: testProvider, + endpointURL: TestURL.mockServer.url) + + let expectation = self.expectation(description: "Reqeust sent") + + let operation = HeroNameQuery() + _ = network.send(operation: operation) { result in + defer { + expectation.fulfill() + } + + switch result { + case .success: + XCTFail("This should not have worked") + case .failure(let error): + switch error { + case MaxRetryInterceptor.RetryError.hitMaxRetryCount(let count, let operationName): + XCTAssertEqual(count, testProvider.retryCount) + // There should be one more hit than retries since it will be hit on the original call + XCTAssertEqual(testProvider.testInterceptor.hitCount, testProvider.retryCount + 1) + XCTAssertEqual(operationName, operation.operationName) + default: + XCTFail("Unexpected error type: \(error)") + } + } + } + + self.wait(for: [expectation], timeout: 1) + } + + func testRetryInterceptorDoesNotErrorIfRetriedFewerThanMaxTimes() { + class TestProvider: InterceptorProvider { + let testInterceptor = RetryToCountThenSucceedInterceptor(timesToCallRetry: 2) + let retryCount = 3 + + let mockClient: MockURLSessionClient = { + let client = MockURLSessionClient() + client.response = HTTPURLResponse(url: TestURL.mockServer.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + let json = [ + "data": [ + "hero": [ + "name": "Luke Skywalker", + "__typename": "Human" + ] + ] + ] + let data = try! JSONSerializationFormat.serialize(value: json) + client.data = data + return client + }() + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + [ + MaxRetryInterceptor(maxRetriesAllowed: self.retryCount), + self.testInterceptor, + NetworkFetchInterceptor(client: self.mockClient), + LegacyParsingInterceptor(), + ] + } + } + + let testProvider = TestProvider() + let network = RequestChainNetworkTransport(interceptorProvider: testProvider, + endpointURL: TestURL.mockServer.url) + + let expectation = self.expectation(description: "Reqeust sent") + + let operation = HeroNameQuery() + _ = network.send(operation: operation) { result in + defer { + expectation.fulfill() + } + + switch result { + case .success(let graphQLResult): + XCTAssertEqual(graphQLResult.data?.hero?.name, "Luke Skywalker") + XCTAssertEqual(testProvider.testInterceptor.timesRetryHasBeenCalled, testProvider.testInterceptor.timesToCallRetry) + case .failure(let error): + XCTFail("Unexpected error: \(error.localizedDescription)") + } + } + + self.wait(for: [expectation], timeout: 1) + } + + // MARK: - Legacy Parsing Interceptor + + func testLegacyParsingInterceptorFailsWithEmptyData() { + class TestProvider: InterceptorProvider { + let mockClient: MockURLSessionClient = { + let client = MockURLSessionClient() + client.response = HTTPURLResponse(url: TestURL.mockServer.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + client.data = Data() + return client + }() + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + [ + NetworkFetchInterceptor(client: self.mockClient), + LegacyParsingInterceptor(), + ] + } + } + + let network = RequestChainNetworkTransport(interceptorProvider: TestProvider(), + endpointURL: TestURL.mockServer.url) + + let expectation = self.expectation(description: "Reqeust sent") + + _ = network.send(operation: HeroNameQuery()) { result in + defer { + expectation.fulfill() + } + + switch result { + case .success: + XCTFail("This should not have succeeded") + case .failure(let error): + switch error { + case LegacyParsingInterceptor.LegacyParsingError.couldNotParseToLegacyJSON(let data): + XCTAssertTrue(data.isEmpty) + default: + XCTFail("Unexpected error type: \(error.localizedDescription)") + } + } + } + + self.wait(for: [expectation], timeout: 1) + } + + // MARK: - Response Code Interceptor + + func testResponseCodeInterceptorLetsAnyDataThroughWithValidResponseCode() { + class TestProvider: InterceptorProvider { + let mockClient: MockURLSessionClient = { + let client = MockURLSessionClient() + client.response = HTTPURLResponse(url: TestURL.mockServer.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + client.data = Data() + return client + }() + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + [ + NetworkFetchInterceptor(client: self.mockClient), + ResponseCodeInterceptor(), + LegacyParsingInterceptor() + ] + } + } + + let network = RequestChainNetworkTransport(interceptorProvider: TestProvider(), + endpointURL: TestURL.mockServer.url) + + let expectation = self.expectation(description: "Reqeust sent") + + _ = network.send(operation: HeroNameQuery()) { result in + defer { + expectation.fulfill() + } + + switch result { + case .success: + XCTFail("This should not have succeeded") + case .failure(let error): + switch error { + case LegacyParsingInterceptor.LegacyParsingError.couldNotParseToLegacyJSON(let data): + XCTAssertTrue(data.isEmpty) + default: + XCTFail("Unexpected error type: \(error.localizedDescription)") + } + } + } + + self.wait(for: [expectation], timeout: 1) + } + + func testResponseCodeInterceptorDoesNotLetDataThroughWithInvalidResponseCode() { + class TestProvider: InterceptorProvider { + let mockClient: MockURLSessionClient = { + let client = MockURLSessionClient() + client.response = HTTPURLResponse(url: TestURL.mockServer.url, + statusCode: 401, + httpVersion: nil, + headerFields: nil) + let json = [ + "data": [ + "hero": [ + "name": "Luke Skywalker", + "__typename": "Human" + ] + ] + ] + let data = try! JSONSerializationFormat.serialize(value: json) + client.data = data + return client + }() + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + [ + NetworkFetchInterceptor(client: self.mockClient), + ResponseCodeInterceptor(), + LegacyParsingInterceptor(), + ] + } + } + + let network = RequestChainNetworkTransport(interceptorProvider: TestProvider(), + endpointURL: TestURL.mockServer.url) + + let expectation = self.expectation(description: "Reqeust sent") + + _ = network.send(operation: HeroNameQuery()) { result in + defer { + expectation.fulfill() + } + + switch result { + case .success: + XCTFail("This should not have succeeded") + case .failure(let error): + switch error { + case ResponseCodeInterceptor.ResponseCodeError.invalidResponseCode(response: let response, let rawData): + XCTAssertEqual(response?.statusCode, 401) + + guard + let data = rawData, + let dataString = String(bytes: data, encoding: .utf8) else { + XCTFail("Incorrect data returned with error") + return + } + + XCTAssertEqual(dataString, "{\"data\":{\"hero\":{\"__typename\":\"Human\",\"name\":\"Luke Skywalker\"}}}") + default: + XCTFail("Unexpected error type: \(error.localizedDescription)") + } + } + } + + self.wait(for: [expectation], timeout: 1) + } +} diff --git a/Tests/ApolloTests/RequestChainTests.swift b/Tests/ApolloTests/RequestChainTests.swift new file mode 100644 index 0000000000..64d8e4cbd2 --- /dev/null +++ b/Tests/ApolloTests/RequestChainTests.swift @@ -0,0 +1,108 @@ +// +// RequestChainTests.swift +// Apollo +// +// Created by Ellen Shapiro on 7/14/20. +// Copyright © 2020 Apollo GraphQL. All rights reserved. +// + +import XCTest +import Apollo +import ApolloTestSupport +import StarWarsAPI + +class RequestChainTests: XCTestCase { + + lazy var legacyClient: ApolloClient = { + let url = TestURL.starWarsServer.url + + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(store: store) + let transport = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: url) + + return ApolloClient(networkTransport: transport) + }() + + func testLoading() { + let expectation = self.expectation(description: "loaded With legacy client") + legacyClient.fetch(query: HeroNameQuery()) { result in + switch result { + case .success(let graphQLResult): + XCTAssertEqual(graphQLResult.source, .server) + XCTAssertEqual(graphQLResult.data?.hero?.name, "R2-D2") + case .failure(let error): + XCTFail("Unexpected error: \(error)") + + } + expectation.fulfill() + } + + self.wait(for: [expectation], timeout: 10) + } + + func testInitialLoadFromNetworkAndSecondaryLoadFromCache() { + let initialLoadExpectation = self.expectation(description: "loaded With legacy client") + legacyClient.fetch(query: HeroNameQuery()) { result in + switch result { + case .success(let graphQLResult): + XCTAssertEqual(graphQLResult.source, .server) + XCTAssertEqual(graphQLResult.data?.hero?.name, "R2-D2") + case .failure(let error): + XCTFail("Unexpected error: \(error)") + + } + initialLoadExpectation.fulfill() + } + + self.wait(for: [initialLoadExpectation], timeout: 10) + + let secondLoadExpectation = self.expectation(description: "loaded With legacy client") + legacyClient.fetch(query: HeroNameQuery()) { result in + switch result { + case .success(let graphQLResult): + XCTAssertEqual(graphQLResult.source, .cache) + XCTAssertEqual(graphQLResult.data?.hero?.name, "R2-D2") + case .failure(let error): + XCTFail("Unexpected error: \(error)") + + } + secondLoadExpectation.fulfill() + } + + self.wait(for: [secondLoadExpectation], timeout: 10) + } + + func testEmptyInterceptorArrayReturnsCorrectError() { + class TestProvider: InterceptorProvider { + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + [] + } + } + + let transport = RequestChainNetworkTransport(interceptorProvider: TestProvider(), + endpointURL: TestURL.mockServer.url) + let expectation = self.expectation(description: "kickoff failed") + _ = transport.send(operation: HeroNameQuery()) { result in + defer { + expectation.fulfill() + } + + switch result { + case .success: + XCTFail("This should not have succeeded") + case .failure(let error): + switch error { + case RequestChain.ChainError.noInterceptors: + // This is what we want. + break + default: + XCTFail("Incorrect error for no interceptors: \(error)") + } + } + } + + + self.wait(for: [expectation], timeout: 1) + } +} diff --git a/Tests/ApolloTests/RetryToCountThenSucceedInterceptor.swift b/Tests/ApolloTests/RetryToCountThenSucceedInterceptor.swift new file mode 100644 index 0000000000..8f31ebcae6 --- /dev/null +++ b/Tests/ApolloTests/RetryToCountThenSucceedInterceptor.swift @@ -0,0 +1,35 @@ +// +// RetryToCountThenSucceedInterceptor.swift +// ApolloTests +// +// Created by Ellen Shapiro on 8/19/20. +// Copyright © 2020 Apollo GraphQL. All rights reserved. +// + +import Foundation +import Apollo + +class RetryToCountThenSucceedInterceptor: ApolloInterceptor { + let timesToCallRetry: Int + var timesRetryHasBeenCalled = 0 + + init(timesToCallRetry: Int) { + self.timesToCallRetry = timesToCallRetry + } + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + if self.timesRetryHasBeenCalled < self.timesToCallRetry { + self.timesRetryHasBeenCalled += 1 + chain.retry(request: request, + completion: completion) + } else { + chain.proceedAsync(request: request, + response: response, + completion: completion) + } + } +} diff --git a/Tests/ApolloTests/TestCustomRequestCreator.swift b/Tests/ApolloTests/TestCustomRequestCreator.swift index 97e2b85d33..5f08144340 100644 --- a/Tests/ApolloTests/TestCustomRequestCreator.swift +++ b/Tests/ApolloTests/TestCustomRequestCreator.swift @@ -54,7 +54,11 @@ struct TestCustomRequestCreator: RequestCreator { } try files.forEach { - formData.appendPart(inputStream: try $0.generateInputStream(), contentLength: $0.contentLength, name: $0.fieldName, contentType: $0.mimeType, filename: $0.originalName) + formData.appendPart(inputStream: try $0.generateInputStream(), + contentLength: $0.contentLength, + name: $0.fieldName, + contentType: $0.mimeType, + filename: $0.originalName) } return formData diff --git a/Tests/ApolloTests/UploadTests.swift b/Tests/ApolloTests/UploadTests.swift index 3f1010c236..98d7986c85 100644 --- a/Tests/ApolloTests/UploadTests.swift +++ b/Tests/ApolloTests/UploadTests.swift @@ -1,12 +1,20 @@ import XCTest import Apollo +import ApolloTestSupport import UploadAPI class UploadTests: XCTestCase { - let uploadClientURL = URL(string: "http://localhost:4000")! + let uploadClientURL = TestURL.uploadServer.url - lazy var client = ApolloClient(url: self.uploadClientURL) + lazy var client: ApolloClient = { + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let provider = LegacyInterceptorProvider(store: store) + let transport = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: self.uploadClientURL) + + return ApolloClient(networkTransport: transport) + }() override static func tearDown() { // Recreate the uploads folder at the end of all tests in this suite to avoid having one billion files in there diff --git a/Tests/ApolloWebsocketTests/MockWebSocket.swift b/Tests/ApolloWebsocketTests/MockWebSocket.swift index e478bb27ca..4425710b3f 100644 --- a/Tests/ApolloWebsocketTests/MockWebSocket.swift +++ b/Tests/ApolloWebsocketTests/MockWebSocket.swift @@ -1,5 +1,6 @@ import Starscream import Foundation +import ApolloTestSupport @testable import ApolloWebSocket class MockWebSocket: ApolloWebSocketClient { @@ -16,7 +17,7 @@ class MockWebSocket: ApolloWebSocketClient { } public init() { - self.request = URLRequest(url: URL(string: "http://localhost:8080")!) + self.request = URLRequest(url: TestURL.starWarsServer.url) } open func reportDidConnect() { diff --git a/Tests/ApolloWebsocketTests/MockWebSocketTests.swift b/Tests/ApolloWebsocketTests/MockWebSocketTests.swift index 9708515422..041164c451 100644 --- a/Tests/ApolloWebsocketTests/MockWebSocketTests.swift +++ b/Tests/ApolloWebsocketTests/MockWebSocketTests.swift @@ -1,5 +1,6 @@ import XCTest import Apollo +import ApolloTestSupport @testable import ApolloWebSocket import StarWarsAPI @@ -20,7 +21,7 @@ class MockWebSocketTests: XCTestCase { super.setUp() WebSocketTransport.provider = MockWebSocket.self - networkTransport = WebSocketTransport(request: URLRequest(url: URL(string: "http://localhost/dummy_url")!)) + networkTransport = WebSocketTransport(request: URLRequest(url: TestURL.mockServer.url)) client = ApolloClient(networkTransport: networkTransport!) } diff --git a/Tests/ApolloWebsocketTests/SplitNetworkTransportTests.swift b/Tests/ApolloWebsocketTests/SplitNetworkTransportTests.swift index b67c150521..1b3ed4aced 100644 --- a/Tests/ApolloWebsocketTests/SplitNetworkTransportTests.swift +++ b/Tests/ApolloWebsocketTests/SplitNetworkTransportTests.swift @@ -8,49 +8,50 @@ import Foundation import XCTest import Apollo +import ApolloTestSupport @testable import ApolloWebSocket class SplitNetworkTransportTests: XCTestCase { - private let httpName = "TestHTTPNetworkTransport" - private let httpVersion = "TestHTTPNetworkTransportVersion" + private let mockTransportName = "TestMockNetworkTransport" + private let mockTransportVersion = "TestMockNetworkTransportVersion" private let webSocketName = "TestWebSocketTransport" private let webSocketVersion = "TestWebSocketTransportVersion" - private lazy var httpTransport: HTTPNetworkTransport = { - let url = URL(string: "http://localhost:8080/graphql")! - let transport = HTTPNetworkTransport(url: url) + private lazy var mockTransport: MockNetworkTransport = { + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let transport = MockNetworkTransport(body: JSONObject(), + store: store) - transport.clientName = self.httpName - transport.clientVersion = self.httpVersion + transport.clientName = self.mockTransportName + transport.clientVersion = self.mockTransportVersion return transport }() private lazy var webSocketTransport: WebSocketTransport = { - let url = URL(string: "ws://localhost:8080/websocket")! - let request = URLRequest(url: url) + let request = URLRequest(url: TestURL.starWarsWebSocket.url) return WebSocketTransport(request: request, clientName: self.webSocketName, clientVersion: self.webSocketVersion) }() private lazy var splitTransport = SplitNetworkTransport( - httpNetworkTransport: self.httpTransport, + uploadingNetworkTransport: self.mockTransport, webSocketNetworkTransport: self.webSocketTransport ) func testGettingSplitClientNameWithDifferentNames() { let splitName = self.splitTransport.clientName XCTAssertTrue(splitName.hasPrefix("SPLIT_")) - XCTAssertTrue(splitName.contains(self.httpName)) + XCTAssertTrue(splitName.contains(self.mockTransportName)) XCTAssertTrue(splitName.contains(self.webSocketName)) } func testGettingSplitClientVersionWithDifferentVersions() { let splitVersion = self.splitTransport.clientVersion XCTAssertTrue(splitVersion.hasPrefix("SPLIT_")) - XCTAssertTrue(splitVersion.contains(self.httpVersion)) + XCTAssertTrue(splitVersion.contains(self.mockTransportVersion)) XCTAssertTrue(splitVersion.contains(self.webSocketVersion)) } @@ -58,7 +59,7 @@ class SplitNetworkTransportTests: XCTestCase { let splitName = "TestSplitClientName" self.webSocketTransport.clientName = splitName - self.httpTransport.clientName = splitName + self.mockTransport.clientName = splitName XCTAssertEqual(self.splitTransport.clientName, splitName) } @@ -67,7 +68,7 @@ class SplitNetworkTransportTests: XCTestCase { let splitVersion = "TestSplitClientVersion" self.webSocketTransport.clientVersion = splitVersion - self.httpTransport.clientVersion = splitVersion + self.mockTransport.clientVersion = splitVersion XCTAssertEqual(self.splitTransport.clientVersion, splitVersion) } diff --git a/Tests/ApolloWebsocketTests/StarWarsSubscriptionTests.swift b/Tests/ApolloWebsocketTests/StarWarsSubscriptionTests.swift index ec3c40cc5d..028ffc32d0 100644 --- a/Tests/ApolloWebsocketTests/StarWarsSubscriptionTests.swift +++ b/Tests/ApolloWebsocketTests/StarWarsSubscriptionTests.swift @@ -6,7 +6,6 @@ import StarWarsAPI import Starscream class StarWarsSubscriptionTests: XCTestCase { - let SERVER = "ws://localhost:8080/websocket" let concurrentQueue = DispatchQueue(label: "com.apollographql.testing", attributes: .concurrent) var client: ApolloClient! @@ -22,7 +21,7 @@ class StarWarsSubscriptionTests: XCTestCase { self.connectionStartedExpectation = self.expectation(description: "Web socket connected") WebSocketTransport.provider = ApolloWebSocket.self - webSocketTransport = WebSocketTransport(request: URLRequest(url: URL(string: SERVER)!)) + webSocketTransport = WebSocketTransport(request: URLRequest(url: TestURL.starWarsWebSocket.url)) webSocketTransport.delegate = self client = ApolloClient(networkTransport: webSocketTransport) @@ -393,7 +392,7 @@ class StarWarsSubscriptionTests: XCTestCase { func testConcurrentConnectAndCloseConnection() { WebSocketTransport.provider = MockWebSocket.self - let webSocketTransport = WebSocketTransport(request: URLRequest(url: URL(string: SERVER)!)) + let webSocketTransport = WebSocketTransport(request: URLRequest(url: TestURL.starWarsWebSocket.url)) let expectation = self.expectation(description: "Connection closed") expectation.expectedFulfillmentCount = 2 @@ -417,12 +416,15 @@ class StarWarsSubscriptionTests: XCTestCase { let reviewMutation = CreateAwesomeReviewMutation() // Send the mutations via a separate transport so they can still be sent when the websocket is disconnected - let httpTransport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!) - let httpClient = ApolloClient(networkTransport: httpTransport) + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let interceptorProvider = LegacyInterceptorProvider(store: store) + let alternateTransport = RequestChainNetworkTransport(interceptorProvider: interceptorProvider, + endpointURL: TestURL.starWarsServer.url) + let alternateClient = ApolloClient(networkTransport: alternateTransport) func sendReview() { let reviewSentExpectation = self.expectation(description: "review sent") - httpClient.perform(mutation: reviewMutation) { mutationResult in + alternateClient.perform(mutation: reviewMutation) { mutationResult in switch mutationResult { case .success: break diff --git a/Tests/ApolloWebsocketTests/StarWarsWebSocketTests.swift b/Tests/ApolloWebsocketTests/StarWarsWebSocketTests.swift index 479f180343..0e8747f21f 100755 --- a/Tests/ApolloWebsocketTests/StarWarsWebSocketTests.swift +++ b/Tests/ApolloWebsocketTests/StarWarsWebSocketTests.swift @@ -4,10 +4,7 @@ import ApolloTestSupport @testable import ApolloWebSocket import StarWarsAPI -// import StarWarsAPI - class StarWarsWebSocketTests: XCTestCase, CacheTesting { - let SERVER = "http://localhost:8080/websocket" var cacheType: TestCacheProvider.Type { InMemoryTestCacheProvider.self @@ -275,7 +272,7 @@ class StarWarsWebSocketTests: XCTestCase, CacheTesting { private func fetch(query: Query, completionHandler: @escaping (_ data: Query.Data) -> Void) { withCache { (cache) in - let network = WebSocketTransport(request: URLRequest(url: URL(string: SERVER)!)) + let network = WebSocketTransport(request: URLRequest(url: TestURL.starWarsWebSocket.url)) let store = ApolloStore(cache: cache) let client = ApolloClient(networkTransport: network, store: store) @@ -304,7 +301,7 @@ class StarWarsWebSocketTests: XCTestCase, CacheTesting { private func perform(mutation: Mutation, completionHandler: @escaping (_ data: Mutation.Data) -> Void) { withCache { (cache) in - let network = WebSocketTransport(request: URLRequest(url: URL(string: SERVER)!)) + let network = WebSocketTransport(request: URLRequest(url: TestURL.starWarsWebSocket.url)) let store = ApolloStore(cache: cache) let client = ApolloClient(networkTransport: network, store: store) diff --git a/Tests/ApolloWebsocketTests/WebSocketTransportTests.swift b/Tests/ApolloWebsocketTests/WebSocketTransportTests.swift index 3234155443..46ae7fc61c 100644 --- a/Tests/ApolloWebsocketTests/WebSocketTransportTests.swift +++ b/Tests/ApolloWebsocketTests/WebSocketTransportTests.swift @@ -1,15 +1,15 @@ import XCTest import Apollo +import ApolloTestSupport import Starscream @testable import ApolloWebSocket class WebSocketTransportTests: XCTestCase { - private let mockSocketURL = URL(string: "http://localhost/dummy_url")! private var webSocketTransport: WebSocketTransport! func testUpdateHeaderValues() { - var request = URLRequest(url: mockSocketURL) + var request = URLRequest(url: TestURL.mockServer.url) request.addValue("OldToken", forHTTPHeaderField: "Authorization") self.webSocketTransport = WebSocketTransport(request: request) @@ -22,7 +22,7 @@ class WebSocketTransportTests: XCTestCase { func testUpdateConnectingPayload() { WebSocketTransport.provider = MockWebSocket.self - self.webSocketTransport = WebSocketTransport(request: URLRequest(url: mockSocketURL), + self.webSocketTransport = WebSocketTransport(request: URLRequest(url: TestURL.mockServer.url), connectingPayload: ["Authorization": "OldToken"]) let mockWebSocketDelegate = MockWebSocketDelegate() diff --git a/docs/source/initialization.md b/docs/source/initialization.md index ec06b2cbc8..5ac1eb1f1a 100644 --- a/docs/source/initialization.md +++ b/docs/source/initialization.md @@ -27,171 +27,293 @@ public init(networkTransport: NetworkTransport, The available implementations are: -- **`HTTPNetworkTransport`**, which has a number of configurable options and uses standard HTTP requests to communicate with the server +- **`RequestChainNetworkTransport`**, which passes a request through a chain of interceptors that can do work both before and after going to the network, and uses standard HTTP requests to communicate with the server - **`WebSocketTransport`**, which will send everything using a web socket. If you're using CocoaPods, make sure to install the `Apollo/WebSocket` sub-spec to access this. - **`SplitNetworkTransport`**, which will send subscription operations via a web socket and all other operations via HTTP. If you're using CocoaPods, make sure to install the `Apollo/WebSocket` sub-spec to access this. -### Using `HTTPNetworkTransport` +### Using `RequestChainNetworkTransport` -The initializer for `HTTPNetworkTransport` has several properties which can allow you to get better information and finer-grained control of your HTTP requests and responses: +The initializer for `RequestChainNetworkTransport` has several properties which can allow you to get better information and finer-grained control of your HTTP requests and responses: -- `client` allows you to pass in a [subclass of `URLSessionClient`](#the-urlsessionclient-class) to handle managing a background-compatible URL session, and set up anything which needs to be done for every single request without alteration. -- `sendOperationIdentifiers` allows you send operation identifiers along with your requests. **NOTE:** To send operation identifiers, Apollo types must be generated with `operationIdentifier`s or sending data will crash. Due to this restriction, this option defaults to `false`. -- `useGETForQueries` sends all requests of `query` type using `GET` instead of `POST`. This defaults to `false` to preserve existing behavior in older versions of the client. -- `delegate` Can conform to one or many of several sub-protocols for `HTTPNetworkTransportDelegate`, detailed below. +- `interceptorProvider`: The interceptor provider to use when constructing chains for a request. See below for details on interceptor providers. +- `endpointURL`: The GraphQL endpoint URL to use for all calls. +- `additionalHeaders`: Any additional headers that should be automatically added to every request. Defaults to an empty dictionary. +- `autoPersistQueries`: Pass `true` if [Automatic Persisted Queries](https://www.apollographql.com/docs/apollo-server/performance/apq/) should be used to send an operation's hash instead of the full operation body by default. **NOTE:** To use APQs, you need to make sure to generate your types with operation identifiers. In your Swift Script, make sure to pass a non-nil `operationIDsURL` to have this output. Due to this restriction, this option defaults to `false`. +- `requestCreator`: The `RequestCreator` object to use to build your `URLRequest`. Defaults to the provided `ApolloRequestCreator` implementation. +- `useGETForQueries`: Sends all requests of `query` and `mutation` types using `GET` instead of `POST`. This is mostly useful for large companies taking advantage of CDNs (Content Distribution Networks) that allow local caches instead of going all the way to your server for data which does not change often. This defaults to `false` to preserve existing behavior in older versions of the client. +- `useGETForPersistedQueryRetry`: Pass `true` to use `GET` instead of `POST` for a retry of a persisted query. Defaults to `false`. -### The URLSessionClient class +### How the `RequestChain` works -Since `URLSession` only supports use in the background using the delegate-based API, we have created our own `URLSessionClient` which handles the basics of setup for that. +A `RequestChain` is constructed using an array of interceptors, to be run in the order given, and handles calling back on a specified `DispatchQueue` after all work is complete. -One thing to be aware of: Because setting up a delegate is only possible in the initializer for `URLSession`, you can only pass in a `URLSessionConfiguration`, **not** an existing `URLSession`, to this class's initializer. +In each interceptor, work can be performed asynchronously on any thread. To move along to the next interceptor in the chain, call `proceedAsync`. -By default, instances of `URLSessionClient` use `URLSessionConfiguration.default` to set up their URL session, and instances of `HTTPNetworkTransport` use the default initializer for `URLSessionClient`. +By default, when the interceptor chain ends, if you have a parsed result available, this result will be returned to the caller. -The `URLSessionClient` class and most of its methods are `open` so you can subclass it if you need to override any of the delegate methods for the `URLSession` delegates we're using or you need to handle additional delegate scenarios. +If you want to directly return a value to the caller, call `returnValueAsync`. If you want to have the chain return an error, call `handleErrorAsync`. Both of these methods will call your completion block on the queue specified when creating the `RequestChain. -### Using `HTTPNetworkTransportDelegate` +Note that calling `returnValue` does **NOT** forbid calling `handleError` - or calling each more than once. For example, if you want to return data from the cache to the UI while a network fetch executes, you'd want to make sure that `returnValueAsync` was called twice. -This delegate includes several sub-protocols so that a single parameter can be passed no matter how many sub-protocols it conforms to. +The chain also includes a `retry` mechanism, which will go all the way back to the first interceptor in the chain, then start running through the interceptors again. -If you conform to a particular sub-protocol, you must implement all the methods in that sub-protocol, but we've tried to break things out in a sensible fashion. The sub-protocols are: +**IMPORTANT**: Do not call `retry` blindly. If your server is returning 500s or if the user has no internet, this will create an infinite loop of requests that are retrying (especially if you're not using something like the `MaxRetryInterceptor` to limit how many retries are made). This **will** kill your user's battery, and might also run up the bill on their data plan. Make sure to only request a retry when there's something your code can actually do about the problem! -#### `HTTPNetworkTransportPreflightDelegate` +In the `RequestChainNetworkTransport`, each request creates an individual request chain, and uses an `ApolloInterceptorProvider` -This protocol allows pre-flight validation of requests, the ability to bail out before modifying the request, and the ability to modify the `URLRequest` with things like additional headers. +### Setting up `ApolloInterceptor` chains with `ApolloInterceptorProvider` -The `shouldSend` method is called before any modifications are made by `willSend`. This allows you do things like check that you have an authentication token in your keychain, and if not, prevent the request from hitting the network. When you cancel a request in `shouldSend`, you will receive an error indicating the request was cancelled. +Every operation sent through a `RequestChainNetworkTransport` will be passed into an `ApolloInterceptorProvider` before going to the network. This protocol creates an array of interceptors for use by a single request chain based on the provided operation. -The `willSend` method is called with an `inout` parameter for the `URLRequest` which is about to be sent. There are several uses for this functionality. +There are two default implementations for this protocol provided: -The first is simple logging of the request that's about to go out. You could theoretically do this in `shouldSend`, but particularly if you're making any changes to the request, you'd probably want to do your logging after you've finished those changes. +- `LegacyInterceptorProvider` works with our existing parsing and caching system and tries to replicate the experience of using the old `HTTPNetworkTransport` as closely as possible. It takes a `URLSessionClient` and an `ApolloStore` to pass into the interceptors it uses. +- `CodableInterceptorProvider` is a **work in progress**, which is going to be for use with our [Swift Codegen Rewrite](https://github.com/apollographql/apollo-ios/projects/2), (which, I swear, will eventually be finished). It is not suitable for use at this time. It takes a `URLSessionClient`, a `FlexibleDecoder` (something can decode anything that conforms to `Decodable`). It does not support caching yet. -The most common usage is to modify the request headers. Note that when modifying request headers, you'll need to make a copy of any pre-existing headers before adding new ones. See the [Example Advanced Client Setup](#example-advanced-client-setup) for details. +If you wish to make your own `ApolloInterceptorProvider` instead of using the provided one, you can take advantage of several interceptors that are included in the library: -You can also make any other changes you need to the request, but be aware that going too crazy with this may lead to Unexpected Behavior™. +#### Pre-network +- `MaxRetryInterceptor` checks to make sure a query has not been tried more than a maximum number of times. +- `LegacyCacheReadInterceptor` reads from a provided `ApolloStore` based on the `cachePolicy`, and will return a resul if one is found. -#### `HTTPNetworkTransportTaskCompletedDelegate` +#### Network +- `NetworkFetchInterceptor` takes a `URLSessionClient` and uses it to send the prepared `HTTPRequest` (or subclass thereof) to the server. -This delegate allows you to peer in to the raw data returned to the `URLSession`. This is helpful both for logging what you're getting directly from your server and for grabbing any information out of the raw response, such as updated authentication tokens, which would be removed before parsing is completed. +#### Post-Network -#### `HTTPNetworkTransportRetryDelegate` +- `ResponseCodeInterceptor` checks to make sure a valid response status code has been returned. **NOTE**: Most errors at the GraphQL level are returned with a `200` status code and information in the `errors` array per the GraphQL Spec. This interceptor helps with things like server errors and errors that are returned by middleware. [This article on error handling in GraphQL](https://medium.com/@sachee/200-ok-error-handling-in-graphql-7ec869aec9bc) is a really helpful look at how and why these differences occur. +- `AutomaticPersistedQueryInterceptor` handles checking responses to see if an error is because an automatic persisted query failed, and the full operation needs to be resent to the server. +- `LegacyParsingInterceptor` parses code generated by our Typescript code generation. +- `LegacyCacheWriteInterceptor` writes to a provided `ApolloStore`. +- `CodableParsingError` is a **work in progress** which will parse `Codable` results form the Swift Codegen Rewrite. -This delegate allows you to asynchronously determine whether to retry your request. This is asynchronous to allow for things like re-authenticating your user. +### The URLSessionClient class -When you decide to retry, the `send` operation for your `GraphQLOperation` will be retried. This means you'll get brand new callbacks from `HTTPNetworkTransportPreflightDelegate` to update your headers again as if it was a totally new request. Therefore, the parameter for the completion closure is a simple `true`/`false` option: Pass `true` to retry, pass `false` to error out. +Since `URLSession` only supports use in the background using the delegate-based API, we have created our own `URLSessionClient` which handles the basics of setup for that. -**IMPORTANT**: Do not call `true` blindly in the completion closure. If your server is returning 500s or if the user has no internet, this will create an infinite loop of requests that are retrying. This **will** kill your user's battery, and might also run up the bill on their data plan. Make sure to only request a retry when there's something your code can actually do about the problem! +One thing to be aware of: Because setting up a delegate is only possible in the initializer for `URLSession`, you can only pass in a `URLSessionConfiguration`, **not** an existing `URLSession`, to this class's initializer. + +By default, instances of `URLSessionClient` use `URLSessionConfiguration.default` to set up their URL session, and instances of `HTTPNetworkTransport` use the default initializer for `URLSessionClient`. + +The `URLSessionClient` class and most of its methods are `open` so you can subclass it if you need to override any of the delegate methods for the `URLSession` delegates we're using or you need to handle additional delegate scenarios. ### Example Advanced Client Setup -Here's a sample of a singleton using an advanced client which handles all three sub-protocols. This code assumes you've got the following classes in your own code (these are **not** part of the Apollo library): +Here's a sample how to use an advanced client with some custom interceptors. This code assumes you've got the following classes in your own code (**these are not part of the Apollo library**): -- **`UserManager`** to check whether the user is logged in, perform associated checks on errors and responses to see if they need to reauthenticate, and perform reauthentication +- **`UserManager`** to check whether the user is logged in, perform associated checks on errors and responses to see if they need to renew their token, and perform that renewal - **`Logger`** to handle printing logs based on their level, and which supports `.debug`, `.error`, or `.always` log levels. -```swift -import Foundation -import Apollo +#### Example interceptors -// MARK: - Singleton Wrapper +##### Sample `UserManagementInteceptor` -class Network { - static let shared = Network() - - // Configure the network transport to use the singleton as the delegate. - private lazy var networkTransport: HTTPNetworkTransport = { - let transport = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!) - transport.delegate = self - return transport - }() +An interceptor which checks if the user is logged in and then renews the user's token if it is expired asynchronously before continuing the chain, using the above-mentioned `UserManager` class: + +```swift +class UserManagementInterceptor: ApolloInterceptor { - // Use the configured network transport in your Apollo client. - private(set) lazy var apollo = ApolloClient(networkTransport: self.networkTransport) + enum UserError: Error { + case noUserLoggedIn + } + + /// Helper function to add the token then move on to the next step + private func addTokenAndProceed( + _ token: Token, + to request: HTTPRequest, + chain: RequestChain, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + request.addHeader(name: "Authentication", value: "Bearer: \(token.value)") + chain.proceedAsync(request: request, + response: response, + completion: completion) + } + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + guard let token = UserManager.shared.token else { + // In this instance, no user is logged in, so we want to call + // the error handler, then return to prevent further work + chain.handleErrorAsync(UserError.noUserLoggedIn, + request: request, + response: response, + completion: completion) + return + } + + // If we've gotten here, there is a token! + if token.isExpired { + // Call an async method to renew the token + UserManager.shared.renewToken { [weak self] tokenRenewResult in + guard let self = self else { + return + } + + switch tokenRenewResult { + case .failure(let error): + // Pass the token renewal error up the chain, and do + // not proceed further. Note that you could also wrap this in a + // `UserError` if you want. + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + case .success(let token): + // Renewing worked! Add the token and move on + self.addTokenAndProceed(token, + to: request, + chain: chain, + response: response, + completion: completion) + } + } + } else { + // We don't need to wait for renewal, add token and move on + self.addTokenAndProceed(token, + to: request, + chain: chain, + response: response, + completion: completion) + } + } } +``` -// MARK: - Pre-flight delegate +##### Sample `RequestLoggingInterceptor` -extension Network: HTTPNetworkTransportPreflightDelegate { +An interceptor which logs the outgoing request using the above-mentioned `Logger` class, then moves on: - func networkTransport(_ networkTransport: HTTPNetworkTransport, - shouldSend request: URLRequest) -> Bool { - // If there's an authenticated user, send the request. If not, don't. - return UserManager.shared.hasAuthenticatedUser - } - - func networkTransport(_ networkTransport: HTTPNetworkTransport, - willSend request: inout URLRequest) { - - // Get the existing headers, or create new ones if they're nil - var headers = request.allHTTPHeaderFields ?? [String: String]() - - // Add any new headers you need - headers["Authorization"] = "Bearer \(UserManager.shared.currentAuthToken)" - - // Re-assign the updated headers to the request. - request.allHTTPHeaderFields = headers +```swift +class RequestLoggingInterceptor: ApolloInterceptor { - Logger.log(.debug, "Outgoing request: \(request)") - } + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + Logger.log(.debug, "Outgoing request: \(request)") + chain.proceedAsync(request: request, + response: response, + completion: completion) + } } +``` -// MARK: - Task Completed Delegate - -extension Network: HTTPNetworkTransportTaskCompletedDelegate { - func networkTransport(_ networkTransport: HTTPNetworkTransport, - didCompleteRawTaskForRequest request: URLRequest, - withData data: Data?, - response: URLResponse?, - error: Error?) { - Logger.log(.debug, "Raw task completed for request: \(request)") - - if let error = error { - Logger.log(.error, "Error: \(error)") - } +##### Sample `‌ResponseLoggingInterceptor` + +An interceptor using the above-mentioned `Logger` which logs the incoming response if it exists, and moves on. + +Note that this is an example of an interceptor which can both proceed **and** throw an error - we don't necessarily want to stop processing if this was set up in the wrong place, but we do want to know about it. + +```swift +class ResponseLoggingInterceptor: ApolloInterceptor { - if let response = response { - Logger.log(.debug, "Response: \(response)") - } else { - Logger.log(.error, "No URL Response received!") + enum ResponseLoggingError: Error { + case notYetReceived } - if let data = data { - Logger.log(.debug, "Data: \(String(describing: String(bytes: data, encoding: .utf8)))") - } else { - Logger.log(.error, "No data received!") + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + defer { + // Even if we can't log, we still want to keep going. + chain.proceedAsync(request: request, + response: response, + completion: completion) + } + + guard let receivedResponse = response else { + chain.handleErrorAsync(ResponseLoggingError.notYetReceived, + request: request, + response: response, + completion: completion) + return + } + + Logger.log(.debug, "HTTP Response: \(receivedResponse.httpResponse)") + + if let stringData = String(bytes: receivedResponse.rawData, encoding: .utf8) { + Logger.log(.debug, "Data: \(stringData)") + } else { + Logger.log(.error, "Could not convert data to string!") + } } - } } +``` -// MARK: - Retry Delegate +#### Example Custom Interceptor Provider -extension Network: HTTPNetworkTransportRetryDelegate { +This `InterceptorProvider` uses all of the interceptors that (as of this writing) are in the default `LegacyInterceptorProvider`, interspersed at the appropriate points with the sample interceptors created above: - func networkTransport(_ networkTransport: HTTPNetworkTransport, - receivedError error: Error, - for request: URLRequest, - response: URLResponse?, - continueHandler: @escaping (_ action: HTTPNetworkTransport.ContinueAction) -> Void) { - // Check if the error and/or response you've received are something that requires authentication - guard UserManager.shared.requiresReAuthentication(basedOn: error, response: response) else { - // This is not something this application can handle, do not retry. - continueHandler(.fail(error)) - return +``` +struct NetworkInterceptorProvider: InterceptorProvider { + + // These properties will remain the same throughout the life of the `InterceptorProvider`, even though they + // will be handed to different interceptors. + private let store: ApolloStore + private let client: URLSessionClient + + init(store: ApolloStore, + client: URLSessionClient) { + self.store = store + self.client = client } - // Attempt to re-authenticate asynchronously - UserManager.shared.reAuthenticate { (reAuthenticateError: Error?) in - // If re-authentication succeeded, try again. If it didn't, don't. - if let reAuthenticateError = reAuthenticateError { - continueHandler(.fail(reAuthenticateError)) // Will return re authenticate error to query callback - // or (depending what error you want to get to callback) - continueHandler(.fail(error)) // Will return original error - } else { - continueHandler(.retry) - } + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + return [ + MaxRetryInterceptor(), + LegacyCacheReadInterceptor(store: self.store), + TokenAddingInterceptor(), + RequestLoggingInterceptor(), + NetworkFetchInterceptor(client: self.client), + ResponseLoggingInterceptor(), + ResponseCodeInterceptor(), + LegacyParsingInterceptor(cacheKeyForObject: self.store.cacheKeyForObject), + AutomaticPersistedQueryInterceptor(), + LegacyCacheWriteInterceptor(store: self.store) + ] } - } } ``` +#### Example Network Singleton Setup + +This is the equivalent of what you'd set up in the [Basic Client Creation](#basic-client-creation) section, and what you'd call into from your application. + +```swift +class Network { + static let shared = Network() + + private(set) lazy var apollo: ApolloClient = { + // The cache is necessary to set up the store, which we're going to hand to the provider + let cache = InMemoryNormalizedCache() + let store = ApolloStore(cache: cache) + + let client = URLSessionClient() + let provider = NetworkInterceptorProvider(store: store, client: client) + let url = URL(string: "https://apollo-fullstack-tutorial.herokuapp.com/")! + + let requestChainTransport = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: url) + + + // Remember to give the store you already created to the client so it + // doesn't create one on its own + return ApolloClient(networkTransport: requestChainTransport, + store: store) + }() +} +``` + + An example of setting up a client which can handle web sockets and subscriptions is included in the [subscription documentation](subscriptions/#sample-subscription-supporting-initializer). diff --git a/docs/source/graphql_file_launchlist.png b/docs/source/tutorial/images/graphql_file_launchlist.png similarity index 100% rename from docs/source/graphql_file_launchlist.png rename to docs/source/tutorial/images/graphql_file_launchlist.png diff --git a/docs/source/tutorial/images/interceptor_breakpoint.png b/docs/source/tutorial/images/interceptor_breakpoint.png new file mode 100644 index 0000000000..a9338ad590 Binary files /dev/null and b/docs/source/tutorial/images/interceptor_breakpoint.png differ diff --git a/docs/source/tutorial/images/preflight_delegate_add_protocol_stubs.png b/docs/source/tutorial/images/preflight_delegate_add_protocol_stubs.png deleted file mode 100644 index 8d0cb71da5..0000000000 Binary files a/docs/source/tutorial/images/preflight_delegate_add_protocol_stubs.png and /dev/null differ diff --git a/docs/source/tutorial/images/preflight_delegate_breakpoint.png b/docs/source/tutorial/images/preflight_delegate_breakpoint.png deleted file mode 100644 index 63c14fc823..0000000000 Binary files a/docs/source/tutorial/images/preflight_delegate_breakpoint.png and /dev/null differ diff --git a/docs/source/tutorial/tutorial-mutations.md b/docs/source/tutorial/tutorial-mutations.md index 69c63c92e0..d39ecb27fa 100644 --- a/docs/source/tutorial/tutorial-mutations.md +++ b/docs/source/tutorial/tutorial-mutations.md @@ -8,72 +8,95 @@ In this section, you'll learn how to build authenticated mutations and handle in Before you can book a trip, you need to be able to pass your authentication token along to the example server. To do that, let's dig a little deeper into how Apollo Client works. -The `ApolloClient` uses something called a `NetworkTransport` under the hood. By default, the client creates an `HTTPNetworkTransport` instance to handle talking over HTTP to your server. +The `ApolloClient` uses something called a `NetworkTransport` under the hood. By default, the client creates a `RequestChainNetworkTransport` instance to handle talking over HTTP to your server. -If you need to do anything before a request hits the wire but after Apollo has done most of the configuration for you, there's a delegate protocol called `HTTPNetworkTransportPreflightDelegate` that allows you to do that. +A `RequestChain` runs your request through an array of `ApolloInterceptor` objects which can mutate the request and/or check the cache before it hits the network, and then do additional work after a response is received from the network. -Open `Network.swift` and add an extension to conform to that delegate: +The `RequestChainNetworkTransport` uses an object that conforms to the `InterceptorProivder` protocol in order to create that array of interceptors for each operation it executes. There are a couple of providers that are set up by default, which return a fairly standard array of interceptors. -```swift:title=Network.swift -extension Network: HTTPNetworkTransportPreflightDelegate { +The nice thing is that you can also add your own interceptors to the chain anywhere you need to perform custom actions. In this case, you want to have an interceptor that will add -} -``` +First, create the new interceptor. Go to **File > New > File...** and create a new **Swift File**. Name it **TokenAddingInterceptor.swift**. Open that file, and add the -You'll get an error telling you that protocol stubs must be implemented, and asking you if you want to fix this. Click **Fix**. +```swift:title=TokenAddingInterceptor.swift +import Foundation +import Apollo -Do you wish to add protocol stubs with fix button +class TokenAddingInterceptor: ApolloInterceptor { + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void) { + + // TODO + } +} +``` -Two protocol methods will be added: `networkTransport(_:shouldSend:)` and `networkTransport(_:willSend:)`. +Next, import `KeychainSwift` at the top of the file so you can access the key you've just stored in the keychain. -The `shouldSend` method enables you to make sure a request should go out to the network at all. This is useful for things like checking that your user is logged in before trying to make a request. +```swift:title=TokenAddingInterceptor.swift +import KeychainSwift +``` -However, you're not going to use that functionality in this application. Update the method to have it return `true` all the time: +Then, replace the `TODO` within the `interceptAsync` method with code to get the token from the keychain, and add it to your headers if it exists: -```swift:title=Network.swift -func networkTransport(_ networkTransport: HTTPNetworkTransport, - shouldSend request: URLRequest) -> Bool { - return true -} +```swift:title=TokenAddingInterceptor.swift +let keychain = KeychainSwift() +if let token = keychain.get(LoginViewController.loginKeychainKey) { + request.addHeader(name: "Authorization", value: token) +} // else do nothing + +chain.proceedAsync(request: request, + response: response, + completion: completion) ``` -The `willSend` request is the last thing that can manipulate the request before it goes out to the network. Because the request is passed as an `inout` variable, you can manipulate its contents directly. - -Update the `willSend` method to add your token as the value for the `Authorization` header: +Next, since you're only adding one interceptor that can run at the very beginning of other interceptors, you can subclass the existing `LegacyInterceptorProvider` (which is the default interceptor provider). -```swift:title=Network.swift -func networkTransport(_ networkTransport: HTTPNetworkTransport, - willSend request: inout URLRequest) { - let keychain = KeychainSwift() - if let token = keychain.get(LoginViewController.loginKeychainKey) { - request.addValue(token, forHTTPHeaderField: "Authorization") - } // else do nothing -} -``` +Go to **File > New > File...** and create a new **Swift File**. Name it **NetworkInterceptorProvider.swift**. Add an initial Add code which inserts your `TokenAddingInterceptor` before the other interceptors provided by the `LegacyInterceptorProvider`: -Then, import `KeychainSwift` at the top of the file: +```swift:title=NetworkInterceptorProvider.swift +import Foundation +import Apollo -```swift:title=Network.swift -import KeychainSwift +class NetworkInterceptorProvider: LegacyInterceptorProvider { + override func interceptors(for operation: Operation) -> [ApolloInterceptor] { + var interceptors = super.interceptors(for: operation) + interceptors.insert(TokenAddingInterceptor(), at: 0) + return interceptors + } +} ``` -Next, you need to make sure that Apollo knows that this delegate exists. To do that, you need to do something that Apollo Client has thus far been doing for you under the hood: instantiating the `HTTPNetworkTransport`. +> Another way to do this would be to copy the interceptors provided by the `LegacyInterceptorProvider` (which are all public), and then place your interceptors in the points in the array where you want them. However, since in this case we can run this interceptor first, it's just as simple to subclass. -In the primary declaration of `Network`, update your `lazy var` to create this transport and set the `Network` object as its delegate, then pass it through to the `ApolloClient`: +Next, go back to your `Network` class. Replace the `ApolloClient` with an updated `lazy var` which creates the `RequestChainNetworkTransport` manually, using your custom interceptor provider: ```swift:title=Network.swift -private(set) lazy var apollo: ApolloClient = { - let httpNetworkTransport = HTTPNetworkTransport(url: URL(string: "https://apollo-fullstack-tutorial.herokuapp.com/")!) - httpNetworkTransport.delegate = self - return ApolloClient(networkTransport: httpNetworkTransport) -}() +class Network { + static let shared = Network() + + private(set) lazy var apollo: ApolloClient = { + let client = URLSessionClient() + let cache = InMemoryNormalizedCache() + let store = ApolloStore(cache: cache) + let provider = NetworkInterceptorProvider(client: client, store: store) + let url = URL(string: "https://apollo-fullstack-tutorial.herokuapp.com/")! + let transport = RequestChainNetworkTransport(interceptorProvider: provider, + endpointURL: url) + return ApolloClient(networkTransport: transport) + }() +} ``` +Now, go back to **TokenAddingInterceptor.swift**. Click on the line numbers to add a breakpoint at the line where you're instantiating the `Keychain`: -adding a breakpoint +adding a breakpoint -Build and run the application. Whenever a network request goes out, that breakpoint should now get hit. If you're logged in, your token will be sent to the server whenever you make a request. +Build and run the application. Whenever a network request goes out, that breakpoint should now get hit. If you're logged in, your token will be sent to the server whenever you make a request! ## Add Alert helper methods