Browse Source

Lifetime Event APIs (#3219)

* Add additional lifetime closures.

* Update formatting.

* Add documentation on the new lifetime methods.
Jon Shier 5 years ago
parent
commit
cc183c78ab
3 changed files with 187 additions and 15 deletions
  1. 35 8
      Documentation/AdvancedUsage.md
  2. 92 6
      Source/Request.swift
  3. 60 1
      Tests/RequestTests.swift

+ 35 - 8
Documentation/AdvancedUsage.md

@@ -309,11 +309,11 @@ AF.request(...)
 Importantly, not all `Request` subclasses are able to report their progress accurately, or may have other dependencies to do so.
 
 - For upload progress, progress can be determined in the following ways:
-	- By the length of the `Data` object provided as the upload body to an `UploadRequest`.
-	- By the length of a file on disk provided as the upload body of an `UploadRequest`.
-	- By the value of the `Content-Length` header on the request, if it has been manually set.
+    - By the length of the `Data` object provided as the upload body to an `UploadRequest`.
+    - By the length of a file on disk provided as the upload body of an `UploadRequest`.
+    - By the value of the `Content-Length` header on the request, if it has been manually set.
 - For download progress, there is a single requirement:
-	- The server response must contain a `Content-Length` header.
+    - The server response must contain a `Content-Length` header.
 Unfortunately there may be other, undocumented requirements for progress reporting from `URLSession` which prevents accurate progress reporting.
 
 #### Handling Redirects
@@ -370,11 +370,38 @@ AF.request(...)
     }
 ```
 
-#### A `Request`’s `URLRequest`s
+#### Lifetime Values
+Alamofire creates a variety of underlying values throughout the lifetime of a `Request`. Most of these are internal implementation details, but the creation of `URLRequest`s and `URLSessionTask`s are exposed to allow for direct interaction with other APIs.
+
+##### A `Request`’s `URLRequest`s
 Each network request issued by a `Request` is ultimately encapsulated in a `URLRequest` value created from the various parameters passed to one of the `Session` request methods. `Request` will keep a copy of these `URLRequest`s in its `requests` array property. These values include both the initial `URLRequest` created from the passed parameters, as well any `URLRequest`s created by `RequestInterceptor`s. That array does not, however, include the `URLRequest`s performed by the `URLSessionTask`s issued on behalf of the `Request`. To inspect those values, the `tasks` property gives access to all of the `URLSessionTasks` performed by the `Request`.
 
-#### `URLSessionTask`s
-In many ways, the various `Request` subclasses act as a wrapper for a `URLSessionTask`, presenting particular API for interacting with particular types of tasks. These tasks are made visible on the `Request` instance through the `tasks` array property. This includes both the initial task created for the `Request`, as well as any subsequent tasks created as part of the retry process, with one task per retry.
+In addition to accumulating these values, every `Request` has an `onURLRequestCreation` method which calls a closure whenever a `URLRequest` is created for the `Request`. This `URLRequest` is the product of the initial parameters passed to the `Session`'s `request` method, as well as changes applied by any `RequestInterceptor`s. It will be called multiple times if the `Request` is retried and only one closure can be set at a time. `URLRequest` values cannot be modified in this closure; if you need to modify `URLRequest`s before they're issued, use a `RequestInterceptor` or compose your requests using the `URLRequestConvertible` protocol before passing them to Alamofire.
+
+```swift
+AF.request(...)
+    .onURLRequestCreation { request in
+        print(request)
+    }
+    .responseDecodable(of: SomeType.self) { response in
+        debugPrint(response)
+    }
+```
+
+##### `URLSessionTask`s
+In many ways, the various `Request` subclasses act as a wrapper for a `URLSessionTask` and present specific API for interacting with the different types of tasks. These tasks are made visible on the `Request` instance through the `tasks` array property. This includes both the initial task created for the `Request`, as well as any subsequent tasks created as part of the retry process, with one task per retry.
+
+In addition to accumulating these values, every `Request` has an `onURLSessionTaskCreation` method which calls a closure whenever a `URLSessionTask` is created for the `Request`. This clsoure will be called multiple times if the `Request` is retried and only one closure can be set at a time. The provided `URLSessionTask` *SHOULD **NOT*** be used to interact with the `task`'s lifetime, which should only be done by the `Request` itself. Instead, you can use this method to provide the `Request`'s active `task` to other APIs, like `NSFileProvider`. 
+
+```swift
+AF.request(...)
+    .onURLSessionTaskCreation { task in
+        print(task)
+    }
+    .responseDecodable(of: SomeType.self) { response in
+        debugPrint(response)
+    }
+```
 
 #### Response
 Each `Request` may have an `HTTPURLResponse` value available once the request is complete. This value is only available if the request wasn’t cancelled and didn’t fail to make the network request. Additionally, if the request is retried, only the *last* response is available. Intermediate responses can be derived from the `URLSessionTask`s in the `tasks` property.
@@ -391,7 +418,7 @@ AF.request(...)
     }
 ```
 
-> Due to `FB7624529`, collection of `URLSessionTaskMetrics` on watchOS is currently disabled.
+> Due to `FB7624529`, collection of `URLSessionTaskMetrics` on watchOS < 7 is currently disabled.
 
 ### `DataRequest`
 `DataRequest` is a subclass of `Request` which encapsulates a `URLSessionDataTask` downloading a server response into `Data` stored in memory. Therefore, it’s important to realize that extremely large downloads may adversely affect system performance. For those types of downloads, using `DownloadRequest` to save the data to disk is recommended.

+ 92 - 6
Source/Request.swift

@@ -92,8 +92,12 @@ public class Request {
         var redirectHandler: RedirectHandler?
         /// `CachedResponseHandler` provided to handle response caching.
         var cachedResponseHandler: CachedResponseHandler?
-        /// Closure called when the `Request` is able to create a cURL description of itself.
-        var cURLHandler: ((String) -> Void)?
+        /// Queue and closure called when the `Request` is able to create a cURL description of itself.
+        var cURLHandler: (queue: DispatchQueue, handler: (String) -> Void)?
+        /// Queue and closure called when the `Request` creates a `URLRequest`.
+        var urlRequestHandler: (queue: DispatchQueue, handler: (URLRequest) -> Void)?
+        /// Queue and closure called when the `Request` creates a `URLSessionTask`.
+        var urlSessionTaskHandler: (queue: DispatchQueue, handler: (URLSessionTask) -> Void)?
         /// Response serialization closures that handle response parsing.
         var responseSerializers: [() -> Void] = []
         /// Response serialization completion closures executed once all response serializers are complete.
@@ -337,6 +341,10 @@ public class Request {
     func didCreateURLRequest(_ request: URLRequest) {
         dispatchPrecondition(condition: .onQueue(underlyingQueue))
 
+        $mutableState.read { state in
+            state.urlRequestHandler?.queue.async { state.urlRequestHandler?.handler(request) }
+        }
+
         eventMonitor?.request(self, didCreateURLRequest: request)
 
         callCURLHandlerIfNecessary()
@@ -347,7 +355,8 @@ public class Request {
         $mutableState.write { mutableState in
             guard let cURLHandler = mutableState.cURLHandler else { return }
 
-            self.underlyingQueue.async { cURLHandler(self.cURLDescription()) }
+            cURLHandler.queue.async { cURLHandler.handler(self.cURLDescription()) }
+
             mutableState.cURLHandler = nil
         }
     }
@@ -358,7 +367,13 @@ public class Request {
     func didCreateTask(_ task: URLSessionTask) {
         dispatchPrecondition(condition: .onQueue(underlyingQueue))
 
-        $mutableState.write { $0.tasks.append(task) }
+        $mutableState.write { state in
+            state.tasks.append(task)
+
+            guard let urlSessionTaskHandler = state.urlSessionTaskHandler else { return }
+
+            urlSessionTaskHandler.queue.async { urlSessionTaskHandler.handler(task) }
+        }
 
         eventMonitor?.request(self, didCreateTask: task)
     }
@@ -812,11 +827,36 @@ public class Request {
         return self
     }
 
+    // MARK: - Lifetime APIs
+
     /// Sets a handler to be called when the cURL description of the request is available.
     ///
     /// - Note: When waiting for a `Request`'s `URLRequest` to be created, only the last `handler` will be called.
     ///
-    /// - Parameter handler: Closure to be called when the cURL description is available.
+    /// - Parameters:
+    ///   - queue:   `DispatchQueue` on which `handler` will be called.
+    ///   - handler: Closure to be called when the cURL description is available.
+    ///
+    /// - Returns:           The instance.
+    @discardableResult
+    public func cURLDescription(on queue: DispatchQueue, calling handler: @escaping (String) -> Void) -> Self {
+        $mutableState.write { mutableState in
+            if mutableState.requests.last != nil {
+                queue.async { handler(self.cURLDescription()) }
+            } else {
+                mutableState.cURLHandler = (queue, handler)
+            }
+        }
+
+        return self
+    }
+
+    /// Sets a handler to be called when the cURL description of the request is available.
+    ///
+    /// - Note: When waiting for a `Request`'s `URLRequest` to be created, only the last `handler` will be called.
+    ///
+    /// - Parameter handler: Closure to be called when the cURL description is available. Called on the instance's
+    ///                      `underlyingQueue` by default.
     ///
     /// - Returns:           The instance.
     @discardableResult
@@ -825,13 +865,59 @@ public class Request {
             if mutableState.requests.last != nil {
                 underlyingQueue.async { handler(self.cURLDescription()) }
             } else {
-                mutableState.cURLHandler = handler
+                mutableState.cURLHandler = (underlyingQueue, handler)
             }
         }
 
         return self
     }
 
+    /// Sets a closure to called whenever Alamofire creates a `URLRequest` for this instance.
+    ///
+    /// - Note: This closure will be called multiple times if the instance adapts incoming `URLRequest`s or is retried.
+    ///
+    /// - Parameters:
+    ///   - queue:   `DispatchQueue` on which `handler` will be called. `.main` by default.
+    ///   - handler: Closure to be called when a `URLRequest` is available.
+    ///
+    /// - Returns:   The instance.
+    @discardableResult
+    public func onURLRequestCreation(on queue: DispatchQueue = .main, perform handler: @escaping (URLRequest) -> Void) -> Self {
+        $mutableState.write { state in
+            if let request = state.requests.last {
+                queue.async { handler(request) }
+            }
+
+            state.urlRequestHandler = (queue, handler)
+        }
+
+        return self
+    }
+
+    /// Sets a closure to be called whenever the instance creates a `URLSessionTask`.
+    ///
+    /// - Note: This API should only be used to provide `URLSessionTask`s to existing API, like `NSFileProvider`. It
+    ///         **SHOULD NOT** be used to interact with tasks directly, as that may be break Alamofire features.
+    ///         Additionally, this closure may be called multiple times if the instance is retried.
+    ///
+    /// - Parameters:
+    ///   - queue:   `DispatchQueue` on which `handler` will be called. `.main` by default.
+    ///   - handler: Closure to be called when the `URLSessionTask` is available.
+    ///
+    /// - Returns:   The instance.
+    @discardableResult
+    public func onURLSessionTaskCreation(on queue: DispatchQueue = .main, perform handler: @escaping (URLSessionTask) -> Void) -> Self {
+        $mutableState.write { state in
+            if let task = state.tasks.last {
+                queue.async { handler(task) }
+            }
+
+            state.urlSessionTaskHandler = (queue, handler)
+        }
+
+        return self
+    }
+
     // MARK: Cleanup
 
     /// Final cleanup step executed when the instance finishes response serialization.

+ 60 - 1
Tests/RequestTests.swift

@@ -1,7 +1,7 @@
 //
 //  RequestTests.swift
 //
-//  Copyright (c) 2014-2018 Alamofire Software Foundation (http://alamofire.org/)
+//  Copyright (c) 2014-2020 Alamofire Software Foundation (http://alamofire.org/)
 //
 //  Permission is hereby granted, free of charge, to any person obtaining a copy
 //  of this software and associated documentation files (the "Software"), to deal
@@ -861,6 +861,29 @@ final class RequestCURLDescriptionTestCase: BaseTestCase {
         XCTAssertEqual(components?.last, "\"\(urlString)\"")
     }
 
+    func testGETRequestCURLDescriptionOnMainQueue() {
+        // Given
+        let url = URL.makeHTTPBinURL()
+        let expectation = self.expectation(description: "request should complete")
+        var isMainThread = false
+        var components: [String]?
+
+        // When
+        manager.request(url).cURLDescription(on: .main) {
+            components = self.cURLCommandComponents(from: $0)
+            isMainThread = Thread.isMainThread
+            expectation.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout, handler: nil)
+
+        // Then
+        XCTAssertTrue(isMainThread)
+        XCTAssertEqual(components?[0..<3], ["$", "curl", "-v"])
+        XCTAssertTrue(components?.contains("-X") == true)
+        XCTAssertEqual(components?.last, "\"\(url)\"")
+    }
+
     func testGETRequestCURLDescriptionSynchronous() {
         // Given
         let urlString = "https://httpbin.org/get"
@@ -1122,3 +1145,39 @@ final class RequestCURLDescriptionTestCase: BaseTestCase {
             .filter { $0 != "" && $0 != "\\" }
     }
 }
+
+final class RequestLifetimeTests: BaseTestCase {
+    func testThatRequestProvidesURLRequestWhenCreated() {
+        // Given
+        let didReceiveRequest = expectation(description: "did receive task")
+        let didComplete = expectation(description: "request did complete")
+        var request: URLRequest?
+
+        // When
+        AF.request(URLRequest.makeHTTPBinRequest())
+            .onURLRequestCreation { request = $0; didReceiveRequest.fulfill() }
+            .responseDecodable(of: HTTPBinResponse.self) { _ in didComplete.fulfill() }
+
+        wait(for: [didReceiveRequest, didComplete], timeout: timeout, enforceOrder: true)
+
+        // Then
+        XCTAssertNotNil(request)
+    }
+
+    func testThatRequestProvidesTaskWhenCreated() {
+        // Given
+        let didReceiveTask = expectation(description: "did receive task")
+        let didComplete = expectation(description: "request did complete")
+        var task: URLSessionTask?
+
+        // When
+        AF.request(URLRequest.makeHTTPBinRequest())
+            .onURLSessionTaskCreation { task = $0; didReceiveTask.fulfill() }
+            .responseDecodable(of: HTTPBinResponse.self) { _ in didComplete.fulfill() }
+
+        wait(for: [didReceiveTask, didComplete], timeout: timeout, enforceOrder: true)
+
+        // Then
+        XCTAssertNotNil(task)
+    }
+}