diff --git a/Apollo.xcodeproj/project.pbxproj b/Apollo.xcodeproj/project.pbxproj index 0a3c2ae095..1b14fa3d52 100644 --- a/Apollo.xcodeproj/project.pbxproj +++ b/Apollo.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 54DDB0921EA045870009DD99 /* InMemoryNormalizedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DDB0911EA045870009DD99 /* InMemoryNormalizedCache.swift */; }; 5AC6CA4322AAF7B200B7C94D /* GraphQLHTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AC6CA4222AAF7B200B7C94D /* GraphQLHTTPMethod.swift */; }; + 9B1A38532332AF6F00325FB4 /* String+SHA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1A38522332AF6F00325FB4 /* String+SHA.swift */; }; + 9B64F6762354D219002D1BB5 /* URL+QueryDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B64F6752354D219002D1BB5 /* URL+QueryDict.swift */; }; 9B708AAD2305884500604A11 /* ApolloClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B708AAC2305884500604A11 /* ApolloClientProtocol.swift */; }; 9B78C71E2326E86E000C8C32 /* ErrorGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B78C71B2326E859000C8C32 /* ErrorGenerationTests.swift */; }; 9B95EDC022CAA0B000702BB2 /* GETTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */; }; @@ -117,6 +119,8 @@ C377CCAB22D7992E00572E03 /* MultipartFormData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C377CCAA22D7992E00572E03 /* MultipartFormData.swift */; }; E86D8E05214B32FD0028EFE1 /* JSONTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E86D8E03214B32DA0028EFE1 /* JSONTests.swift */; }; F16D083C21EF6F7300C458B8 /* QueryFromJSONBuildingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16D083B21EF6F7300C458B8 /* QueryFromJSONBuildingTests.swift */; }; + F82E62E122BCD223000C311B /* AutomaticPersistedQueriesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F82E62E022BCD223000C311B /* AutomaticPersistedQueriesTests.swift */; }; + F8AB781B22E1B4BB00A50B81 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AB781A22E1B4BB00A50B81 /* MockURLSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -264,6 +268,8 @@ 90690D2322433C5900FC2E54 /* Apollo-Target-CacheDependentTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Target-CacheDependentTests.xcconfig"; sourceTree = ""; }; 90690D2422433C8000FC2E54 /* Apollo-Target-PerformanceTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Target-PerformanceTests.xcconfig"; sourceTree = ""; }; 90690D2522433CAF00FC2E54 /* Apollo-Target-TestSupport.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Target-TestSupport.xcconfig"; sourceTree = ""; }; + 9B1A38522332AF6F00325FB4 /* String+SHA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA.swift"; sourceTree = ""; }; + 9B64F6752354D219002D1BB5 /* URL+QueryDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+QueryDict.swift"; sourceTree = ""; }; 9B708AAC2305884500604A11 /* ApolloClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApolloClientProtocol.swift; sourceTree = ""; }; 9B74BCBE2333F4ED00508F84 /* run-bundled-codegen.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; name = "run-bundled-codegen.sh"; path = "scripts/run-bundled-codegen.sh"; sourceTree = SOURCE_ROOT; }; 9B78C71B2326E859000C8C32 /* ErrorGenerationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorGenerationTests.swift; sourceTree = ""; }; @@ -386,6 +392,9 @@ C377CCAA22D7992E00572E03 /* MultipartFormData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartFormData.swift; sourceTree = ""; }; E86D8E03214B32DA0028EFE1 /* JSONTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONTests.swift; sourceTree = ""; }; F16D083B21EF6F7300C458B8 /* QueryFromJSONBuildingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QueryFromJSONBuildingTests.swift; sourceTree = ""; }; + F82E62E022BCD223000C311B /* AutomaticPersistedQueriesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutomaticPersistedQueriesTests.swift; sourceTree = ""; }; + F8AB781A22E1B4BB00A50B81 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; + F8E9D8AE22B2492C0065DA98 /* schema.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = schema.json; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -546,6 +555,7 @@ 9F8A95811EC0FD3300304A2D /* XCTAssertHelpers.swift */, 9F8A95831EC0FD6100304A2D /* XCTestCase+Promise.swift */, 9F10A51D1EC1BA0F0045E62B /* MockNetworkTransport.swift */, + F8AB781A22E1B4BB00A50B81 /* MockURLSession.swift */, 9F8A95851EC0FD9800304A2D /* TestCacheProvider.swift */, 9F8A957A1EC0FC1200304A2D /* ApolloTestSupport.h */, 9F8A957B1EC0FC1200304A2D /* Info.plist */, @@ -653,6 +663,7 @@ isa = PBXGroup; children = ( 9FC750551D2A532D00458D91 /* Info.plist */, + F82E62E022BCD223000C311B /* AutomaticPersistedQueriesTests.swift */, 9F438D0B1E6C494C007BDC1A /* BatchedLoadTests.swift */, 9FC9A9C71E2EFE6E0023C4D5 /* CacheKeyForFieldTests.swift */, 9FADC8531E6B86D900C677E6 /* DataLoaderTests.swift */, @@ -671,6 +682,7 @@ C338DF1622DD9DE9006AF33E /* RequestCreatorTests.swift */, 9F19D8451EED8D3B00C57247 /* ResultOrPromiseTests.swift */, C3279FC52345233000224790 /* TestCustomRequestCreator.swift */, + 9B64F6752354D219002D1BB5 /* URL+QueryDict.swift */, C304EBD322DDC7B200748F72 /* a.txt */, C35D43BE22DDD3C100BCBABE /* b.txt */, C35D43BF22DDD3C100BCBABE /* c.txt */, @@ -719,6 +731,7 @@ 9F19D8431EED568200C57247 /* ResultOrPromise.swift */, 9FEC15B31E681DAD00D461B4 /* Collections.swift */, 9FADC8491E6B0B2300C677E6 /* Locking.swift */, + 9B1A38522332AF6F00325FB4 /* String+SHA.swift */, ); name = Utilities; sourceTree = ""; @@ -756,6 +769,7 @@ 9FCE2D171E6C259B00E34457 /* Starship.graphql */, 9FCE2D0C1E6C259B00E34457 /* CreateReviewForEpisode.graphql */, 9FCE2D0A1E6C258A00E34457 /* API.swift */, + F8E9D8AE22B2492C0065DA98 /* schema.json */, 9FCE2CFC1E6C213D00E34457 /* StarWarsAPI.h */, 9FCE2CFD1E6C213D00E34457 /* Info.plist */, ); @@ -1124,7 +1138,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "SCRIPT_PATH=\"${SRCROOT}/scripts/run-bundled-codegen.sh\"\n\ncd \"${SRCROOT}/Tests/GitHubAPI\"\n\n\"${SCRIPT_PATH}\" codegen:generate --target=swift --localSchemaFile=\"schema.json\" --includes=./**/*.graphql --suppressSwiftMultilineStringLiterals --mergeInFieldsFromFragmentSpreads API.swift\n"; + shellScript = "SCRIPT_PATH=\"${SRCROOT}/scripts/run-bundled-codegen.sh\"\n\ncd \"${SRCROOT}/Tests/GitHubAPI\"\n\n\"${SCRIPT_PATH}\" codegen:generate --target=swift --localSchemaFile=\"schema.json\" --includes=./**/*.graphql --operationIdsPath=operationIdsPath.json --suppressSwiftMultilineStringLiterals --mergeInFieldsFromFragmentSpreads API.swift\n"; }; 9FCE2D061E6C251100E34457 /* Generate Apollo Client API */ = { isa = PBXShellScriptBuildPhase; @@ -1138,7 +1152,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "SCRIPT_PATH=\"${SRCROOT}/scripts/run-bundled-codegen.sh\"\n\ncd \"${SRCROOT}/Tests/StarWarsAPI\"\n\n\"${SCRIPT_PATH}\" codegen:generate --target=swift --localSchemaFile=\"schema.json\" --includes=./**/*.graphql --mergeInFieldsFromFragmentSpreads API.swift\n"; + shellScript = "SCRIPT_PATH=\"${SRCROOT}/scripts/run-bundled-codegen.sh\"\n\ncd \"${SRCROOT}/Tests/StarWarsAPI\"\n\n\"${SCRIPT_PATH}\" codegen:generate --target=swift --localSchemaFile=\"schema.json\" --includes=./**/*.graphql --operationIdsPath=operationIdsPath.json --mergeInFieldsFromFragmentSpreads API.swift\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -1151,6 +1165,7 @@ 9F8A95841EC0FD6100304A2D /* XCTestCase+Promise.swift in Sources */, 9F8A95821EC0FD3300304A2D /* XCTAssertHelpers.swift in Sources */, 9F10A51E1EC1BA0F0045E62B /* MockNetworkTransport.swift in Sources */, + F8AB781B22E1B4BB00A50B81 /* MockURLSession.swift in Sources */, 9F8A95861EC0FD9800304A2D /* TestCacheProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1222,6 +1237,7 @@ 9FADC84F1E6B865E00C677E6 /* DataLoader.swift in Sources */, 9FF90A611DDDEB100034C3B6 /* GraphQLResponse.swift in Sources */, 9F27D4641D40379500715680 /* JSONStandardTypeConversions.swift in Sources */, + 9B1A38532332AF6F00325FB4 /* String+SHA.swift in Sources */, 9BEDC79E22E5D2CF00549BF6 /* RequestCreator.swift in Sources */, 9FA6F3681E65DF4700BF8D73 /* GraphQLResultAccumulator.swift in Sources */, 9FF90A651DDDEB100034C3B6 /* GraphQLExecutor.swift in Sources */, @@ -1244,12 +1260,14 @@ 9B78C71E2326E86E000C8C32 /* ErrorGenerationTests.swift in Sources */, 9FC9A9C81E2EFE6E0023C4D5 /* CacheKeyForFieldTests.swift in Sources */, 9F91CF8F1F6C0DB2008DD0BE /* MutatingResultsTests.swift in Sources */, + F82E62E122BCD223000C311B /* AutomaticPersistedQueriesTests.swift in Sources */, 9F19D8461EED8D3B00C57247 /* ResultOrPromiseTests.swift in Sources */, 9F533AB31E6C4A4200CBE097 /* BatchedLoadTests.swift in Sources */, C3279FC72345234D00224790 /* TestCustomRequestCreator.swift in Sources */, 9B95EDC022CAA0B000702BB2 /* GETTransformerTests.swift in Sources */, 9FF90A6F1DDDEB420034C3B6 /* InputValueEncodingTests.swift in Sources */, 9FE1C6E71E634C8D00C02284 /* PromiseTests.swift in Sources */, + 9B64F6762354D219002D1BB5 /* URL+QueryDict.swift in Sources */, 9FADC8541E6B86D900C677E6 /* DataLoaderTests.swift in Sources */, E86D8E05214B32FD0028EFE1 /* JSONTests.swift in Sources */, 9F8622FA1EC2117C00C38162 /* FragmentConstructionAndConversionTests.swift in Sources */, diff --git a/Sources/Apollo/GraphQLGETTransformer.swift b/Sources/Apollo/GraphQLGETTransformer.swift index 5a97bcd90f..bcbec427ad 100644 --- a/Sources/Apollo/GraphQLGETTransformer.swift +++ b/Sources/Apollo/GraphQLGETTransformer.swift @@ -13,9 +13,6 @@ struct GraphQLGETTransformer { let body: GraphQLMap let url: URL - private let variablesKey = "variables" - private let queryKey = "query" - /// A helper for transforming a GraphQLMap that can be sent with a `POST` request into a URL with query parameters for a `GET` request. /// /// - Parameters: @@ -46,15 +43,16 @@ struct GraphQLGETTransformer { } } else if let string = arg.value as? String { queryItems.append(URLQueryItem(name: arg.key, value: string)) - } else { + } else if (arg.key != "variables") { assertionFailure() } }) } catch { return nil } - + components.queryItems = queryItems + return components.url } } diff --git a/Sources/Apollo/GraphQLHTTPResponseError.swift b/Sources/Apollo/GraphQLHTTPResponseError.swift index 847e5a5061..ef4df4ac7d 100644 --- a/Sources/Apollo/GraphQLHTTPResponseError.swift +++ b/Sources/Apollo/GraphQLHTTPResponseError.swift @@ -5,6 +5,8 @@ public struct GraphQLHTTPResponseError: Error, LocalizedError { public enum ErrorKind { case errorResponse case invalidResponse + case persistedQueryNotFound + case persistedQueryNotSupported var description: String { switch self { @@ -12,6 +14,10 @@ public struct GraphQLHTTPResponseError: Error, LocalizedError { return "Received error response" case .invalidResponse: return "Received invalid response" + case .persistedQueryNotFound: + return "Persisted query not found" + case .persistedQueryNotSupported: + return "Persisted query not supported" } } } diff --git a/Sources/Apollo/GraphQLResponse.swift b/Sources/Apollo/GraphQLResponse.swift index d0002a8ce9..9f68473fd6 100644 --- a/Sources/Apollo/GraphQLResponse.swift +++ b/Sources/Apollo/GraphQLResponse.swift @@ -38,15 +38,17 @@ public final class GraphQLResponse { } } - func parseResultFast() throws -> GraphQLResult { - let errors: [GraphQLError]? - - if let errorsEntry = body["errors"] as? [JSONObject] { - errors = errorsEntry.map(GraphQLError.init) - } else { - errors = nil + func parseErrorsOnlyFast() -> [GraphQLError]? { + guard let errorsEntry = self.body["errors"] as? [JSONObject] else { + return nil } + return errorsEntry.map(GraphQLError.init) + } + + func parseResultFast() throws -> GraphQLResult { + let errors = self.parseErrorsOnlyFast() + if let dataEntry = body["data"] as? JSONObject { let data = try decode(selectionSet: Operation.Data.self, from: dataEntry, variables: operation.variables) return GraphQLResult(data: data, errors: errors, source: .server, dependentKeys: nil) diff --git a/Sources/Apollo/HTTPNetworkTransport.swift b/Sources/Apollo/HTTPNetworkTransport.swift index 9b4f1e81a4..4315ebbd8f 100644 --- a/Sources/Apollo/HTTPNetworkTransport.swift +++ b/Sources/Apollo/HTTPNetworkTransport.swift @@ -73,6 +73,8 @@ public class HTTPNetworkTransport { let session: URLSession let serializationFormat = JSONSerializationFormat.self let useGETForQueries: Bool + let enableAutoPersistedQueries: Bool + let useGETForPersistedQueryRetry: Bool let delegate: HTTPNetworkTransportDelegate? private let requestCreator: RequestCreator private let sendOperationIdentifiers: Bool @@ -84,42 +86,55 @@ public class HTTPNetworkTransport { /// - session: The URLSession to use. Defaults to `URLSession.shared`, /// - 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. /// - delegate: [Optional] A delegate which can conform to any or all of `HTTPNetworkTransportPreflightDelegate`, `HTTPNetworkTransportTaskCompletedDelegate`, and `HTTPNetworkTransportRetryDelegate`. Defaults to nil. public init(url: URL, session: URLSession = .shared, sendOperationIdentifiers: Bool = false, useGETForQueries: Bool = false, + enableAutoPersistedQueries: Bool = false, + useGETForPersistedQueryRetry: Bool = false, delegate: HTTPNetworkTransportDelegate? = nil, requestCreator: RequestCreator = ApolloRequestCreator()) { self.url = url self.session = session self.sendOperationIdentifiers = sendOperationIdentifiers self.useGETForQueries = useGETForQueries + self.enableAutoPersistedQueries = enableAutoPersistedQueries + self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry self.delegate = delegate self.requestCreator = requestCreator } - - private func send(operation: Operation, files: [GraphQLFile]?, completionHandler: @escaping (_ results: Result, Error>) -> Void) -> Cancellable { + + 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, files: files) + request = try self.createRequest(for: operation, + isPersistedQueryRetry: isPersistedQueryRetry, + files: files) } catch { completionHandler(.failure(error)) return EmptyCancellable() } let task = session.dataTask(with: request) { [weak self] data, response, error in - self?.rawTaskCompleted(request: request, - data: data, - response: response, - error: error) + guard let self = self else { + // None of the rest of this really matters + return + } + + self.rawTaskCompleted(request: request, + data: data, + response: response, + error: error) if let receivedError = error { - self?.handleErrorOrRetry(operation: operation, - error: receivedError, - for: request, - response: response, - completionHandler: completionHandler) + self.handleErrorOrRetry(operation: operation, + error: receivedError, + for: request, + response: response, + completionHandler: completionHandler) return } @@ -131,11 +146,11 @@ public class HTTPNetworkTransport { let unsuccessfulError = GraphQLHTTPResponseError(body: data, response: httpResponse, kind: .errorResponse) - self?.handleErrorOrRetry(operation: operation, - error: unsuccessfulError, - for: request, - response: response, - completionHandler: completionHandler) + self.handleErrorOrRetry(operation: operation, + error: unsuccessfulError, + for: request, + response: response, + completionHandler: completionHandler) return } @@ -143,26 +158,35 @@ public class HTTPNetworkTransport { let error = GraphQLHTTPResponseError(body: nil, response: httpResponse, kind: .invalidResponse) - self?.handleErrorOrRetry(operation: operation, - error: error, - for: request, - response: response, - completionHandler: completionHandler) + self.handleErrorOrRetry(operation: operation, + error: error, + for: request, + response: response, + completionHandler: completionHandler) return } do { - guard let body = try self?.serializationFormat.deserialize(data: data) as? JSONObject else { + guard let body = try self.serializationFormat.deserialize(data: data) as? JSONObject else { throw GraphQLHTTPResponseError(body: data, response: httpResponse, kind: .invalidResponse) } - let response = GraphQLResponse(operation: operation, body: body) - completionHandler(.success(response)) + let graphQLResponse = GraphQLResponse(operation: operation, body: body) + if let errors = graphQLResponse.parseErrorsOnlyFast() { + // Handle specific errors from response + self.handleGraphQLErrorsIfNeeded(operation: operation, + for: request, + body: body, + errors: errors, + completionHandler: completionHandler) + } else { + completionHandler(.success(graphQLResponse)) + } } catch let parsingError { - self?.handleErrorOrRetry(operation: operation, - error: parsingError, - for: request, - response: response, - completionHandler: completionHandler) + self.handleErrorOrRetry(operation: operation, + error: parsingError, + for: request, + response: response, + completionHandler: completionHandler) } } @@ -171,11 +195,32 @@ public class HTTPNetworkTransport { return task } + private func handleGraphQLErrorsIfNeeded(operation: Operation, + 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) + completionHandler(.success(response)) + } + } + private func handleErrorOrRetry(operation: Operation, error: Error, for request: URLRequest, response: URLResponse?, - completionHandler: @escaping (_ result: Result, Error>) -> Void) { + completionHandler: @escaping (_ result: Result, Error>) -> Void) { guard let delegate = self.delegate, let retrier = delegate as? HTTPNetworkTransportRetryDelegate else { @@ -215,14 +260,50 @@ public class HTTPNetworkTransport { error: error) } - private func createRequest(for operation: Operation, files: [GraphQLFile]?) throws -> URLRequest { - let body = requestCreator.requestBody(for: operation, sendOperationIdentifiers: self.sendOperationIdentifiers) + 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 + } + 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) // We default to json, but this can be changed below if needed. request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if self.useGETForQueries && operation.operationType == .query { + + switch httpMethod { + case .GET: let transformer = GraphQLGETTransformer(body: body, url: self.url) if let urlForGet = transformer.createGetURL() { request = URLRequest(url: urlForGet) @@ -230,7 +311,7 @@ public class HTTPNetworkTransport { } else { throw GraphQLHTTPRequestError.serializedQueryParamsMessageError } - } else { + case .POST: do { if let files = files, !files.isEmpty { let formData = try requestCreator.requestMultipartFormData( @@ -278,7 +359,10 @@ public class HTTPNetworkTransport { extension HTTPNetworkTransport: NetworkTransport { public func send(operation: Operation, completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable { - return send(operation: operation, files: nil, completionHandler: completionHandler) + return send(operation: operation, + isPersistedQueryRetry: false, + files: nil, + completionHandler: completionHandler) } } @@ -287,7 +371,10 @@ extension HTTPNetworkTransport: NetworkTransport { extension HTTPNetworkTransport: UploadingNetworkTransport { public func upload(operation: Operation, files: [GraphQLFile], completionHandler: @escaping (_ result: Result, Error>) -> Void) -> Cancellable { - return send(operation: operation, files: files, completionHandler: completionHandler) + return send(operation: operation, + isPersistedQueryRetry: false, + files: files, + completionHandler: completionHandler) } } diff --git a/Sources/Apollo/RequestCreator.swift b/Sources/Apollo/RequestCreator.swift index 3f016e1e55..0ea4ac367e 100644 --- a/Sources/Apollo/RequestCreator.swift +++ b/Sources/Apollo/RequestCreator.swift @@ -7,7 +7,10 @@ public protocol RequestCreator { /// - operation: The operation to use /// - sendOperationIdentifiers: Whether or not to send operation identifiers. Defaults to false. /// - Returns: The created `GraphQLMap` - func requestBody(for operation: Operation, sendOperationIdentifiers: Bool) -> GraphQLMap + func requestBody(for operation: Operation, + sendOperationIdentifiers: Bool, + sendQueryDocument: Bool, + autoPersistQuery: Bool) -> GraphQLMap /// Creates multi-part form data to send with a request /// @@ -32,8 +35,13 @@ extension RequestCreator { /// - Parameters: /// - operation: The operation to use /// - sendOperationIdentifiers: Whether or not to send operation identifiers. Defaults to false. + /// - sendQueryDocument: Whether or not to send the full query document. Defaults to true. + /// - autoPersistQuery: Whether to use auto-persisted query information. Defaults to false. /// - Returns: The created `GraphQLMap` - public func requestBody(for operation: Operation, sendOperationIdentifiers: Bool) -> GraphQLMap { + public func requestBody(for operation: Operation, + sendOperationIdentifiers: Bool = false, + sendQueryDocument: Bool = true, + autoPersistQuery: Bool = false) -> GraphQLMap { var body: GraphQLMap = [ "variables": operation.variables, "operationName": operation.operationName, @@ -45,10 +53,31 @@ extension RequestCreator { } body["id"] = operationIdentifier - } else { + } + + if sendQueryDocument { body["query"] = operation.queryDocument } + + if autoPersistQuery { + guard let operationIdentifier = operation.operationIdentifier else { + preconditionFailure("To enable `autoPersistQueries`, Apollo types must be generated with operationIdentifiers") + } + + let hash: String + if operation.operationDefinition == operation.queryDocument { + // The codegen had everything it needed to generate the hash + hash = operationIdentifier + } else { + // The codegen needed more info for the correct hash - regenerate it. + hash = operation.queryDocument.sha256Hash + } + body["extensions"] = [ + "persistedQuery" : ["sha256Hash": hash, "version": 1] + ] + } + return body } diff --git a/Sources/Apollo/String+SHA.swift b/Sources/Apollo/String+SHA.swift new file mode 100644 index 0000000000..a1d3c5b930 --- /dev/null +++ b/Sources/Apollo/String+SHA.swift @@ -0,0 +1,27 @@ +// +// String+SHA.swift +// Apollo +// +// Created by Ellen Shapiro on 9/18/19. +// Copyright © 2019 Apollo GraphQL. All rights reserved. +// + +import Foundation +import CommonCrypto + +extension String { + + var sha256Hash: String { + let data = self.data(using: .utf8)! + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) + } + + var hashString = "" + for byte in hash { + hashString += String(format:"%02x", UInt8(byte)) + } + return hashString + } +} diff --git a/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift b/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift index 3ce83067a4..4d1bcb388a 100644 --- a/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift +++ b/Tests/ApolloCacheDependentTests/StarWarsServerTests.swift @@ -3,9 +3,63 @@ import XCTest import ApolloTestSupport import StarWarsAPI + +protocol TestConfig { + func network() -> HTTPNetworkTransport +} + +class DefaultConfig: TestConfig { + func network() -> HTTPNetworkTransport { + return HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!) + } +} + +class APQsConfig: TestConfig { + func network() -> HTTPNetworkTransport { + return HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!, + enableAutoPersistedQueries: true) + } +} + +class APQsWithGetMethodConfig: TestConfig, HTTPNetworkTransportRetryDelegate{ + var alreadyRetried = false + func networkTransport(_ networkTransport: HTTPNetworkTransport, receivedError error: Error, for request: URLRequest, response: URLResponse?, retryHandler: @escaping (Bool) -> Void) { + retryHandler(!alreadyRetried) + alreadyRetried = true + } + + func network() -> HTTPNetworkTransport { + return HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!, + enableAutoPersistedQueries: true, + useGETForPersistedQueryRetry: true, + delegate: self) + } + +} + +class StarWarsServerAPQsGetMethodTests: StarWarsServerTests { + override func setUp() { + super.setUp() + config = APQsWithGetMethodConfig() + } +} + +class StarWarsServerAPQsTests: StarWarsServerTests { + override func setUp() { + super.setUp() + config = APQsConfig() + } +} + class StarWarsServerTests: XCTestCase { // MARK: Queries + var config: TestConfig! + override func setUp() { + super.setUp() + config = DefaultConfig() + } + func testHeroNameQuery() { fetch(query: HeroNameQuery()) { data in XCTAssertEqual(data.hero?.name, "R2-D2") @@ -263,12 +317,12 @@ class StarWarsServerTests: XCTestCase { } // MARK: - Helpers - + private func fetch(query: Query, 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 client = ApolloClient(networkTransport: network, store: store) + let client = ApolloClient(networkTransport: config.network(), store: store) let expectation = self.expectation(description: "Fetching query") @@ -285,7 +339,11 @@ class StarWarsServerTests: XCTestCase { completionHandler(data) case .failure(let error): - XCTFail("Unexpected error: \(error)") + if let responseError = error as? GraphQLHTTPResponseError { + XCTFail("Response error: \(responseError.bodyDescription)") + } else { + XCTFail("Unexpected error: \(error)") + } } } @@ -295,9 +353,9 @@ class StarWarsServerTests: XCTestCase { private func perform(mutation: Mutation, completionHandler: @escaping (_ data: Mutation.Data) -> Void) { withCache { (cache) in - let network = HTTPNetworkTransport(url: URL(string: "http://localhost:8080/graphql")!) + let store = ApolloStore(cache: cache) - let client = ApolloClient(networkTransport: network, store: store) + let client = ApolloClient(networkTransport: config.network(), store: store) let expectation = self.expectation(description: "Performing mutation") diff --git a/Tests/ApolloTestSupport/MockURLSession.swift b/Tests/ApolloTestSupport/MockURLSession.swift new file mode 100644 index 0000000000..7562119a9a --- /dev/null +++ b/Tests/ApolloTestSupport/MockURLSession.swift @@ -0,0 +1,27 @@ +// +// MockURLSession.swift +// ApolloTestSupport +// +// Copyright © 2019 Apollo GraphQL. All rights reserved. +// + +import Foundation + +public final class MockURLSession: URLSession { + public private (set) var lastRequest: URLRequest? + + override public func dataTask(with request: URLRequest) -> URLSessionDataTask { + lastRequest = request + return URLSessionDataTaskMock() + } + + override public func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { + lastRequest = request + return URLSessionDataTaskMock() + } +} + +private final class URLSessionDataTaskMock: URLSessionDataTask { + override func resume() { + } +} diff --git a/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift b/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift new file mode 100644 index 0000000000..bd0d917daa --- /dev/null +++ b/Tests/ApolloTests/AutomaticPersistedQueriesTests.swift @@ -0,0 +1,382 @@ +import XCTest +@testable import Apollo +import ApolloTestSupport +import StarWarsAPI + +class AutomaticPersistedQueriesTests: XCTestCase { + + private final let endpoint = "http://localhost:8080/graphql" + + // MARK: - Helper Methods + + private func validatePostBody(with request: URLRequest, + query: HeroNameQuery, + queryDocument: Bool = false, + persistedQuery: Bool = false, + file: StaticString = #file, + line: UInt = #line) { + + guard let httpBody = request.httpBody, + let jsonBody = try? JSONSerializationFormat.deserialize(data: httpBody) as? JSONObject else { + XCTFail("httpBody invalid", + file: file, + line: line) + return + } + + let queryString = jsonBody["query"] as? String + if queryDocument { + XCTAssertEqual(queryString, + query.queryDocument, + file: file, + line: line) + } + + if let variables = jsonBody["variables"] as? JSONObject { + XCTAssertEqual(variables["episode"] as? String, + query.episode?.rawValue, + file: file, + line: line) + } else { + XCTFail("variables should not be nil", + file: file, + line: line) + } + + let ext = jsonBody["extensions"] as? JSONObject + if persistedQuery { + guard let ext = ext else { + XCTFail("extensions json data should not be nil", + file: file, + line: line) + return + } + + guard let persistedQuery = ext["persistedQuery"] as? JSONObject else { + XCTFail("persistedQuery is missing", + file: file, + line: line) + return + } + + guard let version = persistedQuery["version"] as? Int else { + XCTFail("version is missing", + file: file, + line: line) + return + } + + guard let sha256Hash = persistedQuery["sha256Hash"] as? String else { + XCTFail("sha256Hash is missing", + file: file, + line: line) + return + } + + XCTAssertEqual(version, 1, + file: file, + line: line) + XCTAssertEqual(sha256Hash, + query.operationIdentifier, + file: file, + line: line) + } else { + XCTAssertNil(ext, + "extensions should be nil", + file: file, + line: line) + } + } + + private func validateUrlParams(with request: URLRequest, + query: HeroNameQuery, + queryDocument: Bool = false, + persistedQuery: Bool = false, + file: StaticString = #file, + line: UInt = #line) { + guard let url = request.url else { + XCTFail("URL not valid", + file: file, + line: line) + return + } + + let queryString = url.queryItemDictionary?["query"] + if queryDocument { + XCTAssertEqual(queryString, + query.queryDocument, + file: file, + line: line) + } else { + XCTAssertNil(queryString, + "query string should be nil", + file: file, + line: line) + } + + if let variables = url.queryItemDictionary?["variables"] { + if let episode = query.episode { + XCTAssertEqual(variables, + "{\"episode\":\"\(episode.rawValue)\"}", + file: file, + line: line) + } else { + XCTAssertEqual(variables, + "{\"episode\":null}", + file: file, + line: line) + } + } else { + XCTFail("variables should not be nil", + file: file, + line: line) + } + + let ext = url.queryItemDictionary?["extensions"] + if persistedQuery { + guard let ext = ext, + let data = ext.data(using: .utf8), + let jsonBody = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + else { + XCTFail("extensions json data should not be nil", + file: file, + line: line) + return + } + + guard let persistedQuery = jsonBody["persistedQuery"] as? JSONObject else { + XCTFail("persistedQuery is missing", + file: file, + line: line) + return + } + + guard let sha256Hash = persistedQuery["sha256Hash"] as? String else { + XCTFail("sha256Hash is missing", + file: file, + line: line) + return + } + + guard let version = persistedQuery["version"] as? Int else { + XCTFail("version is missing", + file: file, + line: line) + return + } + + XCTAssertEqual(version, 1, + file: file, + line: line) + XCTAssertEqual(sha256Hash, query.operationIdentifier, + file: file, + line: line) + } else { + XCTAssertNil(ext, + "extension should be nil", + file: file, + line: line) + } + } + + + // MARK: - Tests + + func testRequestBody() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, session: mockSession) + let query = HeroNameQuery() + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "POST") + + self.validatePostBody(with: request, + query: query, + queryDocument: true) + } + + func testRequestBodyWithVariable() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, session: mockSession) + let query = HeroNameQuery(episode: .jedi) + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "POST") + + validatePostBody(with: request, + query: query, + queryDocument: true) + } + + + func testRequestBodyForAPQsWithVariable() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + enableAutoPersistedQueries: true) + let query = HeroNameQuery(episode: .empire) + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "POST") + + validatePostBody(with: request, + query: query, + persistedQuery: true) + } + + func testQueryStringForAPQsUseGetMethod() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + enableAutoPersistedQueries: true, + useGETForPersistedQueryRetry: true) + let query = HeroNameQuery() + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + + validateUrlParams(with: request, + query: query, + persistedQuery: true) + } + + func testQueryStringForAPQsUseGetMethodWithVariable() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + enableAutoPersistedQueries: true, + useGETForPersistedQueryRetry: true) + let query = HeroNameQuery(episode: .empire) + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "GET") + + validateUrlParams(with: request, + query: query, + persistedQuery: true) + } + + func testUseGETForQueriesRequest() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + useGETForQueries: true) + let query = HeroNameQuery() + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "GET") + + validateUrlParams(with: request, + query: query, + queryDocument: true) + } + + func testNotUseGETForQueriesRequest() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, session: mockSession) + let query = HeroNameQuery() + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "POST") + + validatePostBody(with: request, + query: query, + queryDocument: true) + } + + func testNotUseGETForQueriesAPQsRequest() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + enableAutoPersistedQueries: true) + let query = HeroNameQuery(episode: .empire) + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "POST") + + validatePostBody(with: request, + query: query, + persistedQuery: true) + } + + func testUseGETForQueriesAPQsRequest() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + useGETForQueries: true, + enableAutoPersistedQueries: true) + let query = HeroNameQuery(episode: .empire) + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "GET") + + validateUrlParams(with: request, + query: query, + persistedQuery: true) + } + + func testNotUseGETForQueriesAPQsGETRequest() { + let mockSession = MockURLSession() + let network = HTTPNetworkTransport(url: URL(string: endpoint)!, + session: mockSession, + enableAutoPersistedQueries: true, + useGETForPersistedQueryRetry: true) + let query = HeroNameQuery(episode: .empire) + let _ = network.send(operation: query) { _ in } + + guard let request = mockSession.lastRequest else { + XCTFail("last request should not be nil") + return + } + XCTAssertEqual(request.url?.host, network.url.host) + XCTAssertEqual(request.httpMethod, "GET") + + validateUrlParams(with: request, + query: query, + persistedQuery: true) + } +} diff --git a/Tests/ApolloTests/GETTransformerTests.swift b/Tests/ApolloTests/GETTransformerTests.swift index d483eb29ac..30fd0a308b 100644 --- a/Tests/ApolloTests/GETTransformerTests.swift +++ b/Tests/ApolloTests/GETTransformerTests.swift @@ -88,6 +88,133 @@ class GETTransformerTests: XCTestCase { } } + func testEncodingQueryWith2DParameter() { + let operation = HeroNameQuery(episode: .empire) + + let persistedQuery: GraphQLMap = [ + "version": 1, + "sha256Hash": operation.operationIdentifier + ] + + let extensions: GraphQLMap = [ + "persistedQuery": persistedQuery + ] + + let body: GraphQLMap = [ + "query": operation.queryDocument, + "variables": operation.variables, + "extensions": extensions + ] + + let transformer = GraphQLGETTransformer(body: body, url: self.url) + + let url = transformer.createGetURL() + + if #available(iOS 11, macOS 13, watchOS 4, tvOS 11, *) { + let queryString = url?.absoluteString == "http://localhost:8080/graphql?extensions=%7B%22persistedQuery%22:%7B%22sha256Hash%22:%22f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671%22,%22version%22:1%7D%7D&query=query%20HeroName($episode:%20Episode)%20%7B%0A%20%20hero(episode:%20$episode)%20%7B%0A%20%20%20%20__typename%0A%20%20%20%20name%0A%20%20%7D%0A%7D&variables=%7B%22episode%22:%22EMPIRE%22%7D" + + XCTAssertTrue(queryString) + } else { + guard let query = url?.queryItemDictionary?["query"] else { + XCTFail("query should not nil") + return + } + XCTAssertTrue(query == operation.queryDocument) + + guard let variables = url?.queryItemDictionary?["variables"] else { + XCTFail("variables should not nil") + return + } + XCTAssertEqual(variables, "{\"episode\":\"EMPIRE\"}") + + guard let ext = url?.queryItemDictionary?["extensions"], + let data = ext.data(using: .utf8), + let jsonBody = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + else { + XCTFail("extensions json data should not be nil") + return + } + + guard let comparePersistedQuery = jsonBody["persistedQuery"] as? JSONObject else { + XCTFail("persistedQuery is missing") + return + } + + guard let sha256Hash = comparePersistedQuery["sha256Hash"] as? String else { + XCTFail("sha256Hash is missing") + return + } + + guard let version = comparePersistedQuery["version"] as? Int else { + XCTFail("version is missing") + return + } + + XCTAssertEqual(version, 1) + XCTAssertEqual(sha256Hash, "f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671") + } + } + + func testEncodingQueryWith2DWOQueryParameter() { + let operation = HeroNameQuery(episode: .empire) + + let persistedQuery: GraphQLMap = [ + "version": 1, + "sha256Hash": operation.operationIdentifier + ] + + let extensions: GraphQLMap = [ + "persistedQuery": persistedQuery + ] + + let body: GraphQLMap = [ + "variables": operation.variables, + "extensions": extensions + ] + + let transformer = GraphQLGETTransformer(body: body, url: self.url) + + let url = transformer.createGetURL() + + if #available(iOS 11, macOS 13, watchOS 4, tvOS 11, *) { + let queryString = url?.absoluteString == "http://localhost:8080/graphql?extensions=%7B%22persistedQuery%22:%7B%22sha256Hash%22:%22f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671%22,%22version%22:1%7D%7D&variables=%7B%22episode%22:%22EMPIRE%22%7D" + XCTAssertTrue(queryString) + } else { + + guard let variables = url?.queryItemDictionary?["variables"] else { + XCTFail("variables should not nil") + return + } + XCTAssertEqual(variables, "{\"episode\":\"EMPIRE\"}") + + guard let ext = url?.queryItemDictionary?["extensions"], + let data = ext.data(using: .utf8), + let jsonBody = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + else { + XCTFail("extensions json data should not be nil") + return + } + + guard let comparePersistedQuery = jsonBody["persistedQuery"] as? JSONObject else { + XCTFail("persistedQuery is missing") + return + } + + guard let sha256Hash = comparePersistedQuery["sha256Hash"] as? String else { + XCTFail("sha256Hash is missing") + return + } + + guard let version = comparePersistedQuery["version"] as? Int else { + XCTFail("version is missing") + return + } + + XCTAssertEqual(version, 1) + XCTAssertEqual(sha256Hash, "f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671") + } + } + func testEncodingQueryWithNullDefaultParameter() { let operation = HeroNameQuery() let body = requestCreator.requestBody(for: operation, sendOperationIdentifiers: false) @@ -98,4 +225,64 @@ class GETTransformerTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "http://localhost:8080/graphql?operationName=HeroName&query=query%20HeroName($episode:%20Episode)%20%7B%0A%20%20hero(episode:%20$episode)%20%7B%0A%20%20%20%20__typename%0A%20%20%20%20name%0A%20%20%7D%0A%7D&variables=%7B%22episode%22:null%7D") } + + func testEncodingQueryWith2DNullDefaultParameter() { + let operation = HeroNameQuery() + + let persistedQuery: GraphQLMap = [ + "version": 1, + "sha256Hash": operation.operationIdentifier + ] + + let extensions: GraphQLMap = [ + "persistedQuery": persistedQuery + ] + + let body: GraphQLMap = [ + "query": operation.queryDocument, + "variables": operation.variables, + "extensions": extensions + ] + + let transformer = GraphQLGETTransformer(body: body, url: self.url) + + let url = transformer.createGetURL() + + if #available(iOS 11, macOS 13, watchOS 4, tvOS 11, *) { + let queryString = url?.absoluteString == "http://localhost:8080/graphql?extensions=%7B%22persistedQuery%22:%7B%22sha256Hash%22:%22f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671%22,%22version%22:1%7D%7D&query=query%20HeroName($episode:%20Episode)%20%7B%0A%20%20hero(episode:%20$episode)%20%7B%0A%20%20%20%20__typename%0A%20%20%20%20name%0A%20%20%7D%0A%7D&variables=%7B%22episode%22:null%7D" + XCTAssertTrue(queryString) + } else { + guard let variables = url?.queryItemDictionary?["variables"] else { + XCTFail("variables should not nil") + return + } + XCTAssertEqual(variables, "{\"episode\":null}") + + guard let ext = url?.queryItemDictionary?["extensions"], + let data = ext.data(using: .utf8), + let jsonBody = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject + else { + XCTFail("extensions json data should not be nil") + return + } + + guard let comparePersistedQuery = jsonBody["persistedQuery"] as? JSONObject else { + XCTFail("persistedQuery is missing") + return + } + + guard let sha256Hash = comparePersistedQuery["sha256Hash"] as? String else { + XCTFail("sha256Hash is missing") + return + } + + guard let version = comparePersistedQuery["version"] as? Int else { + XCTFail("version is missing") + return + } + + XCTAssertEqual(version, 1) + XCTAssertEqual(sha256Hash, "f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671") + } + } } diff --git a/Tests/ApolloTests/HTTPTransportTests.swift b/Tests/ApolloTests/HTTPTransportTests.swift index ca698c213c..1efb8fd8d1 100644 --- a/Tests/ApolloTests/HTTPTransportTests.swift +++ b/Tests/ApolloTests/HTTPTransportTests.swift @@ -260,6 +260,9 @@ extension HTTPTransportTests: HTTPNetworkTransportRetryDelegate { retryHandler(true) case .invalidResponse: retryHandler(false) + case .persistedQueryNotFound, + .persistedQueryNotSupported: + retryHandler(false) } } } diff --git a/Tests/ApolloTests/URL+QueryDict.swift b/Tests/ApolloTests/URL+QueryDict.swift new file mode 100644 index 0000000000..2fa8468e7d --- /dev/null +++ b/Tests/ApolloTests/URL+QueryDict.swift @@ -0,0 +1,27 @@ +// +// URL+QueryDict.swift +// ApolloTests +// +// Created by Ellen Shapiro on 10/14/19. +// Copyright © 2019 Apollo GraphQL. All rights reserved. +// + +import Foundation + +extension URL { + + /// Transforms the query items with values into an optional dictionary so it can be subscripted. + var queryItemDictionary: [String: String]? { + return URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems? + .reduce([String: String]()) { dict, queryItem in + guard let value = queryItem.value else { + return dict + } + + var updatedDict = dict + updatedDict[queryItem.name] = value + return updatedDict + } + } +} diff --git a/Tests/GitHubAPI/API.swift b/Tests/GitHubAPI/API.swift index 291aa0ff0c..172aaaa2ef 100644 --- a/Tests/GitHubAPI/API.swift +++ b/Tests/GitHubAPI/API.swift @@ -10,6 +10,8 @@ public final class RepositoryQuery: GraphQLQuery { public let operationName = "Repository" + public let operationIdentifier: String? = "63e25c339275a65f43b847e692e42caed8c06e25fbfb3dc8db6d4897b180c9ef" + public init() { } diff --git a/Tests/GitHubAPI/operationIdsPath.json b/Tests/GitHubAPI/operationIdsPath.json new file mode 100644 index 0000000000..e8e0c95642 --- /dev/null +++ b/Tests/GitHubAPI/operationIdsPath.json @@ -0,0 +1,6 @@ +{ + "63e25c339275a65f43b847e692e42caed8c06e25fbfb3dc8db6d4897b180c9ef": { + "name": "Repository", + "source": "query Repository {\n repository(owner: \"apollographql\", name: \"apollo-ios\") {\n __typename\n issueOrPullRequest(number: 13) {\n __typename\n ... on Issue {\n body\n ... on UniformResourceLocatable {\n url\n }\n author {\n __typename\n avatarUrl\n }\n }\n ... on Reactable {\n viewerCanReact\n ... on Comment {\n author {\n __typename\n login\n }\n }\n }\n }\n }\n}" + } +} \ No newline at end of file diff --git a/Tests/StarWarsAPI/API.swift b/Tests/StarWarsAPI/API.swift index 92a72220df..5552f00d10 100644 --- a/Tests/StarWarsAPI/API.swift +++ b/Tests/StarWarsAPI/API.swift @@ -142,6 +142,8 @@ public final class CreateReviewForEpisodeMutation: GraphQLMutation { public let operationName = "CreateReviewForEpisode" + public let operationIdentifier: String? = "9bbf5b4074d0635fb19d17c621b7b04ebfb1920d468a94266819e149841e7d5d" + public var episode: Episode public var review: ReviewInput @@ -246,6 +248,8 @@ public final class CreateAwesomeReviewMutation: GraphQLMutation { public let operationName = "CreateAwesomeReview" + public let operationIdentifier: String? = "4a1250de93ebcb5cad5870acf15001112bf27bb963e8709555b5ff67a1405374" + public init() { } @@ -344,6 +348,8 @@ public final class HeroAndFriendsNamesQuery: GraphQLQuery { public let operationName = "HeroAndFriendsNames" + public let operationIdentifier: String? = "fe3f21394eb861aa515c4d582e645469045793c9cbbeca4b5d4ce4d7dd617556" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -497,6 +503,8 @@ public final class HeroAndFriendsNamesWithIDsQuery: GraphQLQuery { public let operationName = "HeroAndFriendsNamesWithIDs" + public let operationIdentifier: String? = "8e4ca76c63660898cfd5a3845e3709027750b5f0151c7f9be65759b869c5486d" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -671,6 +679,8 @@ public final class HeroAndFriendsNamesWithIdForParentOnlyQuery: GraphQLQuery { public let operationName = "HeroAndFriendsNamesWithIDForParentOnly" + public let operationIdentifier: String? = "f091468a629f3b757c03a1b7710c6ede8b5c8f10df7ba3238f2bbcd71c56f90f" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -830,6 +840,8 @@ public final class HeroAndFriendsNamesWithFragmentQuery: GraphQLQuery { public let operationName = "HeroAndFriendsNamesWithFragment" + public let operationIdentifier: String? = "1d3ad903dad146ff9d7aa09813fc01becd017489bfc1af8ffd178498730a5a26" + public var queryDocument: String { return operationDefinition.appending(FriendsNames.fragmentDefinition) } public var episode: Episode? @@ -1015,6 +1027,8 @@ public final class HeroAndFriendsNamesWithFragmentTwiceQuery: GraphQLQuery { public let operationName = "HeroAndFriendsNamesWithFragmentTwice" + public let operationIdentifier: String? = "e02ef22e116ad1ca35f0298ed3badb60eeb986203f0088575a5f137768c322fc" + public var queryDocument: String { return operationDefinition.appending(CharacterName.fragmentDefinition) } public var episode: Episode? @@ -1305,6 +1319,8 @@ public final class HeroAppearsInQuery: GraphQLQuery { public let operationName = "HeroAppearsIn" + public let operationIdentifier: String? = "22d772c0fc813281705e8f0a55fc70e71eeff6e98f3f9ef96cf67fb896914522" + public init() { } @@ -1392,6 +1408,8 @@ public final class HeroAppearsInWithFragmentQuery: GraphQLQuery { public let operationName = "HeroAppearsInWithFragment" + public let operationIdentifier: String? = "1756158bd7736d58db45a48d74a724fa1b6fdac735376df8afac8318ba5431fb" + public var queryDocument: String { return operationDefinition.appending(CharacterAppearsIn.fragmentDefinition) } public var episode: Episode? @@ -1515,6 +1533,8 @@ public final class HeroNameConditionalExclusionQuery: GraphQLQuery { public let operationName = "HeroNameConditionalExclusion" + public let operationIdentifier: String? = "3dd42259adf2d0598e89e0279bee2c128a7913f02b1da6aa43f3b5def6a8a1f8" + public var skipName: Bool public init(skipName: Bool) { @@ -1611,6 +1631,8 @@ public final class HeroNameConditionalInclusionQuery: GraphQLQuery { public let operationName = "HeroNameConditionalInclusion" + public let operationIdentifier: String? = "338081aea3acc83d04af0741ecf0da1ec2ee8e6468a88383476b681015905ef8" + public var includeName: Bool public init(includeName: Bool) { @@ -1707,6 +1729,8 @@ public final class HeroNameConditionalBothQuery: GraphQLQuery { public let operationName = "HeroNameConditionalBoth" + public let operationIdentifier: String? = "66f4dc124b6374b1912b22a2a208e34a4b1997349402a372b95bcfafc7884064" + public var skipName: Bool public var includeName: Bool @@ -1808,6 +1832,8 @@ public final class HeroNameConditionalBothSeparateQuery: GraphQLQuery { public let operationName = "HeroNameConditionalBothSeparate" + public let operationIdentifier: String? = "d0f9e9205cdc09320035662f528a177654d3275b0bf94cf0e259a65fde33e7e5" + public var skipName: Bool public var includeName: Bool @@ -1912,6 +1938,8 @@ public final class HeroDetailsInlineConditionalInclusionQuery: GraphQLQuery { public let operationName = "HeroDetailsInlineConditionalInclusion" + public let operationIdentifier: String? = "fcd9d7acb4e7c97e3ae5ad3cbf4e83556626149de589f0c2fce2f8ede31b0d90" + public var includeDetails: Bool public init(includeDetails: Bool) { @@ -2019,6 +2047,8 @@ public final class HeroDetailsFragmentConditionalInclusionQuery: GraphQLQuery { public let operationName = "HeroDetailsFragmentConditionalInclusion" + public let operationIdentifier: String? = "b31aec7d977249e185922e4cc90318fd2c7197631470904bf937b0626de54b4f" + public var queryDocument: String { return operationDefinition.appending(HeroDetails.fragmentDefinition) } public var includeDetails: Bool @@ -2342,6 +2372,8 @@ public final class HeroNameTypeSpecificConditionalInclusionQuery: GraphQLQuery { public let operationName = "HeroNameTypeSpecificConditionalInclusion" + public let operationIdentifier: String? = "4d465fbc6e3731d011025048502f16278307d73300ea9329a709d7e2b6815e40" + public var episode: Episode? public var includeName: Bool @@ -2503,6 +2535,8 @@ public final class HeroFriendsDetailsConditionalInclusionQuery: GraphQLQuery { public let operationName = "HeroFriendsDetailsConditionalInclusion" + public let operationIdentifier: String? = "9bdfeee789c1d22123402a9c3e3edefeb66799b3436289751be8f47905e3babd" + public var includeFriendsDetails: Bool public init(includeFriendsDetails: Bool) { @@ -2716,6 +2750,8 @@ public final class HeroFriendsDetailsUnconditionalAndConditionalInclusionQuery: public let operationName = "HeroFriendsDetailsUnconditionalAndConditionalInclusion" + public let operationIdentifier: String? = "501fcb710e5ffeeab2c65b7935fbded394ffea92e7b5dd904d05d5deab6f39c6" + public var includeFriendsDetails: Bool public init(includeFriendsDetails: Bool) { @@ -2938,6 +2974,8 @@ public final class HeroDetailsQuery: GraphQLQuery { public let operationName = "HeroDetails" + public let operationIdentifier: String? = "2b67111fd3a1c6b2ac7d1ef7764e5cefa41d3f4218e1d60cb67c22feafbd43ec" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -3157,6 +3195,8 @@ public final class HeroDetailsWithFragmentQuery: GraphQLQuery { public let operationName = "HeroDetailsWithFragment" + public let operationIdentifier: String? = "d20fa2f460058b8eec3d227f2f6088a708cf35dfa2b5ebf1414e34f9674ecfce" + public var queryDocument: String { return operationDefinition.appending(HeroDetails.fragmentDefinition) } public var episode: Episode? @@ -3463,6 +3503,8 @@ public final class DroidDetailsWithFragmentQuery: GraphQLQuery { public let operationName = "DroidDetailsWithFragment" + public let operationIdentifier: String? = "7277e97563e911ac8f5c91d401028d218aae41f38df014d7fa0b037bb2a2e739" + public var queryDocument: String { return operationDefinition.appending(DroidDetails.fragmentDefinition) } public var episode: Episode? @@ -3675,6 +3717,8 @@ public final class HeroFriendsOfFriendsNamesQuery: GraphQLQuery { public let operationName = "HeroFriendsOfFriendsNames" + public let operationIdentifier: String? = "37cd5626048e7243716ffda9e56503939dd189772124a1c21b0e0b87e69aae01" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -3864,6 +3908,8 @@ public final class HeroNameQuery: GraphQLQuery { public let operationName = "HeroName" + public let operationIdentifier: String? = "f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -3959,6 +4005,8 @@ public final class HeroNameWithIdQuery: GraphQLQuery { public let operationName = "HeroNameWithID" + public let operationIdentifier: String? = "83c03f612c46fca72f6cb902df267c57bffc9209bc44dd87d2524fb2b34f6f18" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -4064,6 +4112,8 @@ public final class HeroNameWithFragmentQuery: GraphQLQuery { public let operationName = "HeroNameWithFragment" + public let operationIdentifier: String? = "b952f0054915a32ec524ac0dde0244bcda246649debe149f9e32e303e21c8266" + public var queryDocument: String { return operationDefinition.appending(CharacterName.fragmentDefinition) } public var episode: Episode? @@ -4188,6 +4238,8 @@ public final class HeroNameWithFragmentAndIdQuery: GraphQLQuery { public let operationName = "HeroNameWithFragmentAndID" + public let operationIdentifier: String? = "a87a0694c09d1ed245e9a80f245d96a5f57b20a4aa936ee9ab09b2a43620db02" + public var queryDocument: String { return operationDefinition.appending(CharacterName.fragmentDefinition) } public var episode: Episode? @@ -4322,6 +4374,8 @@ public final class HeroNameAndAppearsInWithFragmentQuery: GraphQLQuery { public let operationName = "HeroNameAndAppearsInWithFragment" + public let operationIdentifier: String? = "0664fed3eb4f9fbdb44e8691d9e8fd11f2b3c097ba11327592054f602bd3ba1a" + public var queryDocument: String { return operationDefinition.appending(CharacterNameAndAppearsIn.fragmentDefinition) } public var episode: Episode? @@ -4474,6 +4528,8 @@ public final class HeroParentTypeDependentFieldQuery: GraphQLQuery { public let operationName = "HeroParentTypeDependentField" + public let operationIdentifier: String? = "561e22ac4da5209f254779b70e01557fb2fc57916b9914088429ec809e166cad" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -4912,6 +4968,8 @@ public final class HeroTypeDependentAliasedFieldQuery: GraphQLQuery { public let operationName = "HeroTypeDependentAliasedField" + public let operationIdentifier: String? = "b5838c22bac1c5626023dac4412ca9b86bebfe16608991fb632a37c44e12811e" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -5102,6 +5160,8 @@ public final class SameHeroTwiceQuery: GraphQLQuery { public let operationName = "SameHeroTwice" + public let operationIdentifier: String? = "2a8ad85a703add7d64622aaf6be76b58a1134caf28e4ff6b34dd00ba89541364" + public init() { } @@ -5242,6 +5302,8 @@ public final class StarshipQuery: GraphQLQuery { public let operationName = "Starship" + public let operationIdentifier: String? = "a3734516185da9919e3e66d74fe92b60d65292a1943dc54913f7332637dfdd2a" + public init() { } @@ -5337,6 +5399,8 @@ public final class ReviewAddedSubscription: GraphQLSubscription { public let operationName = "ReviewAdded" + public let operationIdentifier: String? = "38644c5e7cf4fd506b91d2e7010cabf84e63dfcd33cf1deb443b4b32b55e2cbe" + public var episode: Episode? public init(episode: Episode? = nil) { @@ -5450,6 +5514,8 @@ public final class HumanQuery: GraphQLQuery { public let operationName = "Human" + public let operationIdentifier: String? = "b37eb69b82fd52358321e49453769750983be1c286744dbf415735d7bcf12f1e" + public var id: GraphQLID public init(id: GraphQLID) { @@ -5555,6 +5621,8 @@ public final class TwoHeroesQuery: GraphQLQuery { public let operationName = "TwoHeroes" + public let operationIdentifier: String? = "b868fa9c48f19b8151c08c09f46831e3b9cd09f5c617d328647de785244b52bb" + public init() { } diff --git a/Tests/StarWarsAPI/operationIdsPath.json b/Tests/StarWarsAPI/operationIdsPath.json new file mode 100644 index 0000000000..4d8273e4eb --- /dev/null +++ b/Tests/StarWarsAPI/operationIdsPath.json @@ -0,0 +1,138 @@ +{ + "9bbf5b4074d0635fb19d17c621b7b04ebfb1920d468a94266819e149841e7d5d": { + "name": "CreateReviewForEpisode", + "source": "mutation CreateReviewForEpisode($episode: Episode!, $review: ReviewInput!) {\n createReview(episode: $episode, review: $review) {\n __typename\n stars\n commentary\n }\n}" + }, + "4a1250de93ebcb5cad5870acf15001112bf27bb963e8709555b5ff67a1405374": { + "name": "CreateAwesomeReview", + "source": "mutation CreateAwesomeReview {\n createReview(episode: JEDI, review: {stars: 10, commentary: \"This is awesome!\"}) {\n __typename\n stars\n commentary\n }\n}" + }, + "fe3f21394eb861aa515c4d582e645469045793c9cbbeca4b5d4ce4d7dd617556": { + "name": "HeroAndFriendsNames", + "source": "query HeroAndFriendsNames($episode: Episode) {\n hero(episode: $episode) {\n __typename\n name\n friends {\n __typename\n name\n }\n }\n}" + }, + "8e4ca76c63660898cfd5a3845e3709027750b5f0151c7f9be65759b869c5486d": { + "name": "HeroAndFriendsNamesWithIDs", + "source": "query HeroAndFriendsNamesWithIDs($episode: Episode) {\n hero(episode: $episode) {\n __typename\n id\n name\n friends {\n __typename\n id\n name\n }\n }\n}" + }, + "f091468a629f3b757c03a1b7710c6ede8b5c8f10df7ba3238f2bbcd71c56f90f": { + "name": "HeroAndFriendsNamesWithIDForParentOnly", + "source": "query HeroAndFriendsNamesWithIDForParentOnly($episode: Episode) {\n hero(episode: $episode) {\n __typename\n id\n name\n friends {\n __typename\n name\n }\n }\n}" + }, + "1d3ad903dad146ff9d7aa09813fc01becd017489bfc1af8ffd178498730a5a26": { + "name": "HeroAndFriendsNamesWithFragment", + "source": "query HeroAndFriendsNamesWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n name\n ...FriendsNames\n }\n}" + }, + "e02ef22e116ad1ca35f0298ed3badb60eeb986203f0088575a5f137768c322fc": { + "name": "HeroAndFriendsNamesWithFragmentTwice", + "source": "query HeroAndFriendsNamesWithFragmentTwice($episode: Episode) {\n hero(episode: $episode) {\n __typename\n friends {\n __typename\n ...CharacterName\n }\n ... on Droid {\n friends {\n __typename\n ...CharacterName\n }\n }\n }\n}" + }, + "22d772c0fc813281705e8f0a55fc70e71eeff6e98f3f9ef96cf67fb896914522": { + "name": "HeroAppearsIn", + "source": "query HeroAppearsIn {\n hero {\n __typename\n appearsIn\n }\n}" + }, + "1756158bd7736d58db45a48d74a724fa1b6fdac735376df8afac8318ba5431fb": { + "name": "HeroAppearsInWithFragment", + "source": "query HeroAppearsInWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n ...CharacterAppearsIn\n }\n}" + }, + "3dd42259adf2d0598e89e0279bee2c128a7913f02b1da6aa43f3b5def6a8a1f8": { + "name": "HeroNameConditionalExclusion", + "source": "query HeroNameConditionalExclusion($skipName: Boolean!) {\n hero {\n __typename\n name @skip(if: $skipName)\n }\n}" + }, + "338081aea3acc83d04af0741ecf0da1ec2ee8e6468a88383476b681015905ef8": { + "name": "HeroNameConditionalInclusion", + "source": "query HeroNameConditionalInclusion($includeName: Boolean!) {\n hero {\n __typename\n name @include(if: $includeName)\n }\n}" + }, + "66f4dc124b6374b1912b22a2a208e34a4b1997349402a372b95bcfafc7884064": { + "name": "HeroNameConditionalBoth", + "source": "query HeroNameConditionalBoth($skipName: Boolean!, $includeName: Boolean!) {\n hero {\n __typename\n name @skip(if: $skipName) @include(if: $includeName)\n }\n}" + }, + "d0f9e9205cdc09320035662f528a177654d3275b0bf94cf0e259a65fde33e7e5": { + "name": "HeroNameConditionalBothSeparate", + "source": "query HeroNameConditionalBothSeparate($skipName: Boolean!, $includeName: Boolean!) {\n hero {\n __typename\n name @skip(if: $skipName)\n name @include(if: $includeName)\n }\n}" + }, + "fcd9d7acb4e7c97e3ae5ad3cbf4e83556626149de589f0c2fce2f8ede31b0d90": { + "name": "HeroDetailsInlineConditionalInclusion", + "source": "query HeroDetailsInlineConditionalInclusion($includeDetails: Boolean!) {\n hero {\n __typename\n ... @include(if: $includeDetails) {\n name\n appearsIn\n }\n }\n}" + }, + "b31aec7d977249e185922e4cc90318fd2c7197631470904bf937b0626de54b4f": { + "name": "HeroDetailsFragmentConditionalInclusion", + "source": "query HeroDetailsFragmentConditionalInclusion($includeDetails: Boolean!) {\n hero {\n __typename\n ...HeroDetails @include(if: $includeDetails)\n }\n}" + }, + "4d465fbc6e3731d011025048502f16278307d73300ea9329a709d7e2b6815e40": { + "name": "HeroNameTypeSpecificConditionalInclusion", + "source": "query HeroNameTypeSpecificConditionalInclusion($episode: Episode, $includeName: Boolean!) {\n hero(episode: $episode) {\n __typename\n name @include(if: $includeName)\n ... on Droid {\n name\n }\n }\n}" + }, + "9bdfeee789c1d22123402a9c3e3edefeb66799b3436289751be8f47905e3babd": { + "name": "HeroFriendsDetailsConditionalInclusion", + "source": "query HeroFriendsDetailsConditionalInclusion($includeFriendsDetails: Boolean!) {\n hero {\n __typename\n friends @include(if: $includeFriendsDetails) {\n __typename\n name\n ... on Droid {\n primaryFunction\n }\n }\n }\n}" + }, + "501fcb710e5ffeeab2c65b7935fbded394ffea92e7b5dd904d05d5deab6f39c6": { + "name": "HeroFriendsDetailsUnconditionalAndConditionalInclusion", + "source": "query HeroFriendsDetailsUnconditionalAndConditionalInclusion($includeFriendsDetails: Boolean!) {\n hero {\n __typename\n friends {\n __typename\n name\n }\n friends @include(if: $includeFriendsDetails) {\n __typename\n name\n ... on Droid {\n primaryFunction\n }\n }\n }\n}" + }, + "2b67111fd3a1c6b2ac7d1ef7764e5cefa41d3f4218e1d60cb67c22feafbd43ec": { + "name": "HeroDetails", + "source": "query HeroDetails($episode: Episode) {\n hero(episode: $episode) {\n __typename\n name\n ... on Human {\n height\n }\n ... on Droid {\n primaryFunction\n }\n }\n}" + }, + "d20fa2f460058b8eec3d227f2f6088a708cf35dfa2b5ebf1414e34f9674ecfce": { + "name": "HeroDetailsWithFragment", + "source": "query HeroDetailsWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n ...HeroDetails\n }\n}" + }, + "7277e97563e911ac8f5c91d401028d218aae41f38df014d7fa0b037bb2a2e739": { + "name": "DroidDetailsWithFragment", + "source": "query DroidDetailsWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n ...DroidDetails\n }\n}" + }, + "37cd5626048e7243716ffda9e56503939dd189772124a1c21b0e0b87e69aae01": { + "name": "HeroFriendsOfFriendsNames", + "source": "query HeroFriendsOfFriendsNames($episode: Episode) {\n hero(episode: $episode) {\n __typename\n friends {\n __typename\n id\n friends {\n __typename\n name\n }\n }\n }\n}" + }, + "f6e76545cd03aa21368d9969cb39447f6e836a16717823281803778e7805d671": { + "name": "HeroName", + "source": "query HeroName($episode: Episode) {\n hero(episode: $episode) {\n __typename\n name\n }\n}" + }, + "83c03f612c46fca72f6cb902df267c57bffc9209bc44dd87d2524fb2b34f6f18": { + "name": "HeroNameWithID", + "source": "query HeroNameWithID($episode: Episode) {\n hero(episode: $episode) {\n __typename\n id\n name\n }\n}" + }, + "b952f0054915a32ec524ac0dde0244bcda246649debe149f9e32e303e21c8266": { + "name": "HeroNameWithFragment", + "source": "query HeroNameWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n ...CharacterName\n }\n}" + }, + "a87a0694c09d1ed245e9a80f245d96a5f57b20a4aa936ee9ab09b2a43620db02": { + "name": "HeroNameWithFragmentAndID", + "source": "query HeroNameWithFragmentAndID($episode: Episode) {\n hero(episode: $episode) {\n __typename\n id\n ...CharacterName\n }\n}" + }, + "0664fed3eb4f9fbdb44e8691d9e8fd11f2b3c097ba11327592054f602bd3ba1a": { + "name": "HeroNameAndAppearsInWithFragment", + "source": "query HeroNameAndAppearsInWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n ...CharacterNameAndAppearsIn\n }\n}" + }, + "561e22ac4da5209f254779b70e01557fb2fc57916b9914088429ec809e166cad": { + "name": "HeroParentTypeDependentField", + "source": "query HeroParentTypeDependentField($episode: Episode) {\n hero(episode: $episode) {\n __typename\n name\n ... on Human {\n friends {\n __typename\n name\n ... on Human {\n height(unit: FOOT)\n }\n }\n }\n ... on Droid {\n friends {\n __typename\n name\n ... on Human {\n height(unit: METER)\n }\n }\n }\n }\n}" + }, + "b5838c22bac1c5626023dac4412ca9b86bebfe16608991fb632a37c44e12811e": { + "name": "HeroTypeDependentAliasedField", + "source": "query HeroTypeDependentAliasedField($episode: Episode) {\n hero(episode: $episode) {\n __typename\n ... on Human {\n property: homePlanet\n }\n ... on Droid {\n property: primaryFunction\n }\n }\n}" + }, + "2a8ad85a703add7d64622aaf6be76b58a1134caf28e4ff6b34dd00ba89541364": { + "name": "SameHeroTwice", + "source": "query SameHeroTwice {\n hero {\n __typename\n name\n }\n r2: hero {\n __typename\n appearsIn\n }\n}" + }, + "a3734516185da9919e3e66d74fe92b60d65292a1943dc54913f7332637dfdd2a": { + "name": "Starship", + "source": "query Starship {\n starship(id: 3000) {\n __typename\n name\n coordinates\n }\n}" + }, + "38644c5e7cf4fd506b91d2e7010cabf84e63dfcd33cf1deb443b4b32b55e2cbe": { + "name": "ReviewAdded", + "source": "subscription ReviewAdded($episode: Episode) {\n reviewAdded(episode: $episode) {\n __typename\n episode\n stars\n commentary\n }\n}" + }, + "b37eb69b82fd52358321e49453769750983be1c286744dbf415735d7bcf12f1e": { + "name": "Human", + "source": "query Human($id: ID!) {\n human(id: $id) {\n __typename\n name\n mass\n }\n}" + }, + "b868fa9c48f19b8151c08c09f46831e3b9cd09f5c617d328647de785244b52bb": { + "name": "TwoHeroes", + "source": "query TwoHeroes {\n r2: hero {\n __typename\n name\n }\n luke: hero(episode: EMPIRE) {\n __typename\n name\n }\n}" + } +} \ No newline at end of file