Skip to content

Commit 31122ea

Browse files
authored
Add Request/Response History to all public Response types (#817)
Work to close #790 The fact that `HTTPClient.Request` is not Sendable make me think we're going to need to store something else, such as a `URL` and `HTTPRequestHead`, instead?
1 parent 2dbcdf2 commit 31122ea

File tree

7 files changed

+210
-12
lines changed

7 files changed

+210
-12
lines changed

Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,29 @@ extension HTTPClient {
8585
) async throws -> HTTPClientResponse {
8686
var currentRequest = request
8787
var currentRedirectState = redirectState
88+
var history: [HTTPClientRequestResponse] = []
8889

8990
// this loop is there to follow potential redirects
9091
while true {
9192
let preparedRequest = try HTTPClientRequest.Prepared(currentRequest, dnsOverride: configuration.dnsOverride)
92-
let response = try await self.executeCancellable(preparedRequest, deadline: deadline, logger: logger)
93+
let response = try await {
94+
var response = try await self.executeCancellable(preparedRequest, deadline: deadline, logger: logger)
95+
96+
history.append(
97+
.init(
98+
request: currentRequest,
99+
responseHead: .init(
100+
version: response.version,
101+
status: response.status,
102+
headers: response.headers
103+
)
104+
)
105+
)
106+
107+
response.history = history
108+
109+
return response
110+
}()
93111

94112
guard var redirectState = currentRedirectState else {
95113
// a `nil` redirectState means we should not follow redirects

Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import NIOCore
1616
import NIOHTTP1
1717

18+
import struct Foundation.URL
19+
1820
/// A representation of an HTTP response for the Swift Concurrency HTTPClient API.
1921
///
2022
/// This object is similar to ``HTTPClient/Response``, but used for the Swift Concurrency API.
@@ -32,6 +34,18 @@ public struct HTTPClientResponse: Sendable {
3234
/// The body of this HTTP response.
3335
public var body: Body
3436

37+
/// The history of all requests and responses in redirect order.
38+
public var history: [HTTPClientRequestResponse]
39+
40+
/// The target URL (after redirects) of the response.
41+
public var url: URL? {
42+
guard let lastRequestURL = self.history.last?.request.url else {
43+
return nil
44+
}
45+
46+
return URL(string: lastRequestURL)
47+
}
48+
3549
@inlinable public init(
3650
version: HTTPVersion = .http1_1,
3751
status: HTTPResponseStatus = .ok,
@@ -42,14 +56,30 @@ public struct HTTPClientResponse: Sendable {
4256
self.status = status
4357
self.headers = headers
4458
self.body = body
59+
self.history = []
60+
}
61+
62+
@inlinable public init(
63+
version: HTTPVersion = .http1_1,
64+
status: HTTPResponseStatus = .ok,
65+
headers: HTTPHeaders = [:],
66+
body: Body = Body(),
67+
history: [HTTPClientRequestResponse] = []
68+
) {
69+
self.version = version
70+
self.status = status
71+
self.headers = headers
72+
self.body = body
73+
self.history = history
4574
}
4675

4776
init(
4877
requestMethod: HTTPMethod,
4978
version: HTTPVersion,
5079
status: HTTPResponseStatus,
5180
headers: HTTPHeaders,
52-
body: TransactionBody
81+
body: TransactionBody,
82+
history: [HTTPClientRequestResponse]
5383
) {
5484
self.init(
5585
version: version,
@@ -64,11 +94,23 @@ public struct HTTPClientResponse: Sendable {
6494
status: status
6595
)
6696
)
67-
)
97+
),
98+
history: history
6899
)
69100
}
70101
}
71102

103+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
104+
public struct HTTPClientRequestResponse: Sendable {
105+
public var request: HTTPClientRequest
106+
public var responseHead: HTTPResponseHead
107+
108+
public init(request: HTTPClientRequest, responseHead: HTTPResponseHead) {
109+
self.request = request
110+
self.responseHead = responseHead
111+
}
112+
}
113+
72114
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
73115
extension HTTPClientResponse {
74116
/// A representation of the response body for an HTTP response.

Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ extension Transaction: HTTPExecutableRequest {
242242
version: head.version,
243243
status: head.status,
244244
headers: head.headers,
245-
body: body
245+
body: body,
246+
history: []
246247
)
247248
continuation.resume(returning: response)
248249
}

Sources/AsyncHTTPClient/FileDownloadDelegate.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,25 @@ import NIOCore
1616
import NIOHTTP1
1717
import NIOPosix
1818

19+
import struct Foundation.URL
20+
1921
/// Handles a streaming download to a given file path, allowing headers and progress to be reported.
2022
public final class FileDownloadDelegate: HTTPClientResponseDelegate {
2123
/// The response type for this delegate: the total count of bytes as reported by the response
22-
/// "Content-Length" header (if available), the count of bytes downloaded, and the
23-
/// response head.
24+
/// "Content-Length" header (if available), the count of bytes downloaded, the
25+
/// response head, and a history of requests and responses.
2426
public struct Progress: Sendable {
2527
public var totalBytes: Int?
2628
public var receivedBytes: Int
2729

30+
/// The history of all requests and responses in redirect order.
31+
public var history: [HTTPClient.RequestResponse] = []
32+
33+
/// The target URL (after redirects) of the response.
34+
public var url: URL? {
35+
self.history.last?.request.url
36+
}
37+
2838
public var head: HTTPResponseHead {
2939
get {
3040
assert(self._head != nil)
@@ -150,6 +160,10 @@ public final class FileDownloadDelegate: HTTPClientResponseDelegate {
150160
)
151161
}
152162

163+
public func didVisitURL(task: HTTPClient.Task<Progress>, _ request: HTTPClient.Request, _ head: HTTPResponseHead) {
164+
self.progress.history.append(.init(request: request, responseHead: head))
165+
}
166+
153167
public func didReceiveHead(
154168
task: HTTPClient.Task<Response>,
155169
_ head: HTTPResponseHead

Sources/AsyncHTTPClient/HTTPHandler.swift

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,22 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import Algorithms
16-
import Foundation
1716
import Logging
1817
import NIOConcurrencyHelpers
1918
import NIOCore
2019
import NIOHTTP1
2120
import NIOPosix
2221
import NIOSSL
2322

23+
#if compiler(>=6.0)
24+
import Foundation
25+
#else
26+
@preconcurrency import Foundation
27+
#endif
28+
2429
extension HTTPClient {
2530
/// A request body.
26-
public struct Body {
31+
public struct Body: Sendable {
2732
/// A streaming uploader.
2833
///
2934
/// ``StreamWriter`` abstracts
@@ -209,7 +214,7 @@ extension HTTPClient {
209214
}
210215

211216
/// Represents an HTTP request.
212-
public struct Request {
217+
public struct Request: Sendable {
213218
/// Request HTTP method, defaults to `GET`.
214219
public let method: HTTPMethod
215220
/// Remote URL.
@@ -377,6 +382,13 @@ extension HTTPClient {
377382
public var headers: HTTPHeaders
378383
/// Response body.
379384
public var body: ByteBuffer?
385+
/// The history of all requests and responses in redirect order.
386+
public var history: [RequestResponse]
387+
388+
/// The target URL (after redirects) of the response.
389+
public var url: URL? {
390+
self.history.last?.request.url
391+
}
380392

381393
/// Create HTTP `Response`.
382394
///
@@ -392,6 +404,7 @@ extension HTTPClient {
392404
self.version = HTTPVersion(major: 1, minor: 1)
393405
self.headers = headers
394406
self.body = body
407+
self.history = []
395408
}
396409

397410
/// Create HTTP `Response`.
@@ -414,6 +427,32 @@ extension HTTPClient {
414427
self.version = version
415428
self.headers = headers
416429
self.body = body
430+
self.history = []
431+
}
432+
433+
/// Create HTTP `Response`.
434+
///
435+
/// - parameters:
436+
/// - host: Remote host of the request.
437+
/// - status: Response HTTP status.
438+
/// - version: Response HTTP version.
439+
/// - headers: Reponse HTTP headers.
440+
/// - body: Response body.
441+
/// - history: History of all requests and responses in redirect order.
442+
public init(
443+
host: String,
444+
status: HTTPResponseStatus,
445+
version: HTTPVersion,
446+
headers: HTTPHeaders,
447+
body: ByteBuffer?,
448+
history: [RequestResponse]
449+
) {
450+
self.host = host
451+
self.status = status
452+
self.version = version
453+
self.headers = headers
454+
self.body = body
455+
self.history = history
417456
}
418457
}
419458

@@ -457,6 +496,16 @@ extension HTTPClient {
457496
}
458497
}
459498
}
499+
500+
public struct RequestResponse: Sendable {
501+
public var request: Request
502+
public var responseHead: HTTPResponseHead
503+
504+
public init(request: Request, responseHead: HTTPResponseHead) {
505+
self.request = request
506+
self.responseHead = responseHead
507+
}
508+
}
460509
}
461510

462511
/// The default ``HTTPClientResponseDelegate``.
@@ -485,6 +534,7 @@ public final class ResponseAccumulator: HTTPClientResponseDelegate {
485534
}
486535
}
487536

537+
var history = [HTTPClient.RequestResponse]()
488538
var state = State.idle
489539
let requestMethod: HTTPMethod
490540
let requestHost: String
@@ -521,6 +571,14 @@ public final class ResponseAccumulator: HTTPClientResponseDelegate {
521571
self.maxBodySize = maxBodySize
522572
}
523573

574+
public func didVisitURL(
575+
task: HTTPClient.Task<HTTPClient.Response>,
576+
_ request: HTTPClient.Request,
577+
_ head: HTTPResponseHead
578+
) {
579+
self.history.append(.init(request: request, responseHead: head))
580+
}
581+
524582
public func didReceiveHead(task: HTTPClient.Task<Response>, _ head: HTTPResponseHead) -> EventLoopFuture<Void> {
525583
switch self.state {
526584
case .idle:
@@ -596,15 +654,17 @@ public final class ResponseAccumulator: HTTPClientResponseDelegate {
596654
status: head.status,
597655
version: head.version,
598656
headers: head.headers,
599-
body: nil
657+
body: nil,
658+
history: self.history
600659
)
601660
case .body(let head, let body):
602661
return Response(
603662
host: self.requestHost,
604663
status: head.status,
605664
version: head.version,
606665
headers: head.headers,
607-
body: body
666+
body: body,
667+
history: self.history
608668
)
609669
case .end:
610670
preconditionFailure("request already processed")

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
7676
return
7777
}
7878

79+
XCTAssertEqual(response.url?.absoluteString, request.url)
80+
XCTAssertEqual(response.history.map(\.request.url), [request.url])
7981
XCTAssertEqual(response.status, .ok)
8082
XCTAssertEqual(response.version, .http2)
8183
}
@@ -98,6 +100,8 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
98100
return
99101
}
100102

103+
XCTAssertEqual(response.url?.absoluteString, request.url)
104+
XCTAssertEqual(response.history.map(\.request.url), [request.url])
101105
XCTAssertEqual(response.status, .ok)
102106
XCTAssertEqual(response.version, .http2)
103107
}
@@ -734,9 +738,10 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
734738
defer { XCTAssertNoThrow(try client.syncShutdown()) }
735739
let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:))
736740
var request = HTTPClientRequest(url: "https://127.0.0.1:\(bin.port)/redirect/target")
741+
let redirectURL = "https://localhost:\(bin.port)/echohostheader"
737742
request.headers.replaceOrAdd(
738743
name: "X-Target-Redirect-URL",
739-
value: "https://localhost:\(bin.port)/echohostheader"
744+
value: redirectURL
740745
)
741746

742747
guard
@@ -753,6 +758,8 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
753758
XCTAssertNoThrow(maybeRequestInfo = try JSONDecoder().decode(RequestInfo.self, from: body))
754759
guard let requestInfo = maybeRequestInfo else { return }
755760

761+
XCTAssertEqual(response.url?.absoluteString, redirectURL)
762+
XCTAssertEqual(response.history.map(\.request.url), [request.url, redirectURL])
756763
XCTAssertEqual(response.status, .ok)
757764
XCTAssertEqual(response.version, .http2)
758765
XCTAssertEqual(requestInfo.data, "localhost:\(bin.port)")

0 commit comments

Comments
 (0)