Browse Source

Merge pull request #1461 from Alamofire/feature/robust-validation

Feature - Robust Validation
Christian Noon 9 years ago
parent
commit
cb2f77f08c
5 changed files with 864 additions and 294 deletions
  1. 60 39
      Source/AFError.swift
  2. 7 42
      Source/SessionManager.swift
  3. 194 69
      Source/Validation.swift
  4. 23 0
      Tests/AFError+AlamofireTests.swift
  5. 580 144
      Tests/ValidationTests.swift

+ 60 - 39
Source/AFError.swift

@@ -51,8 +51,8 @@ public enum AFError: Error {
     /// - `.bodyPartInputStreamCreationFailed`:     An `InputStream` could not be created for the provided `fileURL`.
     /// - `.outputStreamCreationFailed`:            An `OutputStream` could not be created when attempting to write the
     ///                                             encoded data to disk.
-    /// - `.outputStreamFileAlreadyExists`:         The encoded body data could not be writtent disk because a file already
-    ///                                             exists at the provided `fileURL`.
+    /// - `.outputStreamFileAlreadyExists`:         The encoded body data could not be writtent disk because a file 
+    ///                                             already exists at the provided `fileURL`.
     /// - `.outputStreamURLInvalid`:                The `fileURL` provided for writing the encoded body data to disk is
     ///                                             not a file URL.
     /// - `.outputStreamWriteFailed`:               The attempt to write the encoded body data to disk failed with an
@@ -79,12 +79,16 @@ public enum AFError: Error {
 
     /// The reason underlying the `AFError.responseValidationFailed` state.
     ///
+    /// - `.dataFileNil`:               The data file containing the server response did not exist.
+    /// - `.dataFileReadFailed`:        The data file containing the server response could not be read.
     /// - `.missingContentType`:        The response did not contain a `Content-Type` and the `acceptableContentTypes`
     ///                                 provided did not contain wildcard type.
     /// - `unacceptableContentType`:    The response `Content-Type` did not match any type in the provided
     ///    .                            `acceptableContentTypes`.
     /// - `.unacceptableStatusCode`:    The response status code was not acceptable.
     public enum ValidationFailureReason {
+        case dataFileNil
+        case dataFileReadFailed(at: URL)
         case missingContentType(acceptableContentTypes: [String])
         case unacceptableContentType(acceptableContentTypes: [String], responseContentType: String)
         case unacceptableStatusCode(code: Int)
@@ -210,8 +214,9 @@ extension AFError.MultipartEncodingFailureReason {
     var url: URL? {
         switch self {
         case .bodyPartURLInvalid(let url), .bodyPartFilenameInvalid(let url), .bodyPartFileNotReachable(let url),
-             .bodyPartFileIsDirectory(let url), .bodyPartFileSizeNotAvailable(let url), .bodyPartInputStreamCreationFailed(let url),
-             .outputStreamCreationFailed(let url), .outputStreamFileAlreadyExists(let url), .outputStreamURLInvalid(let url),
+             .bodyPartFileIsDirectory(let url), .bodyPartFileSizeNotAvailable(let url),
+             .bodyPartInputStreamCreationFailed(let url), .outputStreamCreationFailed(let url),
+             .outputStreamFileAlreadyExists(let url), .outputStreamURLInvalid(let url),
              .bodyPartFileNotReachableWithError(let url, _), .bodyPartFileSizeQueryFailedWithError(let url, _):
             return url
         default:
@@ -294,6 +299,45 @@ extension AFError: LocalizedError {
     }
 }
 
+extension AFError.MultipartEncodingFailureReason {
+    var localizedDescription: String {
+        switch self {
+        case .bodyPartURLInvalid(let url):
+            return "The URL provided is not a file URL: \(url)"
+        case .bodyPartFilenameInvalid(let url):
+            return "The URL provided does not have a valid filename: \(url)"
+        case .bodyPartFileNotReachable(let url):
+            return "The URL provided is not reachable: \(url)"
+        case .bodyPartFileNotReachableWithError(let url, let error):
+            return (
+                "The system returned an error while checking the provided URL for " +
+                "reachability.\nURL: \(url)\nError: \(error)"
+            )
+        case .bodyPartFileIsDirectory(let url):
+            return "The URL provided is a directory: \(url)"
+        case .bodyPartFileSizeNotAvailable(let url):
+            return "Could not fetch the file size from the provided URL: \(url)"
+        case .bodyPartFileSizeQueryFailedWithError(let url, let error):
+            return (
+                "The system returned an error while attempting to fetch the file size from the " +
+                "provided URL.\nURL: \(url)\nError: \(error)"
+            )
+        case .bodyPartInputStreamCreationFailed(let url):
+            return "Failed to create an InputStream for the provided URL: \(url)"
+        case .outputStreamCreationFailed(let url):
+            return "Failed to create an OutputStream for URL: \(url)"
+        case .outputStreamFileAlreadyExists(let url):
+            return "A file already exists at the provided URL: \(url)"
+        case .outputStreamURLInvalid(let url):
+            return "The provided OutputStream URL is invalid: \(url)"
+        case .outputStreamWriteFailed(let error):
+            return "OutputStream write failed with error: \(error)"
+        case .inputStreamReadFailed(let error):
+            return "InputStream read failed with error: \(error)"
+        }
+    }
+}
+
 extension AFError.SerializationFailureReason {
     var localizedDescription: String {
         switch self {
@@ -318,45 +362,22 @@ extension AFError.SerializationFailureReason {
 extension AFError.ValidationFailureReason {
     var localizedDescription: String {
         switch self {
+        case .dataFileNil:
+            return "Response could not be validated, data file was nil."
+        case .dataFileReadFailed(let url):
+            return "Response could not be validated, data file could not be read: \(url)."
         case .missingContentType(let types):
-            return "Response Content-Type was missing and acceptable content types (\(types.joined(separator: ","))) do not match \"*/*\"."
+            return (
+                "Response Content-Type was missing and acceptable content types " +
+                "(\(types.joined(separator: ","))) do not match \"*/*\"."
+            )
         case .unacceptableContentType(let acceptableTypes, let responseType):
-            return "Response Content-Type \"\(responseType)\" does not match any acceptable types: \(acceptableTypes.joined(separator: ","))."
+            return (
+                "Response Content-Type \"\(responseType)\" does not match any acceptable types: " +
+                "\(acceptableTypes.joined(separator: ","))."
+            )
         case .unacceptableStatusCode(let code):
             return "Response status code was unacceptable: \(code)."
         }
     }
 }
-
-extension AFError.MultipartEncodingFailureReason {
-    var localizedDescription: String {
-        switch self {
-        case .bodyPartURLInvalid(let url):
-            return "The URL provided is not a file URL: \(url)"
-        case .bodyPartFilenameInvalid(let url):
-            return "The URL provided does not have a valid filename: \(url)"
-        case .bodyPartFileNotReachable(let url):
-            return "The URL provided is not reachable: \(url)"
-        case .bodyPartFileNotReachableWithError(let url, let error):
-            return "The system returned an error while checking the provided URL for reachability.\nURL: \(url)\nError: \(error)"
-        case .bodyPartFileIsDirectory(let url):
-            return "The URL provided is a directory: \(url)"
-        case .bodyPartFileSizeNotAvailable(let url):
-            return "Could not fetch the file size from the provided URL: \(url)"
-        case .bodyPartFileSizeQueryFailedWithError(let url, let error):
-            return "The system returned an error while attempting to fetch the file size from the provided URL.\nURL: \(url)\nError: \(error)"
-        case .bodyPartInputStreamCreationFailed(let url):
-            return "Failed to create an InputStream for the provided URL: \(url)"
-        case .outputStreamCreationFailed(let url):
-            return "Failed to create an OutputStream for URL: \(url)"
-        case .outputStreamFileAlreadyExists(let url):
-            return "A file already exists at the provided URL: \(url)"
-        case .outputStreamURLInvalid(let url):
-            return "The provided OutputStream URL is invalid: \(url)"
-        case .outputStreamWriteFailed(let error):
-            return "OutputStream write failed with error: \(error)"
-        case .inputStreamReadFailed(let error):
-            return "InputStream read failed with error: \(error)"
-        }
-    }
-}

+ 7 - 42
Source/SessionManager.swift

@@ -236,12 +236,9 @@ open class SessionManager {
     /// - returns: The created `DataRequest`.
     open func request(_ urlRequest: URLRequestConvertible) -> DataRequest {
         let originalRequest = urlRequest.urlRequest
-        let adaptedRequest = originalRequest.adapt(using: adapter)
-
-        var task: URLSessionDataTask!
-        queue.sync { task = self.session.dataTask(with: adaptedRequest) }
-
         let originalTask = DataRequest.Requestable(urlRequest: originalRequest)
+
+        let task = originalTask.task(session: session, adapter: adapter, queue: queue)
         let request = DataRequest(session: session, task: task, originalTask: originalTask)
 
         delegate[request.delegate.task] = request
@@ -331,16 +328,7 @@ open class SessionManager {
         to destination: DownloadRequest.DownloadFileDestination)
         -> DownloadRequest
     {
-        var task: URLSessionDownloadTask!
-
-        switch downloadable {
-        case let .request(urlRequest):
-            let urlRequest = urlRequest.adapt(using: adapter)
-            queue.sync { task = self.session.downloadTask(with: urlRequest) }
-        case let .resumeData(resumeData):
-            queue.sync { task = self.session.downloadTask(withResumeData: resumeData) }
-        }
-
+        let task = downloadable.task(session: session, adapter: adapter, queue: queue)
         let request = DownloadRequest(session: session, task: task, originalTask: downloadable)
 
         request.downloadDelegate.downloadTaskDidFinishDownloadingToURL = { session, task, URL in
@@ -593,26 +581,11 @@ open class SessionManager {
     // MARK: Private - Upload Implementation
 
     private func upload(_ uploadable: UploadRequest.Uploadable) -> UploadRequest {
-        var task: URLSessionUploadTask!
-        var HTTPBodyStream: InputStream?
-
-        switch uploadable {
-        case let .data(data, urlRequest):
-            let urlRequest = urlRequest.adapt(using: adapter)
-            queue.sync { task = self.session.uploadTask(with: urlRequest, from: data) }
-        case let .file(fileURL, urlRequest):
-            let urlRequest = urlRequest.adapt(using: adapter)
-            queue.sync { task = self.session.uploadTask(with: urlRequest, fromFile: fileURL) }
-        case let .stream(stream, urlRequest):
-            let urlRequest = urlRequest.adapt(using: adapter)
-            queue.sync { task = self.session.uploadTask(withStreamedRequest: urlRequest) }
-            HTTPBodyStream = stream
-        }
-
+        let task = uploadable.task(session: session, adapter: adapter, queue: queue)
         let request = UploadRequest(session: session, task: task, originalTask: uploadable)
 
-        if HTTPBodyStream != nil {
-            request.delegate.taskNeedNewBodyStream = { _, _ in HTTPBodyStream }
+        if case let .stream(inputStream, _) = uploadable {
+            request.delegate.taskNeedNewBodyStream = { _, _ in inputStream }
         }
 
         delegate[request.delegate.task] = request
@@ -658,15 +631,7 @@ open class SessionManager {
     // MARK: Private - Stream Implementation
 
     private func stream(_ streamable: StreamRequest.Streamable) -> StreamRequest {
-        var task: URLSessionStreamTask!
-
-        switch streamable {
-        case let .stream(hostName, port):
-            queue.sync { task = self.session.streamTask(withHostName: hostName, port: port) }
-        case .netService(let netService):
-            queue.sync { task = self.session.streamTask(with: netService) }
-        }
-
+        let task = streamable.task(session: session, adapter: adapter, queue: queue)
         let request = StreamRequest(session: session, task: task, originalTask: streamable)
 
         delegate[request.delegate.task] = request

+ 194 - 69
Source/Validation.swift

@@ -25,6 +25,11 @@
 import Foundation
 
 extension Request {
+
+    // MARK: Helper Types
+
+    fileprivate typealias ErrorReason = AFError.ValidationFailureReason
+
     /// Used to represent whether validation was successful or encountered an error resulting in a failure.
     ///
     /// - success: The validation was successful.
@@ -34,9 +39,119 @@ extension Request {
         case failure(Error)
     }
 
-    /// A closure used to validate a request that takes a URL request and URL response, and returns whether the
+    fileprivate struct MIMEType {
+        let type: String
+        let subtype: String
+
+        var isWildcard: Bool { return type == "*" && subtype == "*" }
+
+        init?(_ string: String) {
+            let components: [String] = {
+                let stripped = string.trimmingCharacters(in: .whitespacesAndNewlines)
+                let split = stripped.substring(to: stripped.range(of: ";")?.lowerBound ?? stripped.endIndex)
+                return split.components(separatedBy: "/")
+            }()
+
+            if let type = components.first, let subtype = components.last {
+                self.type = type
+                self.subtype = subtype
+            } else {
+                return nil
+            }
+        }
+
+        func matches(_ mime: MIMEType) -> Bool {
+            switch (type, subtype) {
+            case (mime.type, mime.subtype), (mime.type, "*"), ("*", mime.subtype), ("*", "*"):
+                return true
+            default:
+                return false
+            }
+        }
+    }
+
+    // MARK: Properties
+
+    fileprivate var acceptableStatusCodes: [Int] { return Array(200..<300) }
+
+    fileprivate var acceptableContentTypes: [String] {
+        if let accept = request?.value(forHTTPHeaderField: "Accept") {
+            return accept.components(separatedBy: ",")
+        }
+
+        return ["*/*"]
+    }
+
+    // MARK: Status Code
+
+    fileprivate func validate<S: Sequence>(
+        statusCode acceptableStatusCodes: S,
+        response: HTTPURLResponse)
+        -> ValidationResult
+        where S.Iterator.Element == Int
+    {
+        if acceptableStatusCodes.contains(response.statusCode) {
+            return .success
+        } else {
+            let reason: ErrorReason = .unacceptableStatusCode(code: response.statusCode)
+            return .failure(AFError.responseValidationFailed(reason: reason))
+        }
+    }
+
+    // MARK: Content Type
+
+    fileprivate func validate<S: Sequence>(
+        contentType acceptableContentTypes: S,
+        response: HTTPURLResponse,
+        data: Data?)
+        -> ValidationResult
+        where S.Iterator.Element == String
+    {
+        guard let data = data, data.count > 0 else { return .success }
+
+        guard
+            let responseContentType = response.mimeType,
+            let responseMIMEType = MIMEType(responseContentType)
+        else {
+            for contentType in acceptableContentTypes {
+                if let mimeType = MIMEType(contentType), mimeType.isWildcard {
+                    return .success
+                }
+            }
+
+            let error: AFError = {
+                let reason: ErrorReason = .missingContentType(acceptableContentTypes: Array(acceptableContentTypes))
+                return AFError.responseValidationFailed(reason: reason)
+            }()
+
+            return .failure(error)
+        }
+
+        for contentType in acceptableContentTypes {
+            if let acceptableMIMEType = MIMEType(contentType), acceptableMIMEType.matches(responseMIMEType) {
+                return .success
+            }
+        }
+
+        let error: AFError = {
+            let reason: ErrorReason = .unacceptableContentType(
+                acceptableContentTypes: Array(acceptableContentTypes),
+                responseContentType: responseContentType
+            )
+
+            return AFError.responseValidationFailed(reason: reason)
+        }()
+        
+        return .failure(error)
+    }
+}
+
+// MARK: -
+
+extension DataRequest {
+    /// A closure used to validate a request that takes a URL request, a URL response and data, and returns whether the
     /// request was valid.
-    public typealias Validation = (URLRequest?, HTTPURLResponse) -> ValidationResult
+    public typealias Validation = (URLRequest?, HTTPURLResponse, Data?) -> ValidationResult
 
     /// Validates the request, using the specified closure.
     ///
@@ -51,7 +166,7 @@ extension Request {
             if
                 let response = self.response,
                 self.delegate.error == nil,
-                case let .failure(error) = validation(self.request, response)
+                case let .failure(error) = validation(self.request, response, self.delegate.data)
             {
                 self.delegate.error = error
             }
@@ -62,9 +177,7 @@ extension Request {
         return self
     }
 
-    // MARK: - Status Code
-
-    /// Validates that the response has a status code in the specified range.
+    /// Validates that the response has a status code in the specified sequence.
     ///
     /// If validation fails, subsequent calls to response handlers will have an associated error.
     ///
@@ -72,48 +185,84 @@ extension Request {
     ///
     /// - returns: The request.
     @discardableResult
-    public func validate<S: Sequence>(statusCode acceptableStatusCode: S) -> Self where S.Iterator.Element == Int {
-        return validate { _, response in
-            if acceptableStatusCode.contains(response.statusCode) {
-                return .success
-            } else {
-                return .failure(AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: response.statusCode)))
-            }
+    public func validate<S: Sequence>(statusCode acceptableStatusCodes: S) -> Self where S.Iterator.Element == Int {
+        return validate { _, response, _ in
+            return self.validate(statusCode: acceptableStatusCodes, response: response)
         }
     }
 
-    // MARK: - Content-Type
+    /// Validates that the response has a content type in the specified sequence.
+    ///
+    /// If validation fails, subsequent calls to response handlers will have an associated error.
+    ///
+    /// - parameter contentType: The acceptable content types, which may specify wildcard types and/or subtypes.
+    ///
+    /// - returns: The request.
+    @discardableResult
+    public func validate<S: Sequence>(contentType acceptableContentTypes: S) -> Self where S.Iterator.Element == String {
+        return validate { _, response, data in
+            return self.validate(contentType: acceptableContentTypes, response: response, data: data)
+        }
+    }
 
-    private struct MIMEType {
-        let type: String
-        let subtype: String
+    /// Validates that the response has a status code in the default acceptable range of 200...299, and that the content
+    /// type matches any specified in the Accept HTTP header field.
+    ///
+    /// If validation fails, subsequent calls to response handlers will have an associated error.
+    ///
+    /// - returns: The request.
+    @discardableResult
+    public func validate() -> Self {
+        return validate(statusCode: self.acceptableStatusCodes).validate(contentType: self.acceptableContentTypes)
+    }
+}
 
-        init?(_ string: String) {
-            let components: [String] = {
-                let stripped = string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
-                let split = stripped.substring(to: stripped.range(of: ";")?.lowerBound ?? stripped.endIndex)
-                return split.components(separatedBy: "/")
-            }()
+// MARK: -
 
-            if let type = components.first, let subtype = components.last {
-                self.type = type
-                self.subtype = subtype
-            } else {
-                return nil
+extension DownloadRequest {
+    /// A closure used to validate a request that takes a URL request, a URL response and destination URL, and returns
+    /// whether the request was valid.
+    public typealias Validation = (URLRequest?, HTTPURLResponse, URL?) -> ValidationResult
+
+    /// Validates the request, using the specified closure.
+    ///
+    /// If validation fails, subsequent calls to response handlers will have an associated error.
+    ///
+    /// - parameter validation: A closure to validate the request.
+    ///
+    /// - returns: The request.
+    @discardableResult
+    public func validate(_ validation: Validation) -> Self {
+        let validationExecution: () -> Void = {
+            if
+                let response = self.response,
+                self.delegate.error == nil,
+                case let .failure(error) = validation(self.request, response, self.downloadDelegate.destinationURL)
+            {
+                self.delegate.error = error
             }
         }
 
-        func matches(_ mime: MIMEType) -> Bool {
-            switch (type, subtype) {
-            case (mime.type, mime.subtype), (mime.type, "*"), ("*", mime.subtype), ("*", "*"):
-                return true
-            default:
-                return false
-            }
+        validations.append(validationExecution)
+
+        return self
+    }
+
+    /// Validates that the response has a status code in the specified sequence.
+    ///
+    /// If validation fails, subsequent calls to response handlers will have an associated error.
+    ///
+    /// - parameter range: The range of acceptable status codes.
+    ///
+    /// - returns: The request.
+    @discardableResult
+    public func validate<S: Sequence>(statusCode acceptableStatusCodes: S) -> Self where S.Iterator.Element == Int {
+        return validate { _, response, _ in
+            return self.validate(statusCode: acceptableStatusCodes, response: response)
         }
     }
 
-    /// Validates that the response has a content type in the specified array.
+    /// Validates that the response has a content type in the specified sequence.
     ///
     /// If validation fails, subsequent calls to response handlers will have an associated error.
     ///
@@ -122,35 +271,20 @@ extension Request {
     /// - returns: The request.
     @discardableResult
     public func validate<S: Sequence>(contentType acceptableContentTypes: S) -> Self where S.Iterator.Element == String {
-        return validate { _, response in
-            guard let validData = self.delegate.data, validData.count > 0 else { return .success }
-
-            guard
-                let responseContentType = response.mimeType,
-                let responseMIMEType = MIMEType(responseContentType) else {
-                for contentType in acceptableContentTypes {
-                    if let mimeType = MIMEType(contentType), mimeType.type == "*" && mimeType.subtype == "*" {
-                        return .success
-                    }
-                }
-
-                return .failure(AFError.responseValidationFailed(reason: .missingContentType(acceptableContentTypes: Array(acceptableContentTypes))))
+        return validate { _, response, fileURL in
+            guard let fileURL = fileURL else {
+                return .failure(AFError.responseValidationFailed(reason: .dataFileNil))
             }
 
-            for contentType in acceptableContentTypes {
-                if let acceptableMIMEType = MIMEType(contentType), acceptableMIMEType.matches(responseMIMEType) {
-                    return .success
-                }
+            do {
+                let data = try Data(contentsOf: fileURL)
+                return self.validate(contentType: acceptableContentTypes, response: response, data: data)
+            } catch {
+                return .failure(AFError.responseValidationFailed(reason: .dataFileReadFailed(at: fileURL)))
             }
-
-            let error = AFError.responseValidationFailed(reason: .unacceptableContentType(acceptableContentTypes: Array(acceptableContentTypes), responseContentType: responseContentType))
-
-            return .failure(error)
         }
     }
 
-    // MARK: - Automatic
-
     /// Validates that the response has a status code in the default acceptable range of 200...299, and that the content
     /// type matches any specified in the Accept HTTP header field.
     ///
@@ -159,15 +293,6 @@ extension Request {
     /// - returns: The request.
     @discardableResult
     public func validate() -> Self {
-        let acceptableStatusCodes: CountableRange<Int> = 200..<300
-        let acceptableContentTypes: [String] = {
-            if let accept = request?.value(forHTTPHeaderField: "Accept") {
-                return accept.components(separatedBy: ",")
-            }
-
-            return ["*/*"]
-        }()
-
-        return validate(statusCode: acceptableStatusCodes).validate(contentType: acceptableContentTypes)
+        return validate(statusCode: self.acceptableStatusCodes).validate(contentType: self.acceptableContentTypes)
     }
 }

+ 23 - 0
Tests/AFError+AlamofireTests.swift

@@ -25,6 +25,9 @@
 import Alamofire
 
 extension AFError {
+
+    // MultipartEncodingFailureReason
+
     var isBodyPartURLInvalid: Bool {
         if case let .multipartEncodingFailed(reason) = self, reason.isBodyPartURLInvalid { return true }
         return false
@@ -129,6 +132,16 @@ extension AFError {
 
     // ValidationFailureReason
 
+    var isDataFileNil: Bool {
+        if case let .responseValidationFailed(reason) = self, reason.isDataFileNil { return true }
+        return false
+    }
+
+    var isDataFileReadFailed: Bool {
+        if case let .responseValidationFailed(reason) = self, reason.isDataFileReadFailed { return true }
+        return false
+    }
+
     var isMissingContentType: Bool {
         if case let .responseValidationFailed(reason) = self, reason.isMissingContentType { return true }
         return false
@@ -256,6 +269,16 @@ extension AFError.SerializationFailureReason {
 // MARK: -
 
 extension AFError.ValidationFailureReason {
+    var isDataFileNil: Bool {
+        if case .dataFileNil = self { return true }
+        return false
+    }
+
+    var isDataFileReadFailed: Bool {
+        if case .dataFileReadFailed = self { return true }
+        return false
+    }
+
     var isMissingContentType: Bool {
         if case .missingContentType = self { return true }
         return false

+ 580 - 144
Tests/ValidationTests.swift

@@ -26,81 +26,120 @@
 import Foundation
 import XCTest
 
+private let fileURL = URL(fileURLWithPath: FileManager.documentsDirectory + "/test_response.json")
+
 class StatusCodeValidationTestCase: BaseTestCase {
     func testThatValidationForRequestWithAcceptableStatusCodeResponseSucceeds() {
         // Given
         let urlString = "https://httpbin.org/status/200"
-        let expectation = self.expectation(description: "request should return 200 status code")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should return 200 status code")
+        let expectation2 = self.expectation(description: "download should return 200 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(statusCode: 200..<300)
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
             }
 
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(statusCode: 200..<300)
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+        }
+
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithUnacceptableStatusCodeResponseFails() {
         // Given
         let urlString = "https://httpbin.org/status/404"
-        let expectation = self.expectation(description: "request should return 404 status code")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should return 404 status code")
+        let expectation2 = self.expectation(description: "download should return 404 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(statusCode: [200])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
             }
 
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(statusCode: [200])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+        }
+
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError, let statusCode = error.responseCode {
-            XCTAssertTrue(error.isUnacceptableStatusCode)
-            XCTAssertEqual(statusCode, 404)
-        } else {
-            XCTFail("Error should not be nil, should be an AFError, and should have an associated statusCode.")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError, let statusCode = error.responseCode {
+                XCTAssertTrue(error.isUnacceptableStatusCode)
+                XCTAssertEqual(statusCode, 404)
+            } else {
+                XCTFail("Error should not be nil, should be an AFError, and should have an associated statusCode.")
+            }
         }
     }
 
     func testThatValidationForRequestWithNoAcceptableStatusCodesFails() {
         // Given
         let urlString = "https://httpbin.org/status/201"
-        let expectation = self.expectation(description: "request should return 201 status code")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should return 201 status code")
+        let expectation2 = self.expectation(description: "download should return 201 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(statusCode: [])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
             }
 
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(statusCode: [])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+        }
+
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError, let statusCode = error.responseCode {
-            XCTAssertTrue(error.isUnacceptableStatusCode)
-            XCTAssertEqual(statusCode, 201)
-        } else {
-            XCTFail("Error should not be nil, should be an AFError, and should have an associated statusCode.")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError, let statusCode = error.responseCode {
+                XCTAssertTrue(error.isUnacceptableStatusCode)
+                XCTAssertEqual(statusCode, 201)
+            } else {
+                XCTFail("Error should not be nil, should be an AFError, and should have an associated statusCode.")
+            }
         }
     }
 }
@@ -111,9 +150,12 @@ class ContentTypeValidationTestCase: BaseTestCase {
     func testThatValidationForRequestWithAcceptableContentTypeResponseSucceeds() {
         // Given
         let urlString = "https://httpbin.org/ip"
-        let expectation = self.expectation(description: "request should succeed and return ip")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return ip")
+        let expectation2 = self.expectation(description: "download should succeed and return ip")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
@@ -121,22 +163,35 @@ class ContentTypeValidationTestCase: BaseTestCase {
             .validate(contentType: ["application/json;charset=utf8"])
             .validate(contentType: ["application/json;q=0.8;charset=utf8"])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(contentType: ["application/json"])
+            .validate(contentType: ["application/json;charset=utf8"])
+            .validate(contentType: ["application/json;q=0.8;charset=utf8"])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithAcceptableWildcardContentTypeResponseSucceeds() {
         // Given
         let urlString = "https://httpbin.org/ip"
-        let expectation = self.expectation(description: "request should succeed and return ip")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return ip")
+        let expectation2 = self.expectation(description: "download should succeed and return ip")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
@@ -144,93 +199,140 @@ class ContentTypeValidationTestCase: BaseTestCase {
             .validate(contentType: ["application/*"])
             .validate(contentType: ["*/json"])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(contentType: ["*/*"])
+            .validate(contentType: ["application/*"])
+            .validate(contentType: ["*/json"])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithUnacceptableContentTypeResponseFails() {
         // Given
         let urlString = "https://httpbin.org/xml"
-        let expectation = self.expectation(description: "request should succeed and return xml")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return xml")
+        let expectation2 = self.expectation(description: "download should succeed and return xml")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(contentType: ["application/octet-stream"])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(contentType: ["application/octet-stream"])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError {
-            XCTAssertTrue(error.isUnacceptableContentType)
-            XCTAssertEqual(error.responseContentType, "application/xml")
-            XCTAssertEqual(error.acceptableContentTypes?.first, "application/octet-stream")
-        } else {
-            XCTFail("error should not be nil")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError {
+                XCTAssertTrue(error.isUnacceptableContentType)
+                XCTAssertEqual(error.responseContentType, "application/xml")
+                XCTAssertEqual(error.acceptableContentTypes?.first, "application/octet-stream")
+            } else {
+                XCTFail("error should not be nil")
+            }
         }
     }
 
     func testThatValidationForRequestWithNoAcceptableContentTypeResponseFails() {
         // Given
         let urlString = "https://httpbin.org/xml"
-        let expectation = self.expectation(description: "request should succeed and return xml")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return xml")
+        let expectation2 = self.expectation(description: "download should succeed and return xml")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(contentType: [])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(contentType: [])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error, "error should not be nil")
-
-        if let error = error as? AFError {
-            XCTAssertTrue(error.isUnacceptableContentType)
-            XCTAssertEqual(error.responseContentType, "application/xml")
-            XCTAssertTrue(error.acceptableContentTypes?.isEmpty ?? false)
-        } else {
-            XCTFail("error should not be nil")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError {
+                XCTAssertTrue(error.isUnacceptableContentType)
+                XCTAssertEqual(error.responseContentType, "application/xml")
+                XCTAssertTrue(error.acceptableContentTypes?.isEmpty ?? false)
+            } else {
+                XCTFail("error should not be nil")
+            }
         }
     }
 
     func testThatValidationForRequestWithNoAcceptableContentTypeResponseSucceedsWhenNoDataIsReturned() {
         // Given
         let urlString = "https://httpbin.org/status/204"
-        let expectation = self.expectation(description: "request should succeed and return no data")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return no data")
+        let expectation2 = self.expectation(description: "download should succeed and return no data")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(contentType: [])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(contentType: [])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithAcceptableWildcardContentTypeResponseSucceedsWhenResponseIsNil() {
@@ -243,7 +345,32 @@ class ContentTypeValidationTestCase: BaseTestCase {
                 let task: URLSessionDataTask = queue.syncResult { session.dataTask(with: adaptedRequest) }
 
                 let originalTask = DataRequest.Requestable(urlRequest: originalRequest)
-                let request = MockRequest(session: session, task: task, originalTask: originalTask)
+                let request = MockDataRequest(session: session, task: task, originalTask: originalTask)
+
+                delegate[request.delegate.task] = request
+
+                if startRequestsImmediately { request.resume() }
+
+                return request
+            }
+
+            override func download(
+                _ urlRequest: URLRequestConvertible,
+                to destination: DownloadRequest.DownloadFileDestination)
+                -> DownloadRequest
+            {
+                let originalRequest = urlRequest.urlRequest
+                let adaptedRequest = originalRequest.adapt(using: adapter)
+
+                var task: URLSessionDownloadTask!
+                queue.sync { task = self.session.downloadTask(with: adaptedRequest) }
+
+                let originalTask = DownloadRequest.Downloadable.request(originalRequest)
+                let request = MockDownloadRequest(session: session, task: task, originalTask: originalTask)
+
+                request.downloadDelegate.downloadTaskDidFinishDownloadingToURL = { session, task, URL in
+                    return destination(URL, task.response as! HTTPURLResponse)
+                }
 
                 delegate[request.delegate.task] = request
 
@@ -253,7 +380,18 @@ class ContentTypeValidationTestCase: BaseTestCase {
             }
         }
 
-        class MockRequest: DataRequest {
+        class MockDataRequest: DataRequest {
+            override var response: HTTPURLResponse? {
+                return MockHTTPURLResponse(
+                    url: URL(string: request!.urlString)!,
+                    statusCode: 204,
+                    httpVersion: "HTTP/1.1",
+                    headerFields: nil
+                )
+            }
+        }
+
+        class MockDownloadRequest: DownloadRequest {
             override var response: HTTPURLResponse? {
                 return MockHTTPURLResponse(
                     url: URL(string: request!.urlString)!,
@@ -280,27 +418,44 @@ class ContentTypeValidationTestCase: BaseTestCase {
         }()
 
         let urlString = "https://httpbin.org/delete"
-        let expectation = self.expectation(description: "request should be stubbed and return 204 status code")
 
-        var response: DefaultDataResponse?
+        let expectation1 = self.expectation(description: "request should be stubbed and return 204 status code")
+        let expectation2 = self.expectation(description: "download should be stubbed and return 204 status code")
+
+        var requestResponse: DefaultDataResponse?
+        var downloadResponse: DefaultDownloadResponse?
 
         // When
         manager.request(urlString, withMethod: .delete)
             .validate(contentType: ["*/*"])
             .response { resp in
-                response = resp
-                expectation.fulfill()
+                requestResponse = resp
+                expectation1.fulfill()
+            }
+
+        manager.download(urlString, to: { _, _ in fileURL }, withMethod: .delete)
+            .validate(contentType: ["*/*"])
+            .response { resp in
+                downloadResponse = resp
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(response?.response)
-        XCTAssertNotNil(response?.data)
-        XCTAssertNil(response?.error)
+        XCTAssertNotNil(requestResponse?.response)
+        XCTAssertNotNil(requestResponse?.data)
+        XCTAssertNil(requestResponse?.error)
+
+        XCTAssertEqual(requestResponse?.response?.statusCode, 204)
+        XCTAssertNil(requestResponse?.response?.mimeType)
 
-        XCTAssertEqual(response?.response?.statusCode, 204)
-        XCTAssertNil(response?.response?.mimeType)
+        XCTAssertNotNil(downloadResponse?.response)
+        XCTAssertNotNil(downloadResponse?.destinationURL)
+        XCTAssertNil(downloadResponse?.error)
+
+        XCTAssertEqual(downloadResponse?.response?.statusCode, 204)
+        XCTAssertNil(downloadResponse?.response?.mimeType)
     }
 }
 
@@ -310,81 +465,121 @@ class MultipleValidationTestCase: BaseTestCase {
     func testThatValidationForRequestWithAcceptableStatusCodeAndContentTypeResponseSucceeds() {
         // Given
         let urlString = "https://httpbin.org/ip"
-        let expectation = self.expectation(description: "request should succeed and return ip")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return ip")
+        let expectation2 = self.expectation(description: "request should succeed and return ip")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(statusCode: 200..<300)
             .validate(contentType: ["application/json"])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(statusCode: 200..<300)
+            .validate(contentType: ["application/json"])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithUnacceptableStatusCodeAndContentTypeResponseFailsWithStatusCodeError() {
         // Given
         let urlString = "https://httpbin.org/xml"
-        let expectation = self.expectation(description: "request should succeed and return xml")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return xml")
+        let expectation2 = self.expectation(description: "download should succeed and return xml")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(statusCode: 400..<600)
             .validate(contentType: ["application/octet-stream"])
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(statusCode: 400..<600)
+            .validate(contentType: ["application/octet-stream"])
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError {
-            XCTAssertTrue(error.isUnacceptableStatusCode)
-            XCTAssertEqual(error.responseCode, 200)
-        } else {
-            XCTFail("error should not be nil")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError {
+                XCTAssertTrue(error.isUnacceptableStatusCode)
+                XCTAssertEqual(error.responseCode, 200)
+            } else {
+                XCTFail("error should not be nil")
+            }
         }
     }
 
     func testThatValidationForRequestWithUnacceptableStatusCodeAndContentTypeResponseFailsWithContentTypeError() {
         // Given
         let urlString = "https://httpbin.org/xml"
-        let expectation = self.expectation(description: "request should succeed and return xml")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should succeed and return xml")
+        let expectation2 = self.expectation(description: "download should succeed and return xml")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate(contentType: ["application/octet-stream"])
             .validate(statusCode: 400..<600)
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
-        }
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(contentType: ["application/octet-stream"])
+            .validate(statusCode: 400..<600)
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+            }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError {
-            XCTAssertTrue(error.isUnacceptableContentType)
-            XCTAssertEqual(error.responseContentType, "application/xml")
-            XCTAssertEqual(error.acceptableContentTypes?.first, "application/octet-stream")
-        } else {
-            XCTFail("error should not be nil")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError {
+                XCTAssertTrue(error.isUnacceptableContentType)
+                XCTAssertEqual(error.responseContentType, "application/xml")
+                XCTAssertEqual(error.acceptableContentTypes?.first, "application/octet-stream")
+            } else {
+                XCTFail("error should not be nil")
+            }
         }
     }
 }
@@ -398,49 +593,72 @@ class AutomaticValidationTestCase: BaseTestCase {
         var urlRequest = URLRequest(url: url)
         urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
 
-        let expectation = self.expectation(description: "request should succeed and return ip")
+        let expectation1 = self.expectation(description: "request should succeed and return ip")
+        let expectation2 = self.expectation(description: "download should succeed and return ip")
 
-        var error: Error?
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlRequest)
             .validate()
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlRequest, to: { _, _ in fileURL })
+            .validate()
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithUnacceptableStatusCodeResponseFails() {
         // Given
         let urlString = "https://httpbin.org/status/404"
-        let expectation = self.expectation(description: "request should return 404 status code")
 
-        var error: Error?
+        let expectation1 = self.expectation(description: "request should return 404 status code")
+        let expectation2 = self.expectation(description: "download should return 404 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlString, withMethod: .get)
             .validate()
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate()
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError, let statusCode = error.responseCode {
-            XCTAssertTrue(error.isUnacceptableStatusCode)
-            XCTAssertEqual(statusCode, 404)
-        } else {
-            XCTFail("error should not be nil")
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError, let statusCode = error.responseCode {
+                XCTAssertTrue(error.isUnacceptableStatusCode)
+                XCTAssertEqual(statusCode, 404)
+            } else {
+                XCTFail("error should not be nil")
+            }
         }
     }
 
@@ -450,22 +668,32 @@ class AutomaticValidationTestCase: BaseTestCase {
         var urlRequest = URLRequest(url: url)
         urlRequest.setValue("application/*", forHTTPHeaderField: "Accept")
 
-        let expectation = self.expectation(description: "request should succeed and return ip")
+        let expectation1 = self.expectation(description: "request should succeed and return ip")
+        let expectation2 = self.expectation(description: "download should succeed and return ip")
 
-        var error: Error?
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlRequest)
             .validate()
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlRequest, to: { _, _ in fileURL })
+            .validate()
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithAcceptableComplexContentTypeResponseSucceeds() {
@@ -476,22 +704,32 @@ class AutomaticValidationTestCase: BaseTestCase {
         let headerValue = "text/xml, application/xml, application/xhtml+xml, text/html;q=0.9, text/plain;q=0.8,*/*;q=0.5"
         urlRequest.setValue(headerValue, forHTTPHeaderField: "Accept")
 
-        let expectation = self.expectation(description: "request should succeed and return xml")
+        let expectation1 = self.expectation(description: "request should succeed and return xml")
+        let expectation2 = self.expectation(description: "request should succeed and return xml")
 
-        var error: Error?
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlRequest)
             .validate()
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlRequest, to: { _, _ in fileURL })
+            .validate()
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNil(error)
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
     }
 
     func testThatValidationForRequestWithUnacceptableContentTypeResponseFails() {
@@ -500,29 +738,227 @@ class AutomaticValidationTestCase: BaseTestCase {
         var urlRequest = URLRequest(url: url)
         urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
 
-        let expectation = self.expectation(description: "request should succeed and return xml")
+        let expectation1 = self.expectation(description: "request should succeed and return xml")
+        let expectation2 = self.expectation(description: "download should succeed and return xml")
 
-        var error: Error?
+        var requestError: Error?
+        var downloadError: Error?
 
         // When
         Alamofire.request(urlRequest)
             .validate()
             .response { resp in
-                error = resp.error
-                expectation.fulfill()
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlRequest, to: { _, _ in fileURL })
+            .validate()
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+            }
+
+        waitForExpectations(timeout: timeout, handler: nil)
+
+        // Then
+        XCTAssertNotNil(requestError)
+        XCTAssertNotNil(downloadError)
+
+        for error in [requestError, downloadError] {
+            if let error = error as? AFError {
+                XCTAssertTrue(error.isUnacceptableContentType)
+                XCTAssertEqual(error.responseContentType, "application/xml")
+                XCTAssertEqual(error.acceptableContentTypes?.first, "application/json")
+            } else {
+                XCTFail("error should not be nil")
+            }
+        }
+    }
+}
+
+// MARK: -
+
+private enum ValidationError: Error {
+    case missingData, missingFile, fileReadFailed
+}
+
+extension DataRequest {
+    func validateDataExists() -> Self {
+        return validate { request, response, data in
+            guard data != nil else { return .failure(ValidationError.missingData) }
+            return .success
+        }
+    }
+
+    func validate(with error: Error) -> Self {
+        return validate { _, _, _ in .failure(error) }
+    }
+}
+
+extension DownloadRequest {
+    func validateDataExists() -> Self {
+        return validate { request, response, fileURL in
+            guard let fileURL = fileURL else { return .failure(ValidationError.missingFile) }
+
+            do {
+                let _ = try Data(contentsOf: fileURL)
+                return .success
+            } catch {
+                return .failure(ValidationError.fileReadFailed)
+            }
+        }
+    }
+
+    func validate(with error: Error) -> Self {
+        return validate { _, _, _ in .failure(error) }
+    }
+}
+
+// MARK: -
+
+class CustomValidationTestCase: BaseTestCase {
+    func testThatCustomValidationClosureHasAccessToServerResponseData() {
+        // Given
+        let urlString = "https://httpbin.org/get"
+
+        let expectation1 = self.expectation(description: "request should return 200 status code")
+        let expectation2 = self.expectation(description: "download should return 200 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
+
+        // When
+        Alamofire.request(urlString, withMethod: .get)
+            .validate { request, response, data in
+                guard data != nil else { return .failure(ValidationError.missingData) }
+                return .success
+            }
+            .response { resp in
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate { request, response, fileURL in
+                guard let fileURL = fileURL else { return .failure(ValidationError.missingFile) }
+
+                do {
+                    let _ = try Data(contentsOf: fileURL)
+                    return .success
+                } catch {
+                    return .failure(ValidationError.fileReadFailed)
+                }
+            }
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+            }
+
+        waitForExpectations(timeout: timeout, handler: nil)
+
+        // Then
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
+    }
+
+    func testThatCustomValidationCanThrowCustomError() {
+        // Given
+        let urlString = "https://httpbin.org/get"
+
+        let expectation1 = self.expectation(description: "request should return 200 status code")
+        let expectation2 = self.expectation(description: "download should return 200 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
+
+        // When
+        Alamofire.request(urlString, withMethod: .get)
+            .validate { _, _, _ in .failure(ValidationError.missingData) }
+            .validate { _, _, _ in .failure(ValidationError.missingFile) } // should be ignored
+            .response { resp in
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate { _, _, _ in .failure(ValidationError.missingFile) }
+            .validate { _, _, _ in .failure(ValidationError.fileReadFailed) } // should be ignored
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
             }
 
         waitForExpectations(timeout: timeout, handler: nil)
 
         // Then
-        XCTAssertNotNil(error)
-
-        if let error = error as? AFError {
-            XCTAssertTrue(error.isUnacceptableContentType)
-            XCTAssertEqual(error.responseContentType, "application/xml")
-            XCTAssertEqual(error.acceptableContentTypes?.first, "application/json")
-        } else {
-            XCTFail("error should not be nil")
+        XCTAssertEqual(requestError as? ValidationError, ValidationError.missingData)
+        XCTAssertEqual(downloadError as? ValidationError, ValidationError.missingFile)
+    }
+
+    func testThatValidationExtensionHasAccessToServerResponseData() {
+        // Given
+        let urlString = "https://httpbin.org/get"
+
+        let expectation1 = self.expectation(description: "request should return 200 status code")
+        let expectation2 = self.expectation(description: "download should return 200 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
+
+        // When
+        Alamofire.request(urlString, withMethod: .get)
+            .validateDataExists()
+            .response { resp in
+                requestError = resp.error
+                expectation1.fulfill()
         }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validateDataExists()
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout, handler: nil)
+
+        // Then
+        XCTAssertNil(requestError)
+        XCTAssertNil(downloadError)
+    }
+
+    func testThatValidationExtensionCanThrowCustomError() {
+        // Given
+        let urlString = "https://httpbin.org/get"
+
+        let expectation1 = self.expectation(description: "request should return 200 status code")
+        let expectation2 = self.expectation(description: "download should return 200 status code")
+
+        var requestError: Error?
+        var downloadError: Error?
+
+        // When
+        Alamofire.request(urlString, withMethod: .get)
+            .validate(with: ValidationError.missingData)
+            .validate(with: ValidationError.missingFile) // should be ignored
+            .response { resp in
+                requestError = resp.error
+                expectation1.fulfill()
+            }
+
+        Alamofire.download(urlString, to: { _, _ in fileURL }, withMethod: .get)
+            .validate(with: ValidationError.missingFile)
+            .validate(with: ValidationError.fileReadFailed) // should be ignored
+            .response { resp in
+                downloadError = resp.error
+                expectation2.fulfill()
+            }
+
+        waitForExpectations(timeout: timeout, handler: nil)
+
+        // Then
+        XCTAssertEqual(requestError as? ValidationError, ValidationError.missingData)
+        XCTAssertEqual(downloadError as? ValidationError, ValidationError.missingFile)
     }
 }