diff --git a/Alamofire.xcodeproj/project.pbxproj b/Alamofire.xcodeproj/project.pbxproj index 8dd480c70d4e4500090c53e0b7377521ba747cc9..ec9716e9de325a85eb978f3cfb1256f9d6b4db46 100644 --- a/Alamofire.xcodeproj/project.pbxproj +++ b/Alamofire.xcodeproj/project.pbxproj @@ -7,8 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 4C23EB431B327C5B0090E0BC /* MultipartFormData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C23EB421B327C5B0090E0BC /* MultipartFormData.swift */; }; + 4C23EB441B327C5B0090E0BC /* MultipartFormData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C23EB421B327C5B0090E0BC /* MultipartFormData.swift */; }; 4C256A531B096C770065714F /* BaseTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C256A501B096C2C0065714F /* BaseTestCase.swift */; }; 4C256A541B096C770065714F /* BaseTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C256A501B096C2C0065714F /* BaseTestCase.swift */; }; + 4C3238E71B3604DB00FE04AE /* MultipartFormDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3238E61B3604DB00FE04AE /* MultipartFormDataTests.swift */; }; + 4C3238E81B3604DB00FE04AE /* MultipartFormDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3238E61B3604DB00FE04AE /* MultipartFormDataTests.swift */; }; + 4C3238EC1B3617BB00FE04AE /* rainbow.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 4C3238EA1B3617BB00FE04AE /* rainbow.jpg */; }; + 4C3238ED1B3617BB00FE04AE /* rainbow.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 4C3238EA1B3617BB00FE04AE /* rainbow.jpg */; }; + 4C3238EE1B3617BB00FE04AE /* unicorn.png in Resources */ = {isa = PBXBuildFile; fileRef = 4C3238EB1B3617BB00FE04AE /* unicorn.png */; }; + 4C3238EF1B3617BB00FE04AE /* unicorn.png in Resources */ = {isa = PBXBuildFile; fileRef = 4C3238EB1B3617BB00FE04AE /* unicorn.png */; }; 4C341BBA1B1A865A00C1B34D /* CacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C341BB91B1A865A00C1B34D /* CacheTests.swift */; }; 4C341BBB1B1A865A00C1B34D /* CacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C341BB91B1A865A00C1B34D /* CacheTests.swift */; }; 4CCFA79A1B2BE71600B6F460 /* URLProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCFA7991B2BE71600B6F460 /* URLProtocolTests.swift */; }; @@ -70,7 +78,11 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 4C23EB421B327C5B0090E0BC /* MultipartFormData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultipartFormData.swift; sourceTree = ""; }; 4C256A501B096C2C0065714F /* BaseTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseTestCase.swift; sourceTree = ""; }; + 4C3238E61B3604DB00FE04AE /* MultipartFormDataTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultipartFormDataTests.swift; sourceTree = ""; }; + 4C3238EA1B3617BB00FE04AE /* rainbow.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; name = rainbow.jpg; path = Resources/rainbow.jpg; sourceTree = ""; }; + 4C3238EB1B3617BB00FE04AE /* unicorn.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = unicorn.png; path = Resources/unicorn.png; sourceTree = ""; }; 4C341BB91B1A865A00C1B34D /* CacheTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CacheTests.swift; sourceTree = ""; }; 4CCFA7991B2BE71600B6F460 /* URLProtocolTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLProtocolTests.swift; sourceTree = ""; }; 4CDE2C361AF8932A00BABAE5 /* Manager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Manager.swift; sourceTree = ""; }; @@ -149,6 +161,7 @@ children = ( 4C341BB91B1A865A00C1B34D /* CacheTests.swift */, F8111E5B19A9674D0040E7D1 /* DownloadTests.swift */, + 4C3238E61B3604DB00FE04AE /* MultipartFormDataTests.swift */, F86AEFE51AE6A282007D9C76 /* TLSEvaluationTests.swift */, F8111E5F19A9674D0040E7D1 /* UploadTests.swift */, 4CCFA7991B2BE71600B6F460 /* URLProtocolTests.swift */, @@ -157,6 +170,15 @@ name = Features; sourceTree = ""; }; + 4C3238E91B3617A600FE04AE /* Resources */ = { + isa = PBXGroup; + children = ( + 4C3238EA1B3617BB00FE04AE /* rainbow.jpg */, + 4C3238EB1B3617BB00FE04AE /* unicorn.png */, + ); + name = Resources; + sourceTree = ""; + }; 4CDE2C481AF8A14A00BABAE5 /* Core */ = { isa = PBXGroup; children = ( @@ -171,6 +193,7 @@ isa = PBXGroup; children = ( 4CDE2C3C1AF89D4900BABAE5 /* Download.swift */, + 4C23EB421B327C5B0090E0BC /* MultipartFormData.swift */, 4CDE2C451AF89FF300BABAE5 /* ResponseSerialization.swift */, 4CDE2C3F1AF89E0700BABAE5 /* Upload.swift */, 4CDE2C421AF89F0900BABAE5 /* Validation.swift */, @@ -224,6 +247,7 @@ 4C256A501B096C2C0065714F /* BaseTestCase.swift */, 4C256A4E1B09656A0065714F /* Core */, 4C256A4F1B09656E0065714F /* Features */, + 4C3238E91B3617A600FE04AE /* Resources */, F8111E4019A95C8B0040E7D1 /* Supporting Files */, ); path = Tests; @@ -393,6 +417,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C3238EE1B3617BB00FE04AE /* unicorn.png in Resources */, + 4C3238EC1B3617BB00FE04AE /* rainbow.jpg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -400,6 +426,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C3238EF1B3617BB00FE04AE /* unicorn.png in Resources */, + 4C3238ED1B3617BB00FE04AE /* rainbow.jpg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -416,6 +444,7 @@ 4CDE2C471AF89FF300BABAE5 /* ResponseSerialization.swift in Sources */, 4CDE2C381AF8932A00BABAE5 /* Manager.swift in Sources */, 4DD67C251A5C590000ED2280 /* Alamofire.swift in Sources */, + 4C23EB441B327C5B0090E0BC /* MultipartFormData.swift in Sources */, 4CDE2C3E1AF89D4900BABAE5 /* Download.swift in Sources */, 4CDE2C441AF89F0900BABAE5 /* Validation.swift in Sources */, ); @@ -431,6 +460,7 @@ 4CDE2C461AF89FF300BABAE5 /* ResponseSerialization.swift in Sources */, 4CDE2C371AF8932A00BABAE5 /* Manager.swift in Sources */, F897FF4119AA800700AB5182 /* Alamofire.swift in Sources */, + 4C23EB431B327C5B0090E0BC /* MultipartFormData.swift in Sources */, 4CDE2C3D1AF89D4900BABAE5 /* Download.swift in Sources */, 4CDE2C431AF89F0900BABAE5 /* Validation.swift in Sources */, ); @@ -440,6 +470,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C3238E71B3604DB00FE04AE /* MultipartFormDataTests.swift in Sources */, 4C341BBA1B1A865A00C1B34D /* CacheTests.swift in Sources */, 4CCFA79A1B2BE71600B6F460 /* URLProtocolTests.swift in Sources */, F86AEFE71AE6A312007D9C76 /* TLSEvaluationTests.swift in Sources */, @@ -459,6 +490,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C3238E81B3604DB00FE04AE /* MultipartFormDataTests.swift in Sources */, 4C341BBB1B1A865A00C1B34D /* CacheTests.swift in Sources */, 4CCFA79B1B2BE71600B6F460 /* URLProtocolTests.swift in Sources */, F829C6BE1A7A950600A2CD59 /* ParameterEncodingTests.swift in Sources */, diff --git a/README.md b/README.md index a585ba834d9e3de9de5a2cb0b07d49164eef77f4..b7d8077320ff04053394938d40ad9e3f5db30c23 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Alamofire is an HTTP networking library written in Swift. - [x] Chainable Request / Response methods - [x] URL / JSON / plist Parameter Encoding -- [x] Upload File / Data / Stream +- [x] Upload File / Data / Stream / MultipartFormData - [x] Download using Request or Resume data - [x] Authentication with NSURLCredential - [x] HTTP Response Validation @@ -298,6 +298,7 @@ Caching is handled on the system framework level by [`NSURLCache`](https://devel - File - Data - Stream +- MultipartFormData #### Uploading a File @@ -309,7 +310,7 @@ let fileURL = NSBundle.mainBundle() Alamofire.upload(.POST, "http://httpbin.org/post", file: fileURL) ``` -#### Uploading w/Progress +#### Uploading with Progress ```swift Alamofire.upload(.POST, "http://httpbin.org/post", file: fileURL) @@ -321,6 +322,29 @@ Alamofire.upload(.POST, "http://httpbin.org/post", file: fileURL) } ``` +#### Uploading MultipartFormData + +```swift +Alamofire.upload( + .POST, + URLString: "http://httpbin.org/post", + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + multipartFormData.appendBodyPart(fileURL: rainbowImageURL, name: "rainbow") + }, + encodingCompletion: { encodingResult in + switch encodingResult { + case .Success(let upload, _, _): + upload.responseJSON { request, response, JSON, error in + println(JSON) + } + case .Failure(let encodingError): + println(encodingError) + } + } +) +``` + ### Downloading **Supported Download Types** @@ -850,7 +874,6 @@ Use AFNetworking for any of the following: - TLS verification, using `AFSecurityManager` - Situations requiring `NSOperation` or `NSURLConnection`, using `AFURLConnectionOperation` - Network reachability monitoring, using `AFNetworkReachabilityManager` -- Multipart HTTP request construction, using `AFHTTPRequestSerializer` ### What's the origin of the name Alamofire? diff --git a/Source/Alamofire.swift b/Source/Alamofire.swift index 6ee1ddc19c1a21614fbbd1f69c03c37193c0bcf2..b03c214577f7893a6d723bb2407ed5e6c791733b 100644 --- a/Source/Alamofire.swift +++ b/Source/Alamofire.swift @@ -24,6 +24,8 @@ import Foundation /// Alamofire errors public let AlamofireErrorDomain = "com.alamofire.error" +public let AlamofireInputStreamReadFailed = -6000 +public let AlamofireOutputStreamWriteFailed = -6001 // MARK: - URLStringConvertible @@ -204,6 +206,57 @@ public func upload(URLRequest: URLRequestConvertible, stream: NSInputStream) -> return Manager.sharedInstance.upload(URLRequest, stream: stream) } +// MARK: MultipartFormData + +/** + Creates an upload request using the shared manager instance for the specified method and URL string. + + :param: method The HTTP method. + :param: URLString The URL string. + :param: multipartFormData The closure used to append body parts to the `MultipartFormData`. + :param: encodingMemoryThreshold The encoding memory threshold in bytes. `MultipartFormDataEncodingMemoryThreshold` + by default. + :param: encodingCompletion The closure called when the `MultipartFormData` encoding is complete. +*/ +public func upload( + method: Method, + #URLString: URLStringConvertible, + #multipartFormData: MultipartFormData -> Void, + encodingMemoryThreshold: UInt64 = Manager.MultipartFormDataEncodingMemoryThreshold, + #encodingCompletion: (Manager.MultipartFormDataEncodingResult -> Void)?) +{ + return Manager.sharedInstance.upload( + method, + URLString, + multipartFormData: multipartFormData, + encodingMemoryThreshold: encodingMemoryThreshold, + encodingCompletion: encodingCompletion + ) +} + +/** + Creates an upload request using the shared manager instance for the specified method and URL string. + + :param: URLRequest The URL request. + :param: multipartFormData The closure used to append body parts to the `MultipartFormData`. + :param: encodingMemoryThreshold The encoding memory threshold in bytes. `MultipartFormDataEncodingMemoryThreshold` + by default. + :param: encodingCompletion The closure called when the `MultipartFormData` encoding is complete. +*/ +public func upload( + URLRequest: URLRequestConvertible, + #multipartFormData: MultipartFormData -> Void, + encodingMemoryThreshold: UInt64 = Manager.MultipartFormDataEncodingMemoryThreshold, + #encodingCompletion: (Manager.MultipartFormDataEncodingResult -> Void)?) +{ + return Manager.sharedInstance.upload( + URLRequest, + multipartFormData: multipartFormData, + encodingMemoryThreshold: encodingMemoryThreshold, + encodingCompletion: encodingCompletion + ) +} + // MARK: - Download Methods // MARK: URL Request diff --git a/Source/MultipartFormData.swift b/Source/MultipartFormData.swift new file mode 100644 index 0000000000000000000000000000000000000000..82c016bb4c06b68caaeb47adeeec4cd63a5ba814 --- /dev/null +++ b/Source/MultipartFormData.swift @@ -0,0 +1,628 @@ +// MultipartFormData.swift +// +// Copyright (c) 2014–2015 Alamofire Software Foundation (http://alamofire.org/) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation + +#if os(iOS) +import MobileCoreServices +#elseif os(OSX) +import CoreServices +#endif + +/** + Constructs `multipart/form-data` for uploads within an HTTP or HTTPS body. There are currently two ways to encode + multipart form data. The first way is to encode the data directly in memory. This is very efficient, but can lead + to memory issues if the dataset is too large. The second way is designed for larger datasets and will write all the + data to a single file on disk with all the proper boundary segmentation. The second approach MUST be used for + larger datasets such as video content, otherwise your app may run out of memory when trying to encode the dataset. + + For more information on `multipart/form-data` in general, please refer to the RFC-2388 and RFC-2045 specs as well + and the w3 form documentation. + + - http://www.ietf.org/rfc/rfc2388.txt + - http://www.ietf.org/rfc/rfc2045.txt + - http://www.w3.org/TR/html401/interact/forms.html#h-17.13 +*/ +public class MultipartFormData { + + // MARK: - Helper Types + + /** + Used to specify whether encoding was successful. + */ + public enum EncodingResult { + case Success(NSData) + case Failure(NSError) + } + + struct EncodingCharacters { + static let CRLF = "\r\n" + } + + struct BoundaryGenerator { + enum BoundaryType { + case Initial, Encapsulated, Final + } + + static func randomBoundary() -> String { + return String(format: "alamofire.boundary.%08x%08x", arc4random(), arc4random()) + } + + static func boundaryData(#boundaryType: BoundaryType, boundary: String) -> NSData { + let boundaryText: String + + switch boundaryType { + case .Initial: + boundaryText = "--\(boundary)\(EncodingCharacters.CRLF)" + case .Encapsulated: + boundaryText = "\(EncodingCharacters.CRLF)--\(boundary)\(EncodingCharacters.CRLF)" + case .Final: + boundaryText = "\(EncodingCharacters.CRLF)--\(boundary)--\(EncodingCharacters.CRLF)" + } + + return boundaryText.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + } + } + + class BodyPart { + let headers: [String: String] + let bodyStream: NSInputStream + let bodyContentLength: UInt64 + var hasInitialBoundary = false + var hasFinalBoundary = false + + init(headers: [String: String], bodyStream: NSInputStream, bodyContentLength: UInt64) { + self.headers = headers + self.bodyStream = bodyStream + self.bodyContentLength = bodyContentLength + } + } + + // MARK: - Properties + + /// The `Content-Type` header value containing the boundary used to generate the `multipart/form-data`. + public var contentType: String { return "multipart/form-data; boundary=\(self.boundary)" } + + /// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries. + public var contentLength: UInt64 { return self.bodyParts.reduce(0) { $0 + $1.bodyContentLength } } + + /// The boundary used to separate the body parts in the encoded form data. + public let boundary: String + + private var bodyParts: [BodyPart] + private let streamBufferSize: Int + + // MARK: - Lifecycle + + /** + Creates a multipart form data object. + + :returns: The multipart form data object. + */ + public init() { + self.boundary = BoundaryGenerator.randomBoundary() + self.bodyParts = [] + + /** + * The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more + * information, please refer to the following article: + * - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html + */ + + self.streamBufferSize = 1024 + } + + // MARK: - Body Parts + + /** + Creates a body part from the file and appends it to the multipart form data object. + + The body part data will be encoded using the following format: + + - `Content-Disposition: form-data; name=#{name}; filename=#{generated filename}` (HTTP Header) + - `Content-Type: #{generated mimeType}` (HTTP Header) + - Encoded file data + - Multipart form boundary + + The filename in the `Content-Disposition` HTTP header is generated from the last path component of the + `fileURL`. The `Content-Type` HTTP header MIME type is generated by mapping the `fileURL` extension to the + system associated MIME type. + + :param: URL The URL of the file whose content will be encoded into the multipart form data. + :param: name The name to associate with the file content in the `Content-Disposition` HTTP header. + + :returns: An `NSError` if an error occurred, `nil` otherwise. + */ + public func appendBodyPart(fileURL URL: NSURL, name: String) -> NSError? { + if let + fileName = URL.lastPathComponent, + pathExtension = URL.pathExtension + { + let mimeType = mimeTypeForPathExtension(pathExtension) + return appendBodyPart(fileURL: URL, name: name, fileName: fileName, mimeType: mimeType) + } + + let failureReason = "Failed to extract the fileName of the provided URL: \(URL)" + let userInfo = [NSLocalizedFailureReasonErrorKey: failureReason] + + return NSError(domain: AlamofireErrorDomain, code: NSURLErrorBadURL, userInfo: userInfo) + } + + /** + Creates a body part from the file and appends it to the multipart form data object. + + The body part data will be encoded using the following format: + + - Content-Disposition: form-data; name=#{name}; filename=#{filename} (HTTP Header) + - Content-Type: #{mimeType} (HTTP Header) + - Encoded file data + - Multipart form boundary + + :param: URL The URL of the file whose content will be encoded into the multipart form data. + :param: name The name to associate with the file content in the `Content-Disposition` HTTP header. + :param: fileName The filename to associate with the file content in the `Content-Disposition` HTTP header. + :param: mimeType The MIME type to associate with the file content in the `Content-Type` HTTP header. + + :returns: An `NSError` if an error occurred, `nil` otherwise. + */ + public func appendBodyPart(fileURL URL: NSURL, name: String, fileName: String, mimeType: String) -> NSError? { + let headers = contentHeaders(name: name, fileName: fileName, mimeType: mimeType) + var isDirectory: ObjCBool = false + + if !URL.fileURL { + return errorWithCode(NSURLErrorBadURL, failureReason: "The URL does not point to a file URL: \(URL)") + } else if !URL.checkResourceIsReachableAndReturnError(nil) { + return errorWithCode(NSURLErrorBadURL, failureReason: "The URL is not reachable: \(URL)") + } else if NSFileManager.defaultManager().fileExistsAtPath(URL.path!, isDirectory: &isDirectory) && isDirectory { + return errorWithCode(NSURLErrorBadURL, failureReason: "The URL is a directory, not a file: \(URL)") + } + + let bodyContentLength: UInt64 + var fileAttributesError: NSError? + + if let + path = URL.path, + attributes = NSFileManager.defaultManager().attributesOfItemAtPath(path, error: &fileAttributesError), + fileSize = (attributes[NSFileSize] as? NSNumber)?.unsignedLongLongValue + { + bodyContentLength = fileSize + } else { + return errorWithCode(NSURLErrorBadURL, failureReason: "Could not fetch attributes from the URL: \(URL)") + } + + if let bodyStream = NSInputStream(URL: URL) { + let bodyPart = BodyPart(headers: headers, bodyStream: bodyStream, bodyContentLength: bodyContentLength) + self.bodyParts.append(bodyPart) + } else { + let failureReason = "Failed to create an input stream from the URL: \(URL)" + return errorWithCode(NSURLErrorCannotOpenFile, failureReason: failureReason) + } + + return nil + } + + /** + Creates a body part from the data and appends it to the multipart form data object. + + The body part data will be encoded using the following format: + + - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTP Header) + - `Content-Type: #{mimeType}` (HTTP Header) + - Encoded file data + - Multipart form boundary + + :param: data The data to encode into the multipart form data. + :param: name The name to associate with the data in the `Content-Disposition` HTTP header. + :param: fileName The filename to associate with the data in the `Content-Disposition` HTTP header. + :param: mimeType The MIME type to associate with the data in the `Content-Type` HTTP header. + */ + public func appendBodyPart(fileData data: NSData, name: String, fileName: String, mimeType: String) { + let headers = contentHeaders(name: name, fileName: fileName, mimeType: mimeType) + let bodyStream = NSInputStream(data: data) + let bodyContentLength = UInt64(data.length) + let bodyPart = BodyPart(headers: headers, bodyStream: bodyStream, bodyContentLength: bodyContentLength) + + self.bodyParts.append(bodyPart) + } + + /** + Creates a body part from the data and appends it to the multipart form data object. + + The body part data will be encoded using the following format: + + - `Content-Disposition: form-data; name=#{name}` (HTTP Header) + - Encoded file data + - Multipart form boundary + + :param: data The data to encode into the multipart form data. + :param: name The name to associate with the data in the `Content-Disposition` HTTP header. + */ + public func appendBodyPart(#data: NSData, name: String) { + let headers = contentHeaders(name: name) + let bodyStream = NSInputStream(data: data) + let bodyContentLength = UInt64(data.length) + let bodyPart = BodyPart(headers: headers, bodyStream: bodyStream, bodyContentLength: bodyContentLength) + + self.bodyParts.append(bodyPart) + } + + /** + Creates a body part from the stream and appends it to the multipart form data object. + + The body part data will be encoded using the following format: + + - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTP Header) + - `Content-Type: #{mimeType}` (HTTP Header) + - Encoded file data + - Multipart form boundary + + :param: stream The input stream to encode in the multipart form data. + :param: name The name to associate with the stream content in the `Content-Disposition` HTTP header. + :param: fileName The filename to associate with the stream content in the `Content-Disposition` HTTP header. + :param: mimeType The MIME type to associate with the stream content in the `Content-Type` HTTP header. + */ + public func appendBodyPart(#stream: NSInputStream, name: String, fileName: String, length: UInt64, mimeType: String) { + let headers = contentHeaders(name: name, fileName: fileName, mimeType: mimeType) + let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length) + + self.bodyParts.append(bodyPart) + } + + // MARK: - Data Extraction + + /** + Encodes all the appended body parts into a single `NSData` object. + + It is important to note that this method will load all the appended body parts into memory all at the same + time. This method should only be used when the encoded data will have a small memory footprint. For large data + cases, please use the `writeEncodedDataToDisk(fileURL:completionHandler:)` method. + + :returns: EncodingResult containing an `NSData` object if the encoding succeeded, an `NSError` otherwise. + */ + public func encode() -> EncodingResult { + var encoded = NSMutableData() + + self.bodyParts.first?.hasInitialBoundary = true + self.bodyParts.last?.hasFinalBoundary = true + + for bodyPart in self.bodyParts { + let encodedDataResult = encodeBodyPart(bodyPart) + + switch encodedDataResult { + case .Failure: + return encodedDataResult + case let .Success(data): + encoded.appendData(data) + } + } + + return .Success(encoded) + } + + /** + Writes the appended body parts into the given file URL asynchronously and calls the `completionHandler` + when finished. + + This process is facilitated by reading and writing with input and output streams, respectively. Thus, + this approach is very memory efficient and should be used for large body part data. + + :param: fileURL The file URL to write the multipart form data into. + :param: completionHandler A closure to be executed when writing is finished. + */ + public func writeEncodedDataToDisk(fileURL: NSURL, completionHandler: (NSError?) -> Void) { + var error: NSError? + + if let path = fileURL.path where NSFileManager.defaultManager().fileExistsAtPath(path) { + let failureReason = "A file already exists at the given file URL: \(fileURL)" + error = errorWithCode(NSURLErrorBadURL, failureReason: failureReason) + } else if !fileURL.fileURL { + let failureReason = "The URL does not point to a valid file: \(fileURL)" + error = errorWithCode(NSURLErrorBadURL, failureReason: failureReason) + } + + if let error = error { + completionHandler(error) + return + } + + let outputStream: NSOutputStream + + if let possibleOutputStream = NSOutputStream(URL: fileURL, append: false) { + outputStream = possibleOutputStream + } else { + let failureReason = "Failed to create an output stream with the given URL: \(fileURL)" + let error = errorWithCode(NSURLErrorCannotOpenFile, failureReason: failureReason) + + completionHandler(error) + return + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { + outputStream.scheduleInRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode) + outputStream.open() + + self.bodyParts.first?.hasInitialBoundary = true + self.bodyParts.last?.hasFinalBoundary = true + + var error: NSError? + + for bodyPart in self.bodyParts { + if let writeError = self.writeBodyPart(bodyPart, toOutputStream: outputStream) { + error = writeError + break + } + } + + outputStream.close() + outputStream.removeFromRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode) + + dispatch_async(dispatch_get_main_queue()) { + completionHandler(error) + } + } + } + + // MARK: - Private - Body Part Encoding + + private func encodeBodyPart(bodyPart: BodyPart) -> EncodingResult { + let encoded = NSMutableData() + + let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData() + encoded.appendData(initialData) + + let headerData = encodeHeaderDataForBodyPart(bodyPart) + encoded.appendData(headerData) + + let bodyStreamResult = encodeBodyStreamDataForBodyPart(bodyPart) + + switch bodyStreamResult { + case .Failure: + return bodyStreamResult + case let .Success(data): + encoded.appendData(data) + } + + if bodyPart.hasFinalBoundary { + encoded.appendData(finalBoundaryData()) + } + + return .Success(encoded) + } + + private func encodeHeaderDataForBodyPart(bodyPart: BodyPart) -> NSData { + var headerText = "" + + for (key, value) in bodyPart.headers { + headerText += "\(key): \(value)\(EncodingCharacters.CRLF)" + } + headerText += EncodingCharacters.CRLF + + return headerText.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + } + + private func encodeBodyStreamDataForBodyPart(bodyPart: BodyPart) -> EncodingResult { + let inputStream = bodyPart.bodyStream + inputStream.scheduleInRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode) + inputStream.open() + + var error: NSError? + let encoded = NSMutableData() + + while inputStream.hasBytesAvailable { + var buffer = [UInt8](count: self.streamBufferSize, repeatedValue: 0) + let bytesRead = inputStream.read(&buffer, maxLength: self.streamBufferSize) + + if inputStream.streamError != nil { + error = inputStream.streamError + break + } + + if bytesRead > 0 { + encoded.appendBytes(buffer, length: bytesRead) + } else if bytesRead < 0 { + let failureReason = "Failed to read from input stream: \(inputStream)" + error = errorWithCode(AlamofireInputStreamReadFailed, failureReason: failureReason) + break + } else { + break + } + } + + inputStream.close() + inputStream.removeFromRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode) + + if let error = error { + return .Failure(error) + } + + return .Success(encoded) + } + + // MARK: - Private - Writing Body Part to Output Stream + + private func writeBodyPart(bodyPart: BodyPart, toOutputStream outputStream: NSOutputStream) -> NSError? { + if let error = writeInitialBoundaryDataForBodyPart(bodyPart, toOutputStream: outputStream) { + return error + } + + if let error = writeHeaderDataForBodyPart(bodyPart, toOutputStream: outputStream) { + return error + } + + if let error = writeBodyStreamForBodyPart(bodyPart, toOutputStream: outputStream) { + return error + } + + if let error = writeFinalBoundaryDataForBodyPart(bodyPart, toOutputStream: outputStream) { + return error + } + + return nil + } + + private func writeInitialBoundaryDataForBodyPart(bodyPart: BodyPart, toOutputStream outputStream: NSOutputStream) -> NSError? { + let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData() + return writeData(initialData, toOutputStream: outputStream) + } + + private func writeHeaderDataForBodyPart(bodyPart: BodyPart, toOutputStream outputStream: NSOutputStream) -> NSError? { + let headerData = encodeHeaderDataForBodyPart(bodyPart) + return writeData(headerData, toOutputStream: outputStream) + } + + private func writeBodyStreamForBodyPart(bodyPart: BodyPart, toOutputStream outputStream: NSOutputStream) -> NSError? { + var error: NSError? + + let inputStream = bodyPart.bodyStream + inputStream.scheduleInRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode) + inputStream.open() + + while inputStream.hasBytesAvailable { + var buffer = [UInt8](count: self.streamBufferSize, repeatedValue: 0) + let bytesRead = inputStream.read(&buffer, maxLength: self.streamBufferSize) + + if inputStream.streamError != nil { + error = inputStream.streamError + break + } + + if bytesRead > 0 { + if buffer.count != bytesRead { + buffer = Array(buffer[0.. NSError? { + if bodyPart.hasFinalBoundary { + return writeData(finalBoundaryData(), toOutputStream: outputStream) + } + + return nil + } + + // MARK: - Private - Writing Buffered Data to Output Stream + + private func writeData(data: NSData, toOutputStream outputStream: NSOutputStream) -> NSError? { + var buffer = [UInt8](count: data.length, repeatedValue: 0) + data.getBytes(&buffer, length: data.length) + + return writeBuffer(&buffer, toOutputStream: outputStream) + } + + private func writeBuffer(inout buffer: [UInt8], toOutputStream outputStream: NSOutputStream) -> NSError? { + var error: NSError? + + var bytesToWrite = buffer.count + + while bytesToWrite > 0 { + if outputStream.hasSpaceAvailable { + let bytesWritten = outputStream.write(buffer, maxLength: bytesToWrite) + + if outputStream.streamError != nil { + error = outputStream.streamError + break + } + + if bytesWritten < 0 { + let failureReason = "Failed to write to output stream: \(outputStream)" + error = errorWithCode(AlamofireOutputStreamWriteFailed, failureReason: failureReason) + break + } + + bytesToWrite -= bytesWritten + + if bytesToWrite > 0 { + buffer = Array(buffer[bytesWritten.. String { + let identifier = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension, nil).takeRetainedValue() + + if let contentType = UTTypeCopyPreferredTagWithClass(identifier, kUTTagClassMIMEType) { + return contentType.takeRetainedValue() as String + } + + return "application/octet-stream" + } + + // MARK: - Private - Content Headers + + private func contentHeaders(#name: String) -> [String: String] { + return ["Content-Disposition": "form-data; name=\"\(name)\""] + } + + private func contentHeaders(#name: String, fileName: String, mimeType: String) -> [String: String] { + return [ + "Content-Disposition": "form-data; name=\"\(name)\"; filename=\"\(fileName)\"", + "Content-Type": "\(mimeType)" + ] + } + + // MARK: - Private - Boundary Encoding + + private func initialBoundaryData() -> NSData { + return BoundaryGenerator.boundaryData(boundaryType: .Initial, boundary: self.boundary) + } + + private func encapsulatedBoundaryData() -> NSData { + return BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundary: self.boundary) + } + + private func finalBoundaryData() -> NSData { + return BoundaryGenerator.boundaryData(boundaryType: .Final, boundary: self.boundary) + } + + // MARK: - Private - Errors + + private func errorWithCode(code: Int, failureReason: String) -> NSError { + let userInfo = [NSLocalizedFailureReasonErrorKey: failureReason] + return NSError(domain: AlamofireErrorDomain, code: code, userInfo: userInfo) + } +} diff --git a/Source/Upload.swift b/Source/Upload.swift index 4d4bf7387c11aa1a5cf374ef60ab9a1c59fe7f86..313dc50342d607bbbe98bbcdbc77054fcbfa452d 100644 --- a/Source/Upload.swift +++ b/Source/Upload.swift @@ -156,6 +156,153 @@ extension Manager { public func upload(method: Method, _ URLString: URLStringConvertible, stream: NSInputStream) -> Request { return upload(URLRequest(method, URLString), stream: stream) } + + // MARK: MultipartFormData + + /// Default memory threshold used when encoding `MultipartFormData`. + public static let MultipartFormDataEncodingMemoryThreshold: UInt64 = 10 * 1024 * 1024 + + /** + Defines whether the `MultipartFormData` encoding was successful and contains result of the encoding as + associated values. + + - Success: Represents a successful `MultipartFormData` encoding and contains the new `Request` along with + streaming information. + - Failure: Used to represent a failure in the `MultipartFormData` encoding and also contains the encoding + error. + */ + public enum MultipartFormDataEncodingResult { + case Success(request: Request, streamingFromDisk: Bool, streamFileURL: NSURL?) + case Failure(NSError) + } + + /** + Encodes the `MultipartFormData` and creates a request to upload the result to the specified URL request. + + It is important to understand the memory implications of uploading `MultipartFormData`. If the cummulative + payload is small, encoding the data in-memory and directly uploading to a server is the by far the most + efficient approach. However, if the payload is too large, encoding the data in-memory could cause your app to + be terminated. Larger payloads must first be written to disk using input and output streams to keep the memory + footprint low, then the data can be uploaded as a stream from the resulting file. Streaming from disk MUST be + used for larger payloads such as video content. + + The `encodingMemoryThreshold` parameter allows Alamofire to automatically determine whether to encode in-memory + or stream from disk. If the content length of the `MultipartFormData` is below the `encodingMemoryThreshold`, + encoding takes place in-memory. If the content length exceeds the threshold, the data is streamed to disk + during the encoding process. Then the result is uploaded as data or as a stream depending on which encoding + technique was used. + + If `startRequestsImmediately` is `true`, the request will have `resume()` called before being returned. + + :param: method The HTTP method. + :param: URLString The URL string. + :param: multipartFormData The closure used to append body parts to the `MultipartFormData`. + :param: encodingMemoryThreshold The encoding memory threshold in bytes. `MultipartFormDataEncodingMemoryThreshold` + by default. + :param: encodingCompletion The closure called when the `MultipartFormData` encoding is complete. + */ + public func upload( + method: Method, + _ URLString: URLStringConvertible, + multipartFormData: MultipartFormData -> Void, + encodingMemoryThreshold: UInt64 = Manager.MultipartFormDataEncodingMemoryThreshold, + encodingCompletion: (MultipartFormDataEncodingResult -> Void)?) + { + let urlRequest = URLRequest(method, URLString) + + return upload( + urlRequest, + multipartFormData: multipartFormData, + encodingMemoryThreshold: encodingMemoryThreshold, + encodingCompletion: encodingCompletion + ) + } + + /** + Encodes the `MultipartFormData` and creates a request to upload the result to the specified URL request. + + It is important to understand the memory implications of uploading `MultipartFormData`. If the cummulative + payload is small, encoding the data in-memory and directly uploading to a server is the by far the most + efficient approach. However, if the payload is too large, encoding the data in-memory could cause your app to + be terminated. Larger payloads must first be written to disk using input and output streams to keep the memory + footprint low, then the data can be uploaded as a stream from the resulting file. Streaming from disk MUST be + used for larger payloads such as video content. + + The `encodingMemoryThreshold` parameter allows Alamofire to automatically determine whether to encode in-memory + or stream from disk. If the content length of the `MultipartFormData` is below the `encodingMemoryThreshold`, + encoding takes place in-memory. If the content length exceeds the threshold, the data is streamed to disk + during the encoding process. Then the result is uploaded as data or as a stream depending on which encoding + technique was used. + + If `startRequestsImmediately` is `true`, the request will have `resume()` called before being returned. + + :param: URLRequest The URL request. + :param: multipartFormData The closure used to append body parts to the `MultipartFormData`. + :param: encodingMemoryThreshold The encoding memory threshold in bytes. `MultipartFormDataEncodingMemoryThreshold` + by default. + :param: encodingCompletion The closure called when the `MultipartFormData` encoding is complete. + */ + public func upload( + URLRequest: URLRequestConvertible, + multipartFormData: MultipartFormData -> Void, + encodingMemoryThreshold: UInt64 = Manager.MultipartFormDataEncodingMemoryThreshold, + encodingCompletion: (MultipartFormDataEncodingResult -> Void)?) + { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { + let formData = MultipartFormData() + multipartFormData(formData) + + let URLRequestWithContentType = URLRequest.URLRequest.mutableCopy() as! NSMutableURLRequest + URLRequestWithContentType.setValue(formData.contentType, forHTTPHeaderField: "Content-Type") + + if formData.contentLength < encodingMemoryThreshold { + let encodingResult = formData.encode() + + dispatch_async(dispatch_get_main_queue()) { + switch encodingResult { + case .Success(let data): + let encodingResult = MultipartFormDataEncodingResult.Success( + request: self.upload(URLRequestWithContentType, data: data), + streamingFromDisk: false, + streamFileURL: nil + ) + encodingCompletion?(encodingResult) + case .Failure(let error): + encodingCompletion?(.Failure(error)) + } + } + } else { + let fileManager = NSFileManager.defaultManager() + let tempDirectoryURL = NSURL(fileURLWithPath: NSTemporaryDirectory())! + let directoryURL = tempDirectoryURL.URLByAppendingPathComponent("com.alamofire.manager/multipart.form.data") + let fileName = NSUUID().UUIDString + let fileURL = directoryURL.URLByAppendingPathComponent(fileName) + + var error: NSError? + + if fileManager.createDirectoryAtURL(directoryURL, withIntermediateDirectories: true, attributes: nil, error: &error) { + formData.writeEncodedDataToDisk(fileURL) { error in + dispatch_async(dispatch_get_main_queue()) { + if let error = error { + encodingCompletion?(.Failure(error)) + } else { + let encodingResult = MultipartFormDataEncodingResult.Success( + request: self.upload(URLRequestWithContentType, file: fileURL), + streamingFromDisk: true, + streamFileURL: fileURL + ) + encodingCompletion?(encodingResult) + } + } + } + } else { + dispatch_async(dispatch_get_main_queue()) { + encodingCompletion?(.Failure(error!)) + } + } + } + } + } } // MARK: - diff --git a/Tests/BaseTestCase.swift b/Tests/BaseTestCase.swift index 14fb96225e6de2c3219bea680606bf3d44e036bf..b7bab398058cacc419ef17264085bd3a156b68c4 100644 --- a/Tests/BaseTestCase.swift +++ b/Tests/BaseTestCase.swift @@ -26,4 +26,9 @@ import XCTest class BaseTestCase: XCTestCase { let defaultTimeout: NSTimeInterval = 10 + + func URLForResource(fileName: String, withExtension: String) -> NSURL { + let bundle = NSBundle(forClass: BaseTestCase.self) + return bundle.URLForResource(fileName, withExtension: withExtension)! + } } diff --git a/Tests/MultipartFormDataTests.swift b/Tests/MultipartFormDataTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..3da2ec79f477fb9f96022fe5f1d4116cc743d33a --- /dev/null +++ b/Tests/MultipartFormDataTests.swift @@ -0,0 +1,925 @@ +// MultipartFormDataTests.swift +// +// Copyright (c) 2014–2015 Alamofire Software Foundation (http://alamofire.org/) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Alamofire +import Foundation +import XCTest + +struct EncodingCharacters { + static let CRLF = "\r\n" +} + +struct BoundaryGenerator { + enum BoundaryType { + case Initial, Encapsulated, Final + } + + static func boundary(#boundaryType: BoundaryType, boundaryKey: String) -> String { + let boundary: String + + switch boundaryType { + case .Initial: + boundary = "--\(boundaryKey)\(EncodingCharacters.CRLF)" + case .Encapsulated: + boundary = "\(EncodingCharacters.CRLF)--\(boundaryKey)\(EncodingCharacters.CRLF)" + case .Final: + boundary = "\(EncodingCharacters.CRLF)--\(boundaryKey)--\(EncodingCharacters.CRLF)" + } + + return boundary + } + + static func boundaryData(#boundaryType: BoundaryType, boundaryKey: String) -> NSData { + return BoundaryGenerator.boundary( + boundaryType: boundaryType, + boundaryKey: boundaryKey + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + } +} + +private func temporaryFileURL() -> NSURL { + let tempDirectoryURL = NSURL(fileURLWithPath: NSTemporaryDirectory())! + let directoryURL = tempDirectoryURL.URLByAppendingPathComponent("com.alamofire.test/multipart.form.data") + + let fileManager = NSFileManager.defaultManager() + fileManager.createDirectoryAtURL(directoryURL, withIntermediateDirectories: true, attributes: nil, error: nil) + + let fileName = NSUUID().UUIDString + let fileURL = directoryURL.URLByAppendingPathComponent(fileName) + + return fileURL +} + +// MARK: - + +class MultipartFormDataPropertiesTestCase: BaseTestCase { + func testThatContentTypeContainsBoundary() { + // Given + let multipartFormData = MultipartFormData() + + // When + let boundary = multipartFormData.boundary + + // Then + let expectedContentType = "multipart/form-data; boundary=\(boundary)" + XCTAssertEqual(multipartFormData.contentType, expectedContentType, "contentType should match expected value") + } + + func testThatContentLengthMatchesTotalBodyPartSize() { + // Given + let multipartFormData = MultipartFormData() + let data1 = "Lorem ipsum dolor sit amet.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let data2 = "Vim at integre alterum.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + // When + multipartFormData.appendBodyPart(data: data1, name: "data1") + multipartFormData.appendBodyPart(data: data2, name: "data2") + + // Then + let expectedContentLength = UInt64(data1.length + data2.length) + XCTAssertEqual(multipartFormData.contentLength, expectedContentLength, "content length should match expected value") + } +} + +// MARK: - + +class MultipartFormDataEncodingTestCase: BaseTestCase { + let CRLF = EncodingCharacters.CRLF + + func testEncodingDataBodyPart() { + // Given + let multipartFormData = MultipartFormData() + let data = "Lorem ipsum dolor sit amet.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + // When + multipartFormData.appendBodyPart(data: data, name: "data") + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = ( + BoundaryGenerator.boundary(boundaryType: .Initial, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"data\"\(self.CRLF)\(self.CRLF)" + + "Lorem ipsum dolor sit amet." + + BoundaryGenerator.boundary(boundaryType: .Final, boundaryKey: boundary) + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } + + func testEncodingMultipleDataBodyParts() { + // Given + let multipartFormData = MultipartFormData() + let french = "français".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let japanese = "日本語".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let emoji = "😃👍🏻🍻🎉".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + // When + multipartFormData.appendBodyPart(data: french, name: "french") + multipartFormData.appendBodyPart(data: japanese, name: "japanese") + multipartFormData.appendBodyPart(data: emoji, name: "emoji") + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = ( + BoundaryGenerator.boundary(boundaryType: .Initial, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"french\"\(self.CRLF)\(self.CRLF)" + + "français" + + BoundaryGenerator.boundary(boundaryType: .Encapsulated, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"japanese\"\(self.CRLF)\(self.CRLF)" + + "日本語" + + BoundaryGenerator.boundary(boundaryType: .Encapsulated, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"emoji\"\(self.CRLF)\(self.CRLF)" + + "😃👍🏻🍻🎉" + + BoundaryGenerator.boundary(boundaryType: .Final, boundaryKey: boundary) + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } + + func testEncodingFileBodyPart() { + // Given + let multipartFormData = MultipartFormData() + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + + // When + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = NSMutableData() + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } + + func testEncodingMultipleFileBodyParts() { + // Given + let multipartFormData = MultipartFormData() + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + let rainbowImageURL = URLForResource("rainbow", withExtension: "jpg") + + // When + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + multipartFormData.appendBodyPart(fileURL: rainbowImageURL, name: "rainbow") + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = NSMutableData() + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"rainbow\"; filename=\"rainbow.jpg\"\(self.CRLF)" + + "Content-Type: image/jpeg\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: rainbowImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } + + func testEncodingStreamBodyPart() { + // Given + let multipartFormData = MultipartFormData() + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + let unicornDataLength = UInt64(NSData(contentsOfURL: unicornImageURL)!.length) + let unicornStream = NSInputStream(URL: unicornImageURL)! + + // When + multipartFormData.appendBodyPart( + stream: unicornStream, + name: "unicorn", + fileName: "unicorn.png", + length: unicornDataLength, + mimeType: "image/png" + ) + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = NSMutableData() + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } + + func testEncodingMultipleStreamBodyParts() { + // Given + let multipartFormData = MultipartFormData() + + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + let unicornDataLength = UInt64(NSData(contentsOfURL: unicornImageURL)!.length) + let unicornStream = NSInputStream(URL: unicornImageURL)! + + let rainbowImageURL = URLForResource("rainbow", withExtension: "jpg") + let rainbowDataLength = UInt64(NSData(contentsOfURL: rainbowImageURL)!.length) + let rainbowStream = NSInputStream(URL: rainbowImageURL)! + + // When + multipartFormData.appendBodyPart( + stream: unicornStream, + name: "unicorn", + fileName: "unicorn.png", + length: unicornDataLength, + mimeType: "image/png" + ) + multipartFormData.appendBodyPart( + stream: rainbowStream, + name: "rainbow", + fileName: "rainbow.jpg", + length: rainbowDataLength, + mimeType: "image/jpeg" + ) + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = NSMutableData() + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"rainbow\"; filename=\"rainbow.jpg\"\(self.CRLF)" + + "Content-Type: image/jpeg\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: rainbowImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } + + func testEncodingMultipleBodyPartsWithVaryingTypes() { + // Given + let multipartFormData = MultipartFormData() + + let loremData = "Lorem ipsum.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + + let rainbowImageURL = URLForResource("rainbow", withExtension: "jpg") + let rainbowDataLength = UInt64(NSData(contentsOfURL: rainbowImageURL)!.length) + let rainbowStream = NSInputStream(URL: rainbowImageURL)! + + // When + multipartFormData.appendBodyPart(data: loremData, name: "lorem") + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + multipartFormData.appendBodyPart( + stream: rainbowStream, + name: "rainbow", + fileName: "rainbow.jpg", + length: rainbowDataLength, + mimeType: "image/jpeg" + ) + let encodingResult = multipartFormData.encode() + + // Then + switch encodingResult { + case .Success(let data): + let boundary = multipartFormData.boundary + + let expectedData = NSMutableData() + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"lorem\"\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(loremData) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedData.appendData(( + "Content-Disposition: form-data; name=\"rainbow\"; filename=\"rainbow.jpg\"\(self.CRLF)" + + "Content-Type: image/jpeg\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedData.appendData(NSData(contentsOfURL: rainbowImageURL)!) + expectedData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(data, expectedData, "data should match expected data") + case .Failure: + XCTFail("encoding result should not be .Failure") + } + } +} + +// MARK: - + +class MultipartFormDataWriteEncodedDataToDiskTestCase: BaseTestCase { + let CRLF = EncodingCharacters.CRLF + + func testWritingEncodedDataBodyPartToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + let multipartFormData = MultipartFormData() + let data = "Lorem ipsum dolor sit amet.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(data: data, name: "data") + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = ( + BoundaryGenerator.boundary(boundaryType: .Initial, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"data\"\(self.CRLF)\(self.CRLF)" + + "Lorem ipsum dolor sit amet." + + BoundaryGenerator.boundary(boundaryType: .Final, boundaryKey: boundary) + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } + + func testWritingMultipleEncodedDataBodyPartsToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + + let multipartFormData = MultipartFormData() + let french = "français".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let japanese = "日本語".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let emoji = "😃👍🏻🍻🎉".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(data: french, name: "french") + multipartFormData.appendBodyPart(data: japanese, name: "japanese") + multipartFormData.appendBodyPart(data: emoji, name: "emoji") + + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = ( + BoundaryGenerator.boundary(boundaryType: .Initial, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"french\"\(self.CRLF)\(self.CRLF)" + + "français" + + BoundaryGenerator.boundary(boundaryType: .Encapsulated, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"japanese\"\(self.CRLF)\(self.CRLF)" + + "日本語" + + BoundaryGenerator.boundary(boundaryType: .Encapsulated, boundaryKey: boundary) + + "Content-Disposition: form-data; name=\"emoji\"\(self.CRLF)\(self.CRLF)" + + "😃👍🏻🍻🎉" + + BoundaryGenerator.boundary(boundaryType: .Final, boundaryKey: boundary) + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } + + func testWritingEncodedFileBodyPartToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + + let multipartFormData = MultipartFormData() + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = NSMutableData() + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } + + func testWritingMultipleEncodedFileBodyPartsToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + + let multipartFormData = MultipartFormData() + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + let rainbowImageURL = URLForResource("rainbow", withExtension: "jpg") + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + multipartFormData.appendBodyPart(fileURL: rainbowImageURL, name: "rainbow") + + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = NSMutableData() + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"rainbow\"; filename=\"rainbow.jpg\"\(self.CRLF)" + + "Content-Type: image/jpeg\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: rainbowImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } + + func testWritingEncodedStreamBodyPartToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + + let multipartFormData = MultipartFormData() + + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + let unicornDataLength = UInt64(NSData(contentsOfURL: unicornImageURL)!.length) + let unicornStream = NSInputStream(URL: unicornImageURL)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart( + stream: unicornStream, + name: "unicorn", + fileName: "unicorn.png", + length: unicornDataLength, + mimeType: "image/png" + ) + + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = NSMutableData() + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } + + func testWritingMultipleEncodedStreamBodyPartsToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + + let multipartFormData = MultipartFormData() + + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + let unicornDataLength = UInt64(NSData(contentsOfURL: unicornImageURL)!.length) + let unicornStream = NSInputStream(URL: unicornImageURL)! + + let rainbowImageURL = URLForResource("rainbow", withExtension: "jpg") + let rainbowDataLength = UInt64(NSData(contentsOfURL: rainbowImageURL)!.length) + let rainbowStream = NSInputStream(URL: rainbowImageURL)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart( + stream: unicornStream, + name: "unicorn", + fileName: "unicorn.png", + length: unicornDataLength, + mimeType: "image/png" + ) + multipartFormData.appendBodyPart( + stream: rainbowStream, + name: "rainbow", + fileName: "rainbow.jpg", + length: rainbowDataLength, + mimeType: "image/jpeg" + ) + + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = NSMutableData() + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"rainbow\"; filename=\"rainbow.jpg\"\(self.CRLF)" + + "Content-Type: image/jpeg\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: rainbowImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } + + func testWritingMultipleEncodedBodyPartsWithVaryingTypesToDisk() { + // Given + let expectation = expectationWithDescription("multipart form data should be written to disk") + let fileURL = temporaryFileURL() + + let multipartFormData = MultipartFormData() + + let loremData = "Lorem ipsum.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let unicornImageURL = URLForResource("unicorn", withExtension: "png") + + let rainbowImageURL = URLForResource("rainbow", withExtension: "jpg") + let rainbowDataLength = UInt64(NSData(contentsOfURL: rainbowImageURL)!.length) + let rainbowStream = NSInputStream(URL: rainbowImageURL)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(data: loremData, name: "lorem") + multipartFormData.appendBodyPart(fileURL: unicornImageURL, name: "unicorn") + multipartFormData.appendBodyPart( + stream: rainbowStream, + name: "rainbow", + fileName: "rainbow.jpg", + length: rainbowDataLength, + mimeType: "image/jpeg" + ) + + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNil(encodingError, "encoding error should be nil") + + if let fileData = NSData(contentsOfURL: fileURL) { + let boundary = multipartFormData.boundary + + let expectedFileData = NSMutableData() + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Initial, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"lorem\"\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(loremData) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(self.CRLF)" + + "Content-Type: image/png\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: unicornImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Encapsulated, boundaryKey: boundary)) + expectedFileData.appendData(( + "Content-Disposition: form-data; name=\"rainbow\"; filename=\"rainbow.jpg\"\(self.CRLF)" + + "Content-Type: image/jpeg\(self.CRLF)\(self.CRLF)" + ).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + ) + expectedFileData.appendData(NSData(contentsOfURL: rainbowImageURL)!) + expectedFileData.appendData(BoundaryGenerator.boundaryData(boundaryType: .Final, boundaryKey: boundary)) + + XCTAssertEqual(fileData, expectedFileData, "file data should match expected file data") + } else { + XCTFail("file data should not be nil") + } + } +} + +// MARK: - + +class MultipartFormDataFailureTestCase: BaseTestCase { + func testThatAppendingFileBodyPartWithInvalidLastPathComponentReturnsError() { + // Given + let fileURL = NSURL(string: "")! + let multipartFormData = MultipartFormData() + + // When + let error = multipartFormData.appendBodyPart(fileURL: fileURL, name: "empty_data") + + // Then + XCTAssertNotNil(error, "error should not be nil") + + if let error = error { + XCTAssertEqual(error.domain, "com.alamofire.error", "error domain does not match expected value") + XCTAssertEqual(error.code, NSURLErrorBadURL, "error code does not match expected value") + + if let failureReason = error.userInfo?[NSLocalizedFailureReasonErrorKey] as? String { + let expectedFailureReason = "Failed to extract the fileName of the provided URL: \(fileURL)" + XCTAssertEqual(failureReason, expectedFailureReason, "error failure reason does not match expected value") + } else { + XCTFail("failure reason should not be nil") + } + } + } + + func testThatAppendingFileBodyPartThatIsNotFileURLReturnsError() { + // Given + let fileURL = NSURL(string: "http://example.com/image.jpg")! + let multipartFormData = MultipartFormData() + + // When + let error = multipartFormData.appendBodyPart(fileURL: fileURL, name: "empty_data") + + // Then + XCTAssertNotNil(error, "error should not be nil") + + if let error = error { + XCTAssertEqual(error.domain, "com.alamofire.error", "error domain does not match expected value") + XCTAssertEqual(error.code, NSURLErrorBadURL, "error code does not match expected value") + + if let failureReason = error.userInfo?[NSLocalizedFailureReasonErrorKey] as? String { + let expectedFailureReason = "The URL does not point to a file URL: \(fileURL)" + XCTAssertEqual(failureReason, expectedFailureReason, "error failure reason does not match expected value") + } else { + XCTFail("failure reason should not be nil") + } + } + } + + func testThatAppendingFileBodyPartThatIsNotReachableReturnsError() { + // Given + let fileURL = NSURL(fileURLWithPath: NSTemporaryDirectory().stringByAppendingPathComponent("does_not_exist.jpg"))! + let multipartFormData = MultipartFormData() + + // When + let error = multipartFormData.appendBodyPart(fileURL: fileURL, name: "empty_data") + + // Then + XCTAssertNotNil(error, "error should not be nil") + + if let error = error { + XCTAssertEqual(error.domain, "com.alamofire.error", "error domain does not match expected value") + XCTAssertEqual(error.code, NSURLErrorBadURL, "error code does not match expected value") + + if let failureReason = error.userInfo?[NSLocalizedFailureReasonErrorKey] as? String { + let expectedFailureReason = "The URL is not reachable: \(fileURL)" + XCTAssertEqual(failureReason, expectedFailureReason, "error failure reason does not match expected value") + } else { + XCTFail("failure reason should not be nil") + } + } + } + + func testThatAppendingFileBodyPartThatIsDirectoryReturnsError() { + // Given + let directoryURL = NSURL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)! + let multipartFormData = MultipartFormData() + + // When + let error = multipartFormData.appendBodyPart(fileURL: directoryURL, name: "empty_data") + + // Then + XCTAssertNotNil(error, "error should not be nil") + + if let error = error { + XCTAssertEqual(error.domain, "com.alamofire.error", "error domain does not match expected value") + XCTAssertEqual(error.code, NSURLErrorBadURL, "error code does not match expected value") + + if let failureReason = error.userInfo?[NSLocalizedFailureReasonErrorKey] as? String { + let expectedFailureReason = "The URL is a directory, not a file: \(directoryURL)" + XCTAssertEqual(failureReason, expectedFailureReason, "error failure reason does not match expected value") + } else { + XCTFail("failure reason should not be nil") + } + } + } + + func testThatWritingEncodedDataToExistingFileURLFails() { + // Given + let expectation = expectationWithDescription("multipart form data should fail when writing to disk") + + let fileURL = temporaryFileURL() + "dummy data".writeToURL(fileURL, atomically: true, encoding: NSUTF8StringEncoding, error: nil) + + let multipartFormData = MultipartFormData() + let data = "Lorem ipsum dolor sit amet.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(data: data, name: "data") + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(encodingError, "encoding error should not be nil") + + if let encodingError = encodingError { + XCTAssertEqual(encodingError.domain, "com.alamofire.error", "encoding error domain does not match expected value") + XCTAssertEqual(encodingError.code, NSURLErrorBadURL, "encoding error code does not match expected value") + } + } + + func testThatWritingEncodedDataToBadURLFails() { + // Given + let expectation = expectationWithDescription("multipart form data should fail when writing to disk") + let fileURL = NSURL(string: "/this/is/not/a/valid/url")! + + let multipartFormData = MultipartFormData() + let data = "Lorem ipsum dolor sit amet.".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + var encodingError: NSError? + + // When + multipartFormData.appendBodyPart(data: data, name: "data") + multipartFormData.writeEncodedDataToDisk(fileURL) { error in + encodingError = error + expectation.fulfill() + } + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(encodingError, "encoding error should not be nil") + + if let encodingError = encodingError { + XCTAssertEqual(encodingError.domain, "com.alamofire.error", "encoding error domain does not match expected value") + XCTAssertEqual(encodingError.code, NSURLErrorBadURL, "encoding error code does not match expected value") + } + } +} diff --git a/Tests/Resources/rainbow.jpg b/Tests/Resources/rainbow.jpg new file mode 100644 index 0000000000000000000000000000000000000000..91224688e903ce6a71dde6055be8c85c40e55c01 Binary files /dev/null and b/Tests/Resources/rainbow.jpg differ diff --git a/Tests/Resources/unicorn.png b/Tests/Resources/unicorn.png new file mode 100644 index 0000000000000000000000000000000000000000..bc504e55c52c14246f7f86adc729316535b24cef Binary files /dev/null and b/Tests/Resources/unicorn.png differ diff --git a/Tests/UploadTests.swift b/Tests/UploadTests.swift index 838c4192b44b036b157d4ae327bcedcfdcd465cf..a6c157083df76df8e19f6d8bc7502808917f70cd 100644 --- a/Tests/UploadTests.swift +++ b/Tests/UploadTests.swift @@ -24,8 +24,8 @@ import Alamofire import Foundation import XCTest -class UploadResponseTestCase: BaseTestCase { - func testUploadRequest() { +class UploadDataTestCase: BaseTestCase { + func testUploadDataRequest() { // Given let URLString = "http://httpbin.org/post" let data = "Lorem ipsum dolor sit amet".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! @@ -54,7 +54,7 @@ class UploadResponseTestCase: BaseTestCase { XCTAssertNil(error, "error should be nil") } - func testUploadRequestWithProgress() { + func testUploadDataRequestWithProgress() { // Given let URLString = "http://httpbin.org/post" let data: NSData = { @@ -114,7 +114,430 @@ class UploadResponseTestCase: BaseTestCase { } } - if let lastByteValue = byteValues.last, + if let + lastByteValue = byteValues.last, + lastProgressValue = progressValues.last + { + let byteValueFractionalCompletion = Double(lastByteValue.totalBytes) / Double(lastByteValue.totalBytesExpected) + let progressValueFractionalCompletion = Double(lastProgressValue.0) / Double(lastProgressValue.1) + + XCTAssertEqual(byteValueFractionalCompletion, 1.0, "byte value fractional completion should equal 1.0") + XCTAssertEqual(progressValueFractionalCompletion, 1.0, "progress value fractional completion should equal 1.0") + } else { + XCTFail("last item in bytesValues and progressValues should not be nil") + } + } +} + +// MARK: - + +class UploadMultipartFormDataTestCase: BaseTestCase { + + // MARK: Tests + + func testThatUploadingMultipartFormDataSetsContentTypeHeader() { + // Given + let URLString = "http://httpbin.org/post" + let uploadData = "upload_data".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var formData: MultipartFormData? + var request: NSURLRequest? + var response: NSHTTPURLResponse? + var data: AnyObject? + var error: NSError? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: uploadData, name: "upload_data") + formData = multipartFormData + }, + encodingCompletion: { result in + switch result { + case .Success(let upload, _, _): + upload.response { responseRequest, responseResponse, responseData, responseError in + request = responseRequest + response = responseResponse + data = responseData + error = responseError + + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(request, "request should not be nil") + XCTAssertNotNil(response, "response should not be nil") + XCTAssertNotNil(data, "data should not be nil") + XCTAssertNil(error, "error should be nil") + + if let + request = request, + multipartFormData = formData, + contentType = request.valueForHTTPHeaderField("Content-Type") + { + XCTAssertEqual(contentType, multipartFormData.contentType, "Content-Type header value should match") + } else { + XCTFail("Content-Type header value should not be nil") + } + } + + func testThatUploadingMultipartFormDataSucceedsWithDefaultParameters() { + // Given + let URLString = "http://httpbin.org/post" + let french = "français".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let japanese = "日本語".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var request: NSURLRequest? + var response: NSHTTPURLResponse? + var data: AnyObject? + var error: NSError? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: french, name: "french") + multipartFormData.appendBodyPart(data: japanese, name: "japanese") + }, + encodingCompletion: { result in + switch result { + case .Success(let upload, _, _): + upload.response { responseRequest, responseResponse, responseData, responseError in + request = responseRequest + response = responseResponse + data = responseData + error = responseError + + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(request, "request should not be nil") + XCTAssertNotNil(response, "response should not be nil") + XCTAssertNotNil(data, "data should not be nil") + XCTAssertNil(error, "error should be nil") + } + + func testThatUploadingMultipartFormDataWhileStreamingFromMemoryMonitorsProgress() { + executeMultipartFormDataUploadRequestWithProgress(streamFromDisk: false) + } + + func testThatUploadingMultipartFormDataWhileStreamingFromDiskMonitorsProgress() { + executeMultipartFormDataUploadRequestWithProgress(streamFromDisk: true) + } + + func testThatUploadingMultipartFormDataBelowMemoryThresholdStreamsFromMemory() { + // Given + let URLString = "http://httpbin.org/post" + let french = "français".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let japanese = "日本語".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var streamingFromDisk: Bool? + var streamFileURL: NSURL? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: french, name: "french") + multipartFormData.appendBodyPart(data: japanese, name: "japanese") + }, + encodingCompletion: { result in + switch result { + case let .Success(upload, uploadStreamingFromDisk, uploadStreamFileURL): + streamingFromDisk = uploadStreamingFromDisk + streamFileURL = uploadStreamFileURL + + upload.response { _, _, _, _ in + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(streamingFromDisk, "streaming from disk should not be nil") + XCTAssertNil(streamFileURL, "stream file URL should be nil") + + if let streamingFromDisk = streamingFromDisk { + XCTAssertFalse(streamingFromDisk, "streaming from disk should be false") + } + } + + func testThatUploadingMultipartFormDataBelowMemoryThresholdSetsContentTypeHeader() { + // Given + let URLString = "http://httpbin.org/post" + let uploadData = "upload data".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var formData: MultipartFormData? + var request: NSURLRequest? + var streamingFromDisk: Bool? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: uploadData, name: "upload_data") + formData = multipartFormData + }, + encodingCompletion: { result in + switch result { + case let .Success(upload, uploadStreamingFromDisk, uploadStreamFileURL): + streamingFromDisk = uploadStreamingFromDisk + + upload.response { responseRequest, _, _, _ in + request = responseRequest + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(streamingFromDisk, "streaming from disk should not be nil") + + if let streamingFromDisk = streamingFromDisk { + XCTAssertFalse(streamingFromDisk, "streaming from disk should be false") + } + + if let + request = request, + multipartFormData = formData, + contentType = request.valueForHTTPHeaderField("Content-Type") + { + XCTAssertEqual(contentType, multipartFormData.contentType, "Content-Type header value should match") + } else { + XCTFail("Content-Type header value should not be nil") + } + } + + func testThatUploadingMultipartFormDataAboveMemoryThresholdStreamsFromDisk() { + // Given + let URLString = "http://httpbin.org/post" + let french = "français".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + let japanese = "日本語".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var streamingFromDisk: Bool? + var streamFileURL: NSURL? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: french, name: "french") + multipartFormData.appendBodyPart(data: japanese, name: "japanese") + }, + encodingMemoryThreshold: 0, + encodingCompletion: { result in + switch result { + case let .Success(upload, uploadStreamingFromDisk, uploadStreamFileURL): + streamingFromDisk = uploadStreamingFromDisk + streamFileURL = uploadStreamFileURL + + upload.response { _, _, _, _ in + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(streamingFromDisk, "streaming from disk should not be nil") + XCTAssertNotNil(streamFileURL, "stream file URL should not be nil") + + if let + streamingFromDisk = streamingFromDisk, + streamFilePath = streamFileURL?.path + { + XCTAssertTrue(streamingFromDisk, "streaming from disk should be true") + XCTAssertTrue(NSFileManager.defaultManager().fileExistsAtPath(streamFilePath), "stream file path should exist") + } + } + + func testThatUploadingMultipartFormDataAboveMemoryThresholdSetsContentTypeHeader() { + // Given + let URLString = "http://httpbin.org/post" + let uploadData = "upload data".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var formData: MultipartFormData? + var request: NSURLRequest? + var streamingFromDisk: Bool? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: uploadData, name: "upload_data") + formData = multipartFormData + }, + encodingMemoryThreshold: 0, + encodingCompletion: { result in + switch result { + case let .Success(upload, uploadStreamingFromDisk, uploadStreamFileURL): + streamingFromDisk = uploadStreamingFromDisk + + upload.response { responseRequest, _, _, _ in + request = responseRequest + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(streamingFromDisk, "streaming from disk should not be nil") + + if let streamingFromDisk = streamingFromDisk { + XCTAssertTrue(streamingFromDisk, "streaming from disk should be true") + } + + if let + request = request, + multipartFormData = formData, + contentType = request.valueForHTTPHeaderField("Content-Type") + { + XCTAssertEqual(contentType, multipartFormData.contentType, "Content-Type header value should match") + } else { + XCTFail("Content-Type header value should not be nil") + } + } + + // MARK: Combined Test Execution + + private func executeMultipartFormDataUploadRequestWithProgress(#streamFromDisk: Bool) { + // Given + let URLString = "http://httpbin.org/post" + let loremData1: NSData = { + var loremValues: [String] = [] + for _ in 1...1_500 { + loremValues.append("Lorem ipsum dolor sit amet, consectetur adipiscing elit.") + } + + return join(" ", loremValues).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + }() + let loremData2: NSData = { + var loremValues: [String] = [] + for _ in 1...1_500 { + loremValues.append("Lorem ipsum dolor sit amet, nam no graeco recusabo appellantur.") + } + + return join(" ", loremValues).dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false)! + }() + + let expectation = expectationWithDescription("multipart form data upload should succeed") + + var byteValues: [(bytes: Int64, totalBytes: Int64, totalBytesExpected: Int64)] = [] + var progressValues: [(completedUnitCount: Int64, totalUnitCount: Int64)] = [] + var request: NSURLRequest? + var response: NSHTTPURLResponse? + var data: AnyObject? + var error: NSError? + + // When + Alamofire.upload( + .POST, + URLString: URLString, + multipartFormData: { multipartFormData in + multipartFormData.appendBodyPart(data: loremData1, name: "lorem1") + multipartFormData.appendBodyPart(data: loremData2, name: "lorem2") + }, + encodingMemoryThreshold: streamFromDisk ? 0 : 100_000_000, + encodingCompletion: { result in + switch result { + case .Success(let upload, let streamingFromDisk, _): + upload.progress { bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in + let bytes = (bytes: bytesWritten, totalBytes: totalBytesWritten, totalBytesExpected: totalBytesExpectedToWrite) + byteValues.append(bytes) + + let progress = (completedUnitCount: upload.progress.completedUnitCount, totalUnitCount: upload.progress.totalUnitCount) + progressValues.append(progress) + } + upload.response { responseRequest, responseResponse, responseData, responseError in + request = responseRequest + response = responseResponse + data = responseData + error = responseError + + expectation.fulfill() + } + case .Failure: + expectation.fulfill() + } + } + ) + + waitForExpectationsWithTimeout(self.defaultTimeout, handler: nil) + + // Then + XCTAssertNotNil(request, "request should not be nil") + XCTAssertNotNil(response, "response should not be nil") + XCTAssertNotNil(data, "data should not be nil") + XCTAssertNil(error, "error should be nil") + + XCTAssertEqual(byteValues.count, progressValues.count, "byteValues count should equal progressValues count") + + if byteValues.count == progressValues.count { + for index in 0..