Просмотр исходного кода

Validation now exposes response data or destinationURL and supports subclasses.

Previously, the Validation closure only exposed the optional request and response parameters making it difficult to customize validations in inline closures that were not custom extensions in the `Request` class. You didn’t have access to the actual response data. This was limiting in certain cases where you want to create a custom validation error that includes the error message buried in the server data. The only way you could do this previously was to write an extension on `Request`. Now you can do it in an inline closure right at the callsight.

Another limitation was that the `Validation` closure did not expose the `destinationURL` for a `DownloadRequest` incase you needed to inspect the data in some special scenario. There is now a custom `Validation` closure on `DownloadRequest` that exposes the `destinationURL` directly. This allows you to inspect the file right in an inline closure without having to extension `DownloadRequest` directly.
Christian Noon 9 лет назад
Родитель
Сommit
d79de7db02
2 измененных файлов с 772 добавлено и 213 удалено
  1. 192 69
      Source/Validation.swift
  2. 580 144
      Tests/ValidationTests.swift

+ 192 - 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,117 @@ 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
+
+        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: "/")
+            }()
+
+            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.type == "*" && mimeType.subtype == "*" {
+                    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 +164,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 +175,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 +183,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)
+        }
+    }
+
+    /// 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)
         }
     }
 
-    // MARK: - Content-Type
+    /// 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)
+    }
+}
 
-    private struct MIMEType {
-        let type: String
-        let subtype: String
+// MARK: -
 
-        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: "/")
-            }()
+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
 
-            if let type = components.first, let subtype = components.last {
-                self.type = type
-                self.subtype = subtype
-            } else {
-                return nil
+    /// 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 +269,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 +291,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)
     }
 }

+ 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)
     }
 }