diff --git a/Apollo.xcodeproj/project.pbxproj b/Apollo.xcodeproj/project.pbxproj index 0669e49f44..60fb8478ab 100644 --- a/Apollo.xcodeproj/project.pbxproj +++ b/Apollo.xcodeproj/project.pbxproj @@ -108,7 +108,8 @@ 9FF90A711DDDEB420034C3B6 /* ReadFieldValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF90A6B1DDDEB420034C3B6 /* ReadFieldValueTests.swift */; }; 9FF90A731DDDEB420034C3B6 /* ParseQueryResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF90A6C1DDDEB420034C3B6 /* ParseQueryResponseTests.swift */; }; C304EBD722DDC8E600748F72 /* a.txt in Resources */ = {isa = PBXBuildFile; fileRef = C304EBD322DDC7B200748F72 /* a.txt */; }; - C338DF1722DD9DE9006AF33E /* MultipartFormDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C338DF1622DD9DE9006AF33E /* MultipartFormDataTests.swift */; }; + C3279FC72345234D00224790 /* TestCustomRequestCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3279FC52345233000224790 /* TestCustomRequestCreator.swift */; }; + C338DF1722DD9DE9006AF33E /* RequestCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C338DF1622DD9DE9006AF33E /* RequestCreatorTests.swift */; }; C35D43C222DDD4AC00BCBABE /* b.txt in Resources */ = {isa = PBXBuildFile; fileRef = C35D43BE22DDD3C100BCBABE /* b.txt */; }; C35D43C322DDD4AF00BCBABE /* c.txt in Resources */ = {isa = PBXBuildFile; fileRef = C35D43BF22DDD3C100BCBABE /* c.txt */; }; C35D43C422DDD4D100BCBABE /* b.txt in Resources */ = {isa = PBXBuildFile; fileRef = C35D43BE22DDD3C100BCBABE /* b.txt */; }; @@ -381,7 +382,8 @@ 9FF90A6B1DDDEB420034C3B6 /* ReadFieldValueTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadFieldValueTests.swift; sourceTree = ""; }; 9FF90A6C1DDDEB420034C3B6 /* ParseQueryResponseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParseQueryResponseTests.swift; sourceTree = ""; }; C304EBD322DDC7B200748F72 /* a.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = a.txt; sourceTree = ""; }; - C338DF1622DD9DE9006AF33E /* MultipartFormDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartFormDataTests.swift; sourceTree = ""; }; + C3279FC52345233000224790 /* TestCustomRequestCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCustomRequestCreator.swift; sourceTree = ""; }; + C338DF1622DD9DE9006AF33E /* RequestCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestCreatorTests.swift; sourceTree = ""; }; C35D43BE22DDD3C100BCBABE /* b.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = b.txt; sourceTree = ""; }; C35D43BF22DDD3C100BCBABE /* c.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = c.txt; sourceTree = ""; }; C377CCA822D798BD00572E03 /* GraphQLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLFile.swift; sourceTree = ""; }; @@ -664,14 +666,15 @@ 9BF1A94C22CA54F9005292C2 /* HTTPTransportTests.swift */, 9FF90A6A1DDDEB420034C3B6 /* InputValueEncodingTests.swift */, E86D8E03214B32DA0028EFE1 /* JSONTests.swift */, - C338DF1622DD9DE9006AF33E /* MultipartFormDataTests.swift */, 9F91CF8E1F6C0DB2008DD0BE /* MutatingResultsTests.swift */, 9F295E301E27534800A24949 /* NormalizeQueryResults.swift */, 9FF90A6C1DDDEB420034C3B6 /* ParseQueryResponseTests.swift */, 9FE1C6E61E634C8D00C02284 /* PromiseTests.swift */, F16D083B21EF6F7300C458B8 /* QueryFromJSONBuildingTests.swift */, 9FF90A6B1DDDEB420034C3B6 /* ReadFieldValueTests.swift */, + C338DF1622DD9DE9006AF33E /* RequestCreatorTests.swift */, 9F19D8451EED8D3B00C57247 /* ResultOrPromiseTests.swift */, + C3279FC52345233000224790 /* TestCustomRequestCreator.swift */, C304EBD322DDC7B200748F72 /* a.txt */, C35D43BE22DDD3C100BCBABE /* b.txt */, C35D43BF22DDD3C100BCBABE /* c.txt */, @@ -1251,13 +1254,14 @@ 9F91CF8F1F6C0DB2008DD0BE /* MutatingResultsTests.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 */, 9FADC8541E6B86D900C677E6 /* DataLoaderTests.swift in Sources */, E86D8E05214B32FD0028EFE1 /* JSONTests.swift in Sources */, 9F8622FA1EC2117C00C38162 /* FragmentConstructionAndConversionTests.swift in Sources */, - C338DF1722DD9DE9006AF33E /* MultipartFormDataTests.swift in Sources */, + C338DF1722DD9DE9006AF33E /* RequestCreatorTests.swift in Sources */, F16D083C21EF6F7300C458B8 /* QueryFromJSONBuildingTests.swift in Sources */, 9FF90A711DDDEB420034C3B6 /* ReadFieldValueTests.swift in Sources */, 9F295E311E27534800A24949 /* NormalizeQueryResults.swift in Sources */, diff --git a/Sources/Apollo/HTTPNetworkTransport.swift b/Sources/Apollo/HTTPNetworkTransport.swift index d052d8149a..9b4f1e81a4 100644 --- a/Sources/Apollo/HTTPNetworkTransport.swift +++ b/Sources/Apollo/HTTPNetworkTransport.swift @@ -237,7 +237,8 @@ public class HTTPNetworkTransport { for: operation, files: files, sendOperationIdentifiers: self.sendOperationIdentifiers, - serializationFormat: self.serializationFormat) + serializationFormat: self.serializationFormat, + manualBoundary: nil) request.setValue("multipart/form-data; boundary=\(formData.boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = try formData.encode() diff --git a/Sources/Apollo/MultipartFormData.swift b/Sources/Apollo/MultipartFormData.swift index d9c8b0da4a..cb65ef82ce 100644 --- a/Sources/Apollo/MultipartFormData.swift +++ b/Sources/Apollo/MultipartFormData.swift @@ -34,7 +34,12 @@ public class MultipartFormData { self.init(boundary: "apollo-ios.boundary.\(UUID().uuidString)") } - func appendPart(string: String, name: String) throws { + /// Appends the passed-in string as a part of the body. + /// + /// - Parameters: + /// - string: The string to append + /// - name: The name of the part to pass along to the server + public func appendPart(string: String, name: String) throws { self.appendPart(data: try self.encode(string: string), name: name, contentType: nil) diff --git a/Sources/Apollo/RequestCreator.swift b/Sources/Apollo/RequestCreator.swift index 8f093dd9ee..3f016e1e55 100644 --- a/Sources/Apollo/RequestCreator.swift +++ b/Sources/Apollo/RequestCreator.swift @@ -33,7 +33,7 @@ extension RequestCreator { /// - operation: The operation to use /// - sendOperationIdentifiers: Whether or not to send operation identifiers. Defaults to false. /// - Returns: The created `GraphQLMap` - public func requestBody(for operation: Operation, sendOperationIdentifiers: Bool = false) -> GraphQLMap { + public func requestBody(for operation: Operation, sendOperationIdentifiers: Bool) -> GraphQLMap { var body: GraphQLMap = [ "variables": operation.variables, "operationName": operation.operationName, @@ -66,7 +66,7 @@ extension RequestCreator { files: [GraphQLFile], sendOperationIdentifiers: Bool, serializationFormat: JSONSerializationFormat.Type, - manualBoundary: String? = nil) throws -> MultipartFormData { + manualBoundary: String?) throws -> MultipartFormData { let formData: MultipartFormData if let boundary = manualBoundary { diff --git a/Tests/ApolloTests/GETTransformerTests.swift b/Tests/ApolloTests/GETTransformerTests.swift index 2f2913649a..1deb1b4268 100644 --- a/Tests/ApolloTests/GETTransformerTests.swift +++ b/Tests/ApolloTests/GETTransformerTests.swift @@ -16,7 +16,7 @@ class GETTransformerTests: XCTestCase { func testEncodingQueryWithSingleParameter() { let operation = HeroNameQuery(episode: .empire) - let body = requestCreator.requestBody(for: operation) + let body = requestCreator.requestBody(for: operation, sendOperationIdentifiers: false) let transformer = GraphQLGETTransformer(body: body, url: self.url) @@ -27,7 +27,7 @@ class GETTransformerTests: XCTestCase { func testEncodingQueryWithMoreThanOneParameterIncludingNonHashableValue() { let operation = HeroNameTypeSpecificConditionalInclusionQuery(episode: .jedi, includeName: true) - let body = requestCreator.requestBody(for: operation) + let body = requestCreator.requestBody(for: operation, sendOperationIdentifiers: false) let transformer = GraphQLGETTransformer(body: body, url: self.url) @@ -90,7 +90,7 @@ class GETTransformerTests: XCTestCase { func testEncodingQueryWithNullDefaultParameter() { let operation = HeroNameQuery() - let body = requestCreator.requestBody(for: operation) + let body = requestCreator.requestBody(for: operation, sendOperationIdentifiers: false) let transformer = GraphQLGETTransformer(body: body, url: self.url) diff --git a/Tests/ApolloTests/MultipartFormDataTests.swift b/Tests/ApolloTests/RequestCreatorTests.swift similarity index 82% rename from Tests/ApolloTests/MultipartFormDataTests.swift rename to Tests/ApolloTests/RequestCreatorTests.swift index 959f52a200..511d218355 100644 --- a/Tests/ApolloTests/MultipartFormDataTests.swift +++ b/Tests/ApolloTests/RequestCreatorTests.swift @@ -10,8 +10,9 @@ import XCTest @testable import Apollo import StarWarsAPI -class MultipartFormDataTests: XCTestCase { - private let requestCreator = ApolloRequestCreator() +class RequestCreatorTests: XCTestCase { + private let customRequestCreator = TestCustomRequestCreator() + private let apolloRequestCreator = ApolloRequestCreator() private func checkString(_ string: String, includes expectedString: String, @@ -158,7 +159,7 @@ Charlie file content. XCTAssertEqual(stringToCompare, expectedString) } - func testSingleFileWithRequestCreator() throws { + func testSingleFileWithApolloRequestCreator() throws { let alphaFileUrl = self.fileURLForFile(named: "a", extension: "txt") let alphaFile = GraphQLFile(fieldName: "upload", @@ -166,7 +167,7 @@ Charlie file content. mimeType: "text/plain", fileURL: alphaFileUrl) - let data = try requestCreator.requestMultipartFormData( + let data = try apolloRequestCreator.requestMultipartFormData( for: HeroNameQuery(), files: [alphaFile!], sendOperationIdentifiers: false, @@ -213,8 +214,8 @@ Alpha file content. self.checkString(stringToCompare, includes: expectedEndString) } } - - func testMultipleFilesWithRequestCreator() throws { + + func testMultipleFilesWithApolloRequestCreator() throws { let alphaFileURL = self.fileURLForFile(named: "a", extension: "txt") let alphaFile = GraphQLFile(fieldName: "uploads", originalName: "a.txt", @@ -228,7 +229,7 @@ Alpha file content. fileURL: betaFileURL)! - let data = try requestCreator.requestMultipartFormData( + let data = try apolloRequestCreator.requestMultipartFormData( for: HeroNameQuery(), files: [alphaFile, betaFile], sendOperationIdentifiers: false, @@ -283,4 +284,59 @@ Bravo file content. self.checkString(stringToCompare, includes: endString) } } + + func testRequestBodyWithApolloRequestCreator() { + let query = HeroNameQuery() + let req = apolloRequestCreator.requestBody(for: query, sendOperationIdentifiers: false) + + XCTAssertEqual(query.queryDocument, req["query"] as? String) + } + + // MARK: - Custom request creator tests + + func testSingleFileWithCustomRequestCreator() throws { + let alphaFileUrl = self.fileURLForFile(named: "a", extension: "txt") + + let alphaFile = GraphQLFile(fieldName: "upload", + originalName: "a.txt", + mimeType: "text/plain", + fileURL: alphaFileUrl) + + let data = try customRequestCreator.requestMultipartFormData( + for: HeroNameQuery(), + files: [alphaFile!], + sendOperationIdentifiers: false, + serializationFormat: JSONSerializationFormat.self, + manualBoundary: "TEST.BOUNDARY" + ) + + let stringToCompare = try self.string(from: data) + + // Operation parameters may be in weird order, so let's at least check that the files and single parameter got encoded properly. + let expectedEndString = """ +--TEST.BOUNDARY +Content-Disposition: form-data; name="upload"; filename="a.txt" +Content-Type: text/plain + +Alpha file content. + +--TEST.BOUNDARY-- +""" + + let expectedQueryString = """ +--TEST.BOUNDARY +Content-Disposition: form-data; name="test_query" + +query HeroName($episode: Episode) { hero(episode: $episode) { __typename name } } +""" + self.checkString(stringToCompare, includes: expectedEndString) + self.checkString(stringToCompare, includes: expectedQueryString) + } + + func testRequestBodyWithCustomRequestCreator() { + let query = HeroNameQuery() + let req = customRequestCreator.requestBody(for: query, sendOperationIdentifiers: false) + + XCTAssertEqual(query.queryDocument, req["test_query"] as? String) + } } diff --git a/Tests/ApolloTests/TestCustomRequestCreator.swift b/Tests/ApolloTests/TestCustomRequestCreator.swift new file mode 100644 index 0000000000..9a58ca56ed --- /dev/null +++ b/Tests/ApolloTests/TestCustomRequestCreator.swift @@ -0,0 +1,62 @@ +// +// TestCustomRequestCreator.swift +// Apollo +// +// Created by Kim de Vos on 02/10/2019. +// Copyright © 2019 Apollo GraphQL. All rights reserved. +// + +import Apollo + +struct TestCustomRequestCreator: RequestCreator { + public func requestBody(for operation: Operation, sendOperationIdentifiers: Bool) -> GraphQLMap { + var body: GraphQLMap = [ + "test_variables": operation.variables, + "test_operationName": operation.operationName, + ] + + if sendOperationIdentifiers { + guard let operationIdentifier = operation.operationIdentifier else { + preconditionFailure("To send operation identifiers, Apollo types must be generated with operationIdentifiers") + } + + body["test_id"] = operationIdentifier + } else { + body["test_query"] = operation.queryDocument + } + + return body + } + + public func requestMultipartFormData(for operation: Operation, + files: [GraphQLFile], + sendOperationIdentifiers: Bool, + serializationFormat: JSONSerializationFormat.Type, + manualBoundary: String?) throws -> MultipartFormData { + let formData: MultipartFormData + + if let boundary = manualBoundary { + formData = MultipartFormData(boundary: boundary) + } else { + formData = MultipartFormData() + } + + let fields = requestBody(for: operation, sendOperationIdentifiers: false) + for (name, data) in fields { + if let data = data as? GraphQLMap { + let data = try serializationFormat.serialize(value: data) + formData.appendPart(data: data, name: name) + } else if let data = data as? String { + try formData.appendPart(string: data, name: name) + } else { + try formData.appendPart(string: data.debugDescription, name: name) + } + } + + files.forEach { + formData.appendPart(inputStream: $0.inputStream, contentLength: $0.contentLength, name: $0.fieldName, contentType: $0.mimeType, filename: $0.originalName) + } + + return formData + } +}