diff --git a/Apollo.xcodeproj/project.pbxproj b/Apollo.xcodeproj/project.pbxproj index 115455a177..8d4e5a3cec 100644 --- a/Apollo.xcodeproj/project.pbxproj +++ b/Apollo.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 54DDB0921EA045870009DD99 /* InMemoryNormalizedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DDB0911EA045870009DD99 /* InMemoryNormalizedCache.swift */; }; 5AC6CA4322AAF7B200B7C94D /* GraphQLHTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AC6CA4222AAF7B200B7C94D /* GraphQLHTTPMethod.swift */; }; 9B95EDC022CAA0B000702BB2 /* GETTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */; }; + 9BA1244A22D8A8EA00BF1D24 /* JSONSerialziation+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA1244922D8A8EA00BF1D24 /* JSONSerialziation+Sorting.swift */; }; 9BDE43D122C6655300FD7C7F /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BDE43D022C6655200FD7C7F /* Cancellable.swift */; }; 9BDE43DD22C6705300FD7C7F /* GraphQLHTTPResponseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BDE43DC22C6705300FD7C7F /* GraphQLHTTPResponseError.swift */; }; 9BDE43DF22C6708600FD7C7F /* GraphQLHTTPRequestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BDE43DE22C6708600FD7C7F /* GraphQLHTTPRequestError.swift */; }; @@ -252,6 +253,7 @@ 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 = ""; }; 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GETTransformerTests.swift; sourceTree = ""; }; + 9BA1244922D8A8EA00BF1D24 /* JSONSerialziation+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONSerialziation+Sorting.swift"; sourceTree = ""; }; 9BDE43D022C6655200FD7C7F /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; 9BDE43DC22C6705300FD7C7F /* GraphQLHTTPResponseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLHTTPResponseError.swift; sourceTree = ""; }; 9BDE43DE22C6708600FD7C7F /* GraphQLHTTPRequestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLHTTPRequestError.swift; sourceTree = ""; }; @@ -520,6 +522,7 @@ 9FC4B91F1D2A6F8D0046A641 /* JSON.swift */, 9FEB050C1DB5732300DA3B44 /* JSONSerializationFormat.swift */, 9F27D4631D40379500715680 /* JSONStandardTypeConversions.swift */, + 9BA1244922D8A8EA00BF1D24 /* JSONSerialziation+Sorting.swift */, ); name = JSON; sourceTree = ""; @@ -1118,6 +1121,7 @@ 9FC9A9C51E2D6CE70023C4D5 /* GraphQLSelectionSet.swift in Sources */, 9BDE43DD22C6705300FD7C7F /* GraphQLHTTPResponseError.swift in Sources */, 9FCDFD231E33A0D8007519DC /* AsynchronousOperation.swift in Sources */, + 9BA1244A22D8A8EA00BF1D24 /* JSONSerialziation+Sorting.swift in Sources */, 9FC9A9CC1E2FD0760023C4D5 /* Record.swift in Sources */, 9FC4B9201D2A6F8D0046A641 /* JSON.swift in Sources */, 9FEC15B41E681DAD00D461B4 /* Collections.swift in Sources */, diff --git a/Sources/Apollo/GraphQLGETTransformer.swift b/Sources/Apollo/GraphQLGETTransformer.swift index 3f6016cc11..61aa91f35d 100644 --- a/Sources/Apollo/GraphQLGETTransformer.swift +++ b/Sources/Apollo/GraphQLGETTransformer.swift @@ -44,12 +44,12 @@ struct GraphQLGETTransformer { queryItems.append(URLQueryItem(name: self.queryKey, value: query)) components.queryItems = queryItems - guard let variables = self.body.jsonObject[self.variablesKey] as? [String: AnyHashable] else { + guard let variables = self.body.jsonObject[self.variablesKey] as? [String: Any] else { return components.url } guard - let serializedData = try? JSONSerialization.data(withJSONObject: variables), + let serializedData = try? JSONSerialization.dataSortedIfPossible(withJSONObject: variables), let jsonString = String(bytes: serializedData, encoding: .utf8) else { return components.url } diff --git a/Sources/Apollo/JSONSerialziation+Sorting.swift b/Sources/Apollo/JSONSerialziation+Sorting.swift new file mode 100644 index 0000000000..57b3ed2919 --- /dev/null +++ b/Sources/Apollo/JSONSerialziation+Sorting.swift @@ -0,0 +1,29 @@ +// +// JSONSerialziation+Sorting.swift +// Apollo +// +// Created by Ellen Shapiro on 7/12/19. +// Copyright © 2019 Apollo GraphQL. All rights reserved. +// + +import Foundation + +extension JSONSerialization { + + /// Uses `sortedKeys` to create a stable representation of JSON objects when the operating system supports it. + /// + /// - Parameter object: The object to serialize + /// - Returns: The serialized data + /// - Throws: Errors related to the serialization of data. + static func dataSortedIfPossible(withJSONObject object: Any) throws -> Data { + // The `sortedKeys` option is not available on all platforms we + // presently support, but we should use it where we can in + // order to get stable JSON representations, especially if being + // used in queries. + if #available(iOS 11, macOS 13, watchOS 4, tvOS 11, *) { + return try self.data(withJSONObject: object, options: [.sortedKeys]) + } else { + return try self.data(withJSONObject: object) + } + } +} diff --git a/Tests/ApolloTests/GETTransformerTests.swift b/Tests/ApolloTests/GETTransformerTests.swift index f3ccf03761..3f974f5f0c 100644 --- a/Tests/ApolloTests/GETTransformerTests.swift +++ b/Tests/ApolloTests/GETTransformerTests.swift @@ -28,6 +28,64 @@ class GETTransformerTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "http://localhost:8080/graphql?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") } + func testEncodingQueryWithMoreThanOneParameterIncludingNonHashableValue() { + let operation = HeroNameTypeSpecificConditionalInclusionQuery(episode: .jedi, includeName: true) + let body: GraphQLMap = [ + "query": operation.queryDocument, + "variables": operation.variables, + ] + + let transformer = GraphQLGETTransformer(body: body, url: self.url) + + let url = transformer.createGetURL() + + if #available(iOS 11, macOS 13, tvOS 11, watchOS 4, *) { + // Here, we know that everything should be encoded in a stable order, + // and we can check the encoded URL string directly. + XCTAssertEqual(url?.absoluteString, "http://localhost:8080/graphql?query=query%20HeroNameTypeSpecificConditionalInclusion($episode:%20Episode,%20$includeName:%20Boolean!)%20%7B%0A%20%20hero(episode:%20$episode)%20%7B%0A%20%20%20%20__typename%0A%20%20%20%20name%20@include(if:%20$includeName)%0A%20%20%20%20...%20on%20Droid%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D&variables=%7B%22episode%22:%22JEDI%22,%22includeName%22:true%7D") + } else { + // We can't guarantee order of encoding, so we need to pull the JSON back + // out and check that it has the correct and correctly typed properties. + guard let transformedURL = url else { + XCTFail("URL not created!") + return + } + + guard let urlComponents = URLComponents(url: transformedURL, resolvingAgainstBaseURL: false) else { + XCTFail("Couldn't access URL components") + return + } + + guard let queryItems = urlComponents.queryItems else { + XCTFail("No query items!") + return + } + + + guard + let variablesQueryItem = queryItems.first(where: { $0.name == "variables" }), + let variables = variablesQueryItem.value else { + XCTFail("Query items did not contain variables!") + return + } + + guard let data = variables.data(using: .utf8) else { + XCTFail("Couldn't convert data to UTF8 string!") + return + } + + guard + let object = try? JSONSerialization.jsonObject(with: data), + let dict = object as? [String: Any] else { + XCTFail("Couldn't get dictionary out of json!") + return + } + + XCTAssertEqual(dict["includeName"] as? Bool, true) + XCTAssertEqual(dict["episode"] as? String, "JEDI") + } + } + func testEncodingQueryWithNullDefaultParameter() { let operation = HeroNameQuery() let body: GraphQLMap = [