diff --git a/Sources/HTTPClientConformance/HTTPClientConformance.swift b/Sources/HTTPClientConformance/HTTPClientConformance.swift index 6ba2cc0..d8e4f39 100644 --- a/Sources/HTTPClientConformance/HTTPClientConformance.swift +++ b/Sources/HTTPClientConformance/HTTPClientConformance.swift @@ -66,6 +66,8 @@ public enum ConformanceTestCase: Sendable, Hashable, CaseIterable { case testEmptyChunkedBody case testURLParams case testETag + case testTrailerRead + case testTrailerWrite } // Runs an HTTP client through all the conformance tests, @@ -147,6 +149,8 @@ struct ConformanceTestSuite { case .testEmptyChunkedBody: try await testEmptyChunkedBody() case .testURLParams: try await testURLParams() case .testETag: try await testETag() + case .testTrailerRead: try await testTrailerRead() + case .testTrailerWrite: try await testTrailerWrite() } } @@ -1140,4 +1144,64 @@ struct ConformanceTestSuite { ) } } + + func testTrailerRead() async throws { + let client = try await clientFactory() + let request = HTTPRequest( + method: .get, + scheme: "http", + authority: "127.0.0.1:\(testServerPort)", + path: "/trailers" + ) + try await client.perform( + request: request + ) { response, responseBodyAndTrailers in + #expect(response.status == .ok) + let (body, trailers) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in + return String(copying: try UTF8Span(validating: span)) + } + + // Verify the body + #expect(body == "Response body") + + // Verify that trailers were received + #expect(trailers != nil) + + // Verify the custom trailer headers + #expect(trailers?[.init("X-Trailer-One")!] == "first-value") + #expect(trailers?[.init("X-Trailer-Two")!] == "second-value") + #expect(trailers?[.init("X-Checksum")!] == "abc123") + } + } + + func testTrailerWrite() async throws { + let client = try await clientFactory() + let request = HTTPRequest( + method: .post, + scheme: "http", + authority: "127.0.0.1:\(testServerPort)", + path: "/request" + ) + try await client.perform( + request: request, + body: .restartable { writer in + var writer = writer + try await writer.write("Hello World".utf8.span) + return [ + .init("X-Request-Trailer-One")!: "first-trailer-value", + .init("X-Request-Trailer-Two")!: "second-trailer-value", + ] + } + ) { response, responseBodyAndTrailers in + #expect(response.status == .ok) + let (jsonRequest, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in + let body = String(copying: try UTF8Span(validating: span)) + let data = body.data(using: .utf8)! + return try JSONDecoder().decode(JSONHTTPRequest.self, from: data) + } + #expect(jsonRequest.body == "Hello World") + #expect(jsonRequest.trailers["X-Request-Trailer-One"] == ["first-trailer-value"]) + #expect(jsonRequest.trailers["X-Request-Trailer-Two"] == ["second-trailer-value"]) + } + } } diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index 4bc4e79..55bf699 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -32,6 +32,9 @@ struct JSONHTTPRequest: Codable { // Method of the request let method: String + + // Trailers from the request + let trailers: [String: [String]] } @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 { headers[field.name.rawName, default: []].append(field.value) } - // Parse the body as a UTF8 string - let (body, _) = try await requestBodyAndTrailers.collect(upTo: 1024) { span in + // Parse the body as a UTF8 string and capture trailers + let (body, requestTrailers) = try await requestBodyAndTrailers.collect(upTo: 1024) { span in return String(copying: try UTF8Span(validating: span)) } + // Collect the trailers that were sent in with the request + var trailers: [String: [String]] = [:] + if let requestTrailers { + for field in requestTrailers { + trailers[field.name.rawName, default: []].append(field.value) + } + } + let method = request.method.rawValue // Construct the JSON request object and send it as a response - let response = JSONHTTPRequest(params: params, headers: headers, body: body, method: method) + let response = JSONHTTPRequest(params: params, headers: headers, body: body, method: method, trailers: trailers) let responseData = try JSONEncoder().encode(response) let responseSpan = responseData.span @@ -407,6 +418,20 @@ func serve(server: NIOHTTPServer) async throws { let data = serverETag.data(using: .ascii)! try await responseBodyAndTrailers.writeAndConclude(data.span, finalElement: nil) } + case "/trailers": + // Send a response with custom trailers + let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) + try await responseBodyAndTrailers.produceAndConclude { responseBody in + var responseBody = responseBody + // Write the body + try await responseBody.write("Response body".utf8.span) + // Return custom trailers + return [ + .init("X-Trailer-One")!: "first-value", + .init("X-Trailer-Two")!: "second-value", + .init("X-Checksum")!: "abc123", + ] + } default: let writer = try await responseSender.send(HTTPResponse(status: .internalServerError)) try await writer.writeAndConclude("Unknown path".utf8.span, finalElement: nil) diff --git a/Tests/HTTPClientTests/DarwinHTTPClientTests.swift b/Tests/HTTPClientTests/DarwinHTTPClientTests.swift index 8926994..bd50815 100644 --- a/Tests/HTTPClientTests/DarwinHTTPClientTests.swift +++ b/Tests/HTTPClientTests/DarwinHTTPClientTests.swift @@ -41,6 +41,10 @@ struct DarwinHTTPClientTests { // TODO: Writing just an empty span causes an indefinite stall. The terminating chunk (size 0) is not written out on the wire. .testEmptyChunkedBody, + + // TODO: Trailers are not supported by URLSession + .testTrailerRead, + .testTrailerWrite, ]) { return DefaultHTTPClient.shared }