Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions Sources/HTTPClientConformance/HTTPClientConformance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,179 @@ public func runBasicConformanceTests<Client: HTTPClient & ~Copyable>(
_ clientFactory: @escaping () async throws -> Client
) async throws {
try await withTestHTTPServer { port in
print("Test HTTP Server port: \(port)")
try await BasicConformanceTests(port: port, clientFactory: clientFactory).run()
}
try await withRawHTTPServer { port in
print("Raw HTTP Server: \(port)")
try await RawServerConformanceTests(port: port, clientFactory: clientFactory).run()
}
}

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
struct RawServerConformanceTests<Client: HTTPClient & ~Copyable> {
let port: Int
let clientFactory: () async throws -> Client

func run() async throws {
try await testNotHTTP()
try await testBadHttpCase()
try await testNoReason()
try await test204WithContentLength()
try await test304WithContentLength()
try await testIncompleteBody()
try await testNoLengthHint()
try await testConflictingContentLength()
}

func testNotHTTP() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/not_http"
)
await #expect(throws: (any Error).self) {
try await client.perform(
request: request,
) { _, _ in }
}
}

func testNoReason() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/no_reason"
)
try await client.perform(
request: request,
) { response, _ in
#expect(response.status == .ok)
}
}

func testBadHttpCase() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/http_case"
)
await #expect(throws: (any Error).self) {
try await client.perform(
request: request,
) { _, _ in }
}
}

func test204WithContentLength() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/204_with_cl"
)
try await client.perform(
request: request
) { response, responseBodyAndTrailers in
#expect(response.status == .noContent)
let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
let isEmpty = span.isEmpty
#expect(isEmpty)
}
}
}

func test304WithContentLength() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/304_with_cl"
)
try await client.perform(
request: request
) { response, responseBodyAndTrailers in
#expect(response.status == .notModified)
let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
let isEmpty = span.isEmpty
#expect(isEmpty)
}
}
}

func testIncompleteBody() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/incomplete_body"
)

// An incomplete body based on content-length results in error
await #expect(throws: (any Error).self) {
try await client.perform(
request: request
) { response, responseBodyAndTrailers in
#expect(response.status == .ok)
let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
let isEmpty = span.isEmpty
#expect(isEmpty)
}
}
}
}

func testConflictingContentLength() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/conflicting_cl"
)

// Conflicting content-length results in error
await #expect(throws: (any Error).self) {
try await client.perform(
request: request
) { response, responseBodyAndTrailers in
#expect(response.status == .ok)
let (_, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
let isEmpty = span.isEmpty
#expect(isEmpty)
}
}
}
}

func testNoLengthHint() async throws {
let client = try await clientFactory()
let request = HTTPRequest(
method: .get,
scheme: "http",
authority: "127.0.0.1:\(port)",
path: "/no_length_hint"
)

try await client.perform(
request: request
) { response, responseBodyAndTrailers in
#expect(response.status == .ok)
let (body, _) = try await responseBodyAndTrailers.collect(upTo: 1024) { span in
return String(copying: try UTF8Span(validating: span))
}
#expect(body == "1234")
}
}
}

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
Expand Down
137 changes: 137 additions & 0 deletions Sources/HTTPClientConformance/HTTPServerForTesting/RawHTTPServer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP API Proposal open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Foundation
import NIOCore
import NIOHTTP1
import NIOPosix

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
public func withRawHTTPServer(perform: (Int) async throws -> Void) async throws {
try await withThrowingTaskGroup {
let server = try await RawHTTPServer()
$0.addTask {
try await server.run(handler: handler)
}
try await perform(server.port)
$0.cancelAll()
}
}

func linesToData(_ lines: [String]) -> Data {
return lines.joined(separator: "\r\n").data(using: .ascii)!
}

func handler(request: HTTPRequestHead) -> Data {
switch request.uri {
case "/not_http":
return "FOOBAR".data(using: .ascii)!
case "/http_case":
return "Http/1.1 200 OK\r\n\r\n".data(using: .ascii)!
case "/no_reason":
return "HTTP/1.1 200\r\n\r\n".data(using: .ascii)!
case "/204_with_cl":
return linesToData([
"HTTP/1.1 204 No Content",
"Content-Length: 1000",
"",
"",
])
case "/304_with_cl":
return linesToData([
"HTTP/1.1 304 Not Modified",
"Content-Length: 1000",
"",
"",
])
case "/incomplete_body":
return linesToData([
"HTTP/1.1 200 OK",
"Content-Length: 1000",
"",
"1234",
])
case "/no_length_hint":
return linesToData([
"HTTP/1.1 200 OK",
"",
"1234",
])
case "/conflicting_cl":
return linesToData([
"HTTP/1.1 200 OK",
"Content-Length: 10, 4",
"",
"1234",
])
default:
return "HTTP/1.1 500 Internal Server Error\r\n\r\n".data(using: .ascii)!
}
}

@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
actor RawHTTPServer {
let server_channel:
NIOAsyncChannel<
NIOAsyncChannel<
HTTPServerRequestPart, IOData
>, Never
>

var port: Int {
server_channel.channel.localAddress!.port!
}

init() async throws {
server_channel = try await ServerBootstrap(
group: .singletonMultiThreadedEventLoopGroup
)
.bind(
host: "127.0.0.1",
port: 0,
) { channel in
channel.eventLoop.makeCompletedFuture {
let requestDecoder = ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes))
channel.pipeline.addHandler(requestDecoder)

return try NIOAsyncChannel<
HTTPServerRequestPart, IOData
>(wrappingChannelSynchronously: channel)
}
}
}

func run(handler: @Sendable @escaping (HTTPRequestHead) async throws -> Data) async throws {
try await server_channel.executeThenClose { inbound in
for try await httpChannel in inbound {
try await httpChannel.executeThenClose { inbound, outbound in
for try await requestPart in inbound {
// Wait for a request header.
// Ignore request bodies for now.
guard case .head(let head) = requestPart else {
return
}

// Get the response from the handler
let response = try await handler(head)

// Write the response out
let data = IOData.byteBuffer(ByteBuffer(bytes: response))
try await outbound.write(data)
}
}
}
}
}
}
Loading