diff --git a/Apollo.xcodeproj/project.pbxproj b/Apollo.xcodeproj/project.pbxproj index a94ab7179e..4d1fd59611 100644 --- a/Apollo.xcodeproj/project.pbxproj +++ b/Apollo.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 9B0E471E240B239D0093BDA7 /* ASTEnumValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B0E471D240B239D0093BDA7 /* ASTEnumValue.swift */; }; 9B1A38532332AF6F00325FB4 /* String+SHA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1A38522332AF6F00325FB4 /* String+SHA.swift */; }; 9B1CCDD92360F02C007C9032 /* Bundle+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1CCDD82360F02C007C9032 /* Bundle+Helpers.swift */; }; + 9B21FD752422C29D00998B5C /* GraphQLFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B21FD742422C29D00998B5C /* GraphQLFileTests.swift */; }; + 9B21FD772422C8CC00998B5C /* TestFileHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B21FD762422C8CC00998B5C /* TestFileHelper.swift */; }; 9B518C87235F819E004C426D /* CLIDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B518C85235F8125004C426D /* CLIDownloader.swift */; }; 9B518C8C235F8B5F004C426D /* ApolloFilePathHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B518C8A235F8B05004C426D /* ApolloFilePathHelper.swift */; }; 9B518C8D235F8B9E004C426D /* CLIDownloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B518C88235F8AD4004C426D /* CLIDownloaderTests.swift */; }; @@ -360,6 +362,8 @@ 9B0E471D240B239D0093BDA7 /* ASTEnumValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASTEnumValue.swift; sourceTree = ""; }; 9B1A38522332AF6F00325FB4 /* String+SHA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA.swift"; sourceTree = ""; }; 9B1CCDD82360F02C007C9032 /* Bundle+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Helpers.swift"; sourceTree = ""; }; + 9B21FD742422C29D00998B5C /* GraphQLFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLFileTests.swift; sourceTree = ""; }; + 9B21FD762422C8CC00998B5C /* TestFileHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFileHelper.swift; sourceTree = ""; }; 9B4AA8AD239EFDC9003E1300 /* Apollo-Target-CodegenTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Target-CodegenTests.xcconfig"; sourceTree = ""; }; 9B518C85235F8125004C426D /* CLIDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIDownloader.swift; sourceTree = ""; }; 9B518C88235F8AD4004C426D /* CLIDownloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIDownloaderTests.swift; sourceTree = ""; }; @@ -721,6 +725,7 @@ children = ( C3279FC52345233000224790 /* TestCustomRequestCreator.swift */, 9B64F6752354D219002D1BB5 /* URL+QueryDict.swift */, + 9B21FD762422C8CC00998B5C /* TestFileHelper.swift */, ); name = TestHelpers; sourceTree = ""; @@ -1150,6 +1155,7 @@ 9B78C71B2326E859000C8C32 /* ErrorGenerationTests.swift */, 9F8622F91EC2117C00C38162 /* FragmentConstructionAndConversionTests.swift */, 9B95EDBF22CAA0AF00702BB2 /* GETTransformerTests.swift */, + 9B21FD742422C29D00998B5C /* GraphQLFileTests.swift */, 9BF1A94C22CA54F9005292C2 /* HTTPTransportTests.swift */, 9FF90A6A1DDDEB420034C3B6 /* InputValueEncodingTests.swift */, E86D8E03214B32DA0028EFE1 /* JSONTests.swift */, @@ -2013,6 +2019,8 @@ 9FE1C6E71E634C8D00C02284 /* PromiseTests.swift in Sources */, 9B64F6762354D219002D1BB5 /* URL+QueryDict.swift in Sources */, 9FADC8541E6B86D900C677E6 /* DataLoaderTests.swift in Sources */, + 9B21FD772422C8CC00998B5C /* TestFileHelper.swift in Sources */, + 9B21FD752422C29D00998B5C /* GraphQLFileTests.swift in Sources */, E86D8E05214B32FD0028EFE1 /* JSONTests.swift in Sources */, 9F8622FA1EC2117C00C38162 /* FragmentConstructionAndConversionTests.swift in Sources */, C338DF1722DD9DE9006AF33E /* RequestCreatorTests.swift in Sources */, diff --git a/Sources/Apollo/GraphQLFile.swift b/Sources/Apollo/GraphQLFile.swift index abc7140ed3..7fe7823128 100644 --- a/Sources/Apollo/GraphQLFile.swift +++ b/Sources/Apollo/GraphQLFile.swift @@ -8,6 +8,20 @@ public struct GraphQLFile { public let data: Data? public let fileURL: URL? public let contentLength: UInt64 + + public enum GraphQLFileError: Error, LocalizedError { + case couldNotCreateInputStream + case couldNotGetFileSize(fileURL: URL) + + public var errorDescription: String? { + switch self { + case .couldNotCreateInputStream: + return "An input stream could not be created from either the passed-in file URL or data. Please check that you've passed at least one of these, and that for files you have proper permission to stream data." + case .couldNotGetFileSize(let fileURL): + return "Apollo could not get the file size for the file at \(fileURL). This likely indicates either a) The file is not at that URL or b) a permissions issue." + } + } + } /// A convenience constant for declaring your mimetype is octet-stream. public static let octetStreamMimeType = "application/octet-stream" @@ -31,46 +45,47 @@ public struct GraphQLFile { self.contentLength = UInt64(data.count) } - /// Failable convenience initializer for files in the filesystem - /// Will return `nil` if the file URL cannot be used to create an `InputStream`, or if the file's size could not be determined. + /// Throwing convenience initializer for files in the filesystem /// /// - Parameters: /// - fieldName: The name of the field this file is being sent for /// - originalName: The original name of the file /// - mimeType: The mime type of the file to send to the server. Defaults to `GraphQLFile.octetStreamMimeType`. /// - fileURL: The URL of the file to upload. - public init?(fieldName: String, + /// - Throws: If the file's size could not be determined + public init(fieldName: String, originalName: String, mimeType: String = GraphQLFile.octetStreamMimeType, - fileURL: URL) { - guard let contentLength = GraphQLFile.getFileSize(fileURL: fileURL) else { - return nil - } - + fileURL: URL) throws { + self.contentLength = try GraphQLFile.getFileSize(fileURL: fileURL) self.fieldName = fieldName self.originalName = originalName self.mimeType = mimeType self.data = nil self.fileURL = fileURL - self.contentLength = contentLength } - /// Retrieves the InputStream + /// Uses either the data or the file URL to create an + /// `InputStream` that can be used to stream data into + /// a multipart-form. /// + /// - Returns: The created `InputStream`. + /// - Throws: If an input stream could not be created from either data or a file URL. public func generateInputStream() throws -> InputStream { if let data = data { return InputStream(data: data) - } else if let fileURL = fileURL, let inputStream = InputStream(url: fileURL) { + } else if let fileURL = fileURL, + let inputStream = InputStream(url: fileURL) { return inputStream + } else { + throw GraphQLFileError.couldNotCreateInputStream } - - throw GraphQLError("InputStream was not created.") } - private static func getFileSize(fileURL: URL) -> UInt64? { + private static func getFileSize(fileURL: URL) throws -> UInt64 { guard let fileSizeAttribute = try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size], let fileSize = fileSizeAttribute as? NSNumber else { - return nil + throw GraphQLFileError.couldNotGetFileSize(fileURL: fileURL) } return fileSize.uint64Value diff --git a/Tests/ApolloTests/GraphQLFileTests.swift b/Tests/ApolloTests/GraphQLFileTests.swift new file mode 100644 index 0000000000..c36bc6bc12 --- /dev/null +++ b/Tests/ApolloTests/GraphQLFileTests.swift @@ -0,0 +1,87 @@ +// +// GraphQLFileTests.swift +// ApolloTests +// +// Created by Ellen Shapiro on 3/18/20. +// Copyright © 2020 Apollo GraphQL. All rights reserved. +// + +import XCTest + +@testable import Apollo + +class GraphQLFileTests: XCTestCase { + + func testCreatingFileWithKnownBadURLFails() { + let url = URL(fileURLWithPath: "/known/bad/path") + do { + _ = try GraphQLFile(fieldName: "test", + originalName: "test", + fileURL: url) + } catch { + switch error { + case GraphQLFile.GraphQLFileError.couldNotGetFileSize(let fileURL): + XCTAssertEqual(fileURL, url) + default: + XCTFail("Unexpected error creating file: \(error)") + } + } + } + + func testCreatingFileWithKnownGoodURLSucceedsAndCreatesAndCanRecreateInputStream() throws { + let knownFileURL = TestFileHelper.testParentFolder() + .appendingPathComponent("a.txt") + + let file = try GraphQLFile(fieldName: "test", + originalName: "test", + fileURL: knownFileURL) + + let inputStream = try file.generateInputStream() + + inputStream.open() + XCTAssertTrue(inputStream.hasBytesAvailable) + inputStream.close() + + let inputStream2 = try file.generateInputStream() + + inputStream2.open() + XCTAssertTrue(inputStream2.hasBytesAvailable) + inputStream2.close() + } + + func testCreatingFileWithEmptyDataSucceedsAndCreatesInputStream() throws { + let data = Data() + XCTAssertTrue(data.isEmpty) + + let file = GraphQLFile(fieldName: "test", + originalName: "test", + data: data) + + let inputStream = try file.generateInputStream() + + // Shouldn't have any bytes available if data is empty + inputStream.open() + XCTAssertFalse(inputStream.hasBytesAvailable) + inputStream.close() + } + + func testCreatingFileWithNonEmptyDataSuccedsAndCreatesAndCanRecreateInputStream() throws { + let data = try XCTUnwrap("A test string".data(using: .utf8)) + XCTAssertFalse(data.isEmpty) + + let file = GraphQLFile(fieldName: "test", + originalName: "test", + data: data) + + let inputStream = try file.generateInputStream() + inputStream.open() + XCTAssertTrue(inputStream.hasBytesAvailable) + inputStream.close() + + let inputStream2 = try file.generateInputStream() + + inputStream2.open() + XCTAssertTrue(inputStream2.hasBytesAvailable) + inputStream2.close() + } +} diff --git a/Tests/ApolloTests/RequestCreatorTests.swift b/Tests/ApolloTests/RequestCreatorTests.swift index 15e9c133f2..8b6af60a83 100644 --- a/Tests/ApolloTests/RequestCreatorTests.swift +++ b/Tests/ApolloTests/RequestCreatorTests.swift @@ -14,13 +14,7 @@ class RequestCreatorTests: XCTestCase { private let customRequestCreator = TestCustomRequestCreator() private let apolloRequestCreator = ApolloRequestCreator() - private func testParentFolder(for file: StaticString = #file) -> URL { - let fileAsString = file.withUTF8Buffer { - String(decoding: $0, as: UTF8.self) - } - let url = URL(fileURLWithPath: fileAsString) - return url.deletingLastPathComponent() - } + private func checkString(_ string: String, includes expectedString: String, @@ -41,7 +35,7 @@ class RequestCreatorTests: XCTestCase { } private func fileURLForFile(named name: String, extension fileExtension: String) -> URL { - return self.testParentFolder() + return TestFileHelper.testParentFolder() .appendingPathComponent(name) .appendingPathExtension(fileExtension) } @@ -172,14 +166,14 @@ Charlie file content. func testSingleFileWithApolloRequestCreator() throws { let alphaFileUrl = self.fileURLForFile(named: "a", extension: "txt") - let alphaFile = GraphQLFile(fieldName: "upload", - originalName: "a.txt", + let alphaFile = try GraphQLFile(fieldName: "upload", + originalName: "a.txt", mimeType: "text/plain", fileURL: alphaFileUrl) let data = try apolloRequestCreator.requestMultipartFormData( for: HeroNameQuery(), - files: [alphaFile!], + files: [alphaFile], sendOperationIdentifiers: false, serializationFormat: JSONSerializationFormat.self, manualBoundary: "TEST.BOUNDARY" @@ -227,16 +221,16 @@ Alpha file content. func testMultipleFilesWithApolloRequestCreator() throws { let alphaFileURL = self.fileURLForFile(named: "a", extension: "txt") - let alphaFile = GraphQLFile(fieldName: "uploads", - originalName: "a.txt", - mimeType: "text/plain", - fileURL: alphaFileURL)! + let alphaFile = try GraphQLFile(fieldName: "uploads", + originalName: "a.txt", + mimeType: "text/plain", + fileURL: alphaFileURL) let betaFileURL = self.fileURLForFile(named: "b", extension: "txt") - let betaFile = GraphQLFile(fieldName: "uploads", - originalName: "b.txt", - mimeType: "text/plain", - fileURL: betaFileURL)! + let betaFile = try GraphQLFile(fieldName: "uploads", + originalName: "b.txt", + mimeType: "text/plain", + fileURL: betaFileURL) let data = try apolloRequestCreator.requestMultipartFormData( @@ -307,14 +301,14 @@ Bravo file content. 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 alphaFile = try GraphQLFile(fieldName: "upload", + originalName: "a.txt", + mimeType: "text/plain", + fileURL: alphaFileUrl) let data = try customRequestCreator.requestMultipartFormData( for: HeroNameQuery(), - files: [alphaFile!], + files: [alphaFile], sendOperationIdentifiers: false, serializationFormat: JSONSerializationFormat.self, manualBoundary: "TEST.BOUNDARY" diff --git a/Tests/ApolloTests/TestFileHelper.swift b/Tests/ApolloTests/TestFileHelper.swift new file mode 100644 index 0000000000..52b62e4756 --- /dev/null +++ b/Tests/ApolloTests/TestFileHelper.swift @@ -0,0 +1,20 @@ +// +// TestFileHelper.swift +// ApolloTests +// +// Created by Ellen Shapiro on 3/18/20. +// Copyright © 2020 Apollo GraphQL. All rights reserved. +// + +import Foundation + +struct TestFileHelper { + + static func testParentFolder(for file: StaticString = #file) -> URL { + let fileAsString = file.withUTF8Buffer { + String(decoding: $0, as: UTF8.self) + } + let url = URL(fileURLWithPath: fileAsString) + return url.deletingLastPathComponent() + } +}