Skip to content

Commit bb9f2d5

Browse files
xbhatnagfabianfett
andauthored
Add Trailer Read/Write Test (#129)
Read: Makes a request to `/trailers` endpoint and verifies that the trailers are read correctly by the client. Write: Makes a request `/request` endpoint with trailers and verifies that the trailers are echoed back by the server. Passes for AHC. Disabled for URLSession. --------- Co-authored-by: Fabian Fett <fabianfett@apple.com>
1 parent e8e90dd commit bb9f2d5

3 files changed

Lines changed: 96 additions & 3 deletions

File tree

Sources/HTTPClientConformance/HTTPClientConformance.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ public enum ConformanceTestCase: Sendable, Hashable, CaseIterable {
6666
case testEmptyChunkedBody
6767
case testURLParams
6868
case testETag
69+
case testTrailerRead
70+
case testTrailerWrite
6971
}
7072

7173
// Runs an HTTP client through all the conformance tests,
@@ -147,6 +149,8 @@ struct ConformanceTestSuite<Client: HTTPClient & ~Copyable> {
147149
case .testEmptyChunkedBody: try await testEmptyChunkedBody()
148150
case .testURLParams: try await testURLParams()
149151
case .testETag: try await testETag()
152+
case .testTrailerRead: try await testTrailerRead()
153+
case .testTrailerWrite: try await testTrailerWrite()
150154
}
151155
}
152156

@@ -1140,4 +1144,64 @@ struct ConformanceTestSuite<Client: HTTPClient & ~Copyable> {
11401144
)
11411145
}
11421146
}
1147+
1148+
func testTrailerRead() async throws {
1149+
let client = try await clientFactory()
1150+
let request = HTTPRequest(
1151+
method: .get,
1152+
scheme: "http",
1153+
authority: "127.0.0.1:\(testServerPort)",
1154+
path: "/trailers"
1155+
)
1156+
try await client.perform(
1157+
request: request
1158+
) { response, responseBodyAndTrailers in
1159+
#expect(response.status == .ok)
1160+
let (body, trailers) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
1161+
return String(copying: try UTF8Span(validating: span))
1162+
}
1163+
1164+
// Verify the body
1165+
#expect(body == "Response body")
1166+
1167+
// Verify that trailers were received
1168+
#expect(trailers != nil)
1169+
1170+
// Verify the custom trailer headers
1171+
#expect(trailers?[.init("X-Trailer-One")!] == "first-value")
1172+
#expect(trailers?[.init("X-Trailer-Two")!] == "second-value")
1173+
#expect(trailers?[.init("X-Checksum")!] == "abc123")
1174+
}
1175+
}
1176+
1177+
func testTrailerWrite() async throws {
1178+
let client = try await clientFactory()
1179+
let request = HTTPRequest(
1180+
method: .post,
1181+
scheme: "http",
1182+
authority: "127.0.0.1:\(testServerPort)",
1183+
path: "/request"
1184+
)
1185+
try await client.perform(
1186+
request: request,
1187+
body: .restartable { writer in
1188+
var writer = writer
1189+
try await writer.write("Hello World".utf8.span)
1190+
return [
1191+
.init("X-Request-Trailer-One")!: "first-trailer-value",
1192+
.init("X-Request-Trailer-Two")!: "second-trailer-value",
1193+
]
1194+
}
1195+
) { response, responseBodyAndTrailers in
1196+
#expect(response.status == .ok)
1197+
let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
1198+
let body = String(copying: try UTF8Span(validating: span))
1199+
let data = body.data(using: .utf8)!
1200+
return try JSONDecoder().decode(JSONHTTPRequest.self, from: data)
1201+
}
1202+
#expect(jsonRequest.body == "Hello World")
1203+
#expect(jsonRequest.trailers["X-Request-Trailer-One"] == ["first-trailer-value"])
1204+
#expect(jsonRequest.trailers["X-Request-Trailer-Two"] == ["second-trailer-value"])
1205+
}
1206+
}
11431207
}

Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ struct JSONHTTPRequest: Codable {
3232

3333
// Method of the request
3434
let method: String
35+
36+
// Trailers from the request
37+
let trailers: [String: [String]]
3538
}
3639

3740
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
@@ -110,15 +113,23 @@ func serve(server: NIOHTTPServer) async throws {
110113
headers[field.name.rawName, default: []].append(field.value)
111114
}
112115

113-
// Parse the body as a UTF8 string
114-
let (body, _) = try await requestBodyAndTrailers.collect(upTo: 1024) { span in
116+
// Parse the body as a UTF8 string and capture trailers
117+
let (body, requestTrailers) = try await requestBodyAndTrailers.collect(upTo: 1024) { span in
115118
return String(copying: try UTF8Span(validating: span))
116119
}
117120

121+
// Collect the trailers that were sent in with the request
122+
var trailers: [String: [String]] = [:]
123+
if let requestTrailers {
124+
for field in requestTrailers {
125+
trailers[field.name.rawName, default: []].append(field.value)
126+
}
127+
}
128+
118129
let method = request.method.rawValue
119130

120131
// Construct the JSON request object and send it as a response
121-
let response = JSONHTTPRequest(params: params, headers: headers, body: body, method: method)
132+
let response = JSONHTTPRequest(params: params, headers: headers, body: body, method: method, trailers: trailers)
122133

123134
let responseData = try JSONEncoder().encode(response)
124135
let responseSpan = responseData.span
@@ -407,6 +418,20 @@ func serve(server: NIOHTTPServer) async throws {
407418
let data = serverETag.data(using: .ascii)!
408419
try await responseBodyAndTrailers.writeAndConclude(data.span, finalElement: nil)
409420
}
421+
case "/trailers":
422+
// Send a response with custom trailers
423+
let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok))
424+
try await responseBodyAndTrailers.produceAndConclude { responseBody in
425+
var responseBody = responseBody
426+
// Write the body
427+
try await responseBody.write("Response body".utf8.span)
428+
// Return custom trailers
429+
return [
430+
.init("X-Trailer-One")!: "first-value",
431+
.init("X-Trailer-Two")!: "second-value",
432+
.init("X-Checksum")!: "abc123",
433+
]
434+
}
410435
default:
411436
let writer = try await responseSender.send(HTTPResponse(status: .internalServerError))
412437
try await writer.writeAndConclude("Unknown path".utf8.span, finalElement: nil)

Tests/HTTPClientTests/DarwinHTTPClientTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ struct DarwinHTTPClientTests {
4141

4242
// TODO: Writing just an empty span causes an indefinite stall. The terminating chunk (size 0) is not written out on the wire.
4343
.testEmptyChunkedBody,
44+
45+
// TODO: Trailers are not supported by URLSession
46+
.testTrailerRead,
47+
.testTrailerWrite,
4448
]) {
4549
return DefaultHTTPClient.shared
4650
}

0 commit comments

Comments
 (0)