Skip to content
Merged
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
188 changes: 188 additions & 0 deletions Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
@testable import HTTPServer
import HTTPTypes
import NIOCore
import NIOHTTP1
import NIOHTTPTypes
import NIOPosix
import Testing

@Suite
struct HTTPRequestConcludingAsyncReaderTests {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test where we throw in the read closure?

@Test("Head request not allowed")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testWriteHeadRequestPartFatalError() async throws {
// The request body reader should fatal error if it receives a head part
await #expect(processExitsWith: .failure) {
let (stream, source) = NIOAsyncChannelInboundStream<HTTPRequestPart>.makeTestingStream()

// Write just a request head
source.yield(.head(.init(method: .get, scheme: "http", authority: "", path: "")))
source.finish()

let requestReader = HTTPRequestConcludingAsyncReader(
iterator: stream.makeAsyncIterator(),
readerState: .init()
)

_ = try await requestReader.consumeAndConclude { bodyReader in
var bodyReader = bodyReader
try await bodyReader.read { element in () }
}
}
}

@Test(
"Request with concluding element",
arguments: [ByteBuffer(repeating: 1, count: 100), ByteBuffer()],
[
HTTPFields([.init(name: .cookie, value: "test_cookie")]),
HTTPFields([.init(name: .cookie, value: "first_cookie"), .init(name: .cookie, value: "second_cookie")])
]
)
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testRequestWithConcludingElement(body: ByteBuffer, trailers: HTTPFields) async throws {
let (stream, source) = NIOAsyncChannelInboundStream<HTTPRequestPart>.makeTestingStream()

// First write the request
source.yield(.body(body))
source.yield(.end(trailers))
source.finish()

// Then start reading the request
let requestReader = HTTPRequestConcludingAsyncReader(iterator: stream.makeAsyncIterator(), readerState: .init())
let (requestBody, finalElement) = try await requestReader.consumeAndConclude { bodyReader in
var bodyReader = bodyReader

var buffer = ByteBuffer()
// Read just once: we only sent one body chunk
try await bodyReader.read { element in
if let element {
buffer.writeBytes(element.bytes)
} else {
Issue.record("Unexpectedly failed to read the client's request body")
}
}

// Attempting to read again should result in a `nil` element (we only sent one body chunk)
try await bodyReader.read { element in
if element != nil {
Issue.record("Received a non-nil value after the request body was completely read")
}
}

return buffer
}

#expect(requestBody == body)
#expect(finalElement == trailers)
}

@Test(
"Streamed request with concluding element",
arguments: [
(0..<10).map { i in ByteBuffer() }, // 10 empty ByteBuffers
(0..<100).map { i in ByteBuffer(bytes: [i]) } // 100 single-byte ByteBuffers
],
[
HTTPFields([.init(name: .cookie, value: "test")]),
HTTPFields([.init(name: .cookie, value: "first_cookie"), .init(name: .cookie, value: "second_cookie")]),
]
)
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testStreamedRequestBody(bodyChunks: [ByteBuffer], trailers: HTTPFields) async throws {
let (stream, source) = NIOAsyncChannelInboundStream<HTTPRequestPart>.makeTestingStream()

// Execute the writer and reader tasks concurrently
await withThrowingTaskGroup { group in
group.addTask {
for chunk in bodyChunks {
source.yield(.body(chunk))
}
source.yield(.end(trailers))
source.finish()
}

group.addTask {
let requestReader = HTTPRequestConcludingAsyncReader(
iterator: stream.makeAsyncIterator(),
readerState: .init()
)
let finalElement = try await requestReader.consumeAndConclude { bodyReader in
var bodyReader = bodyReader

for chunk in bodyChunks {
try await bodyReader.read { element in
if let element {
var buffer = ByteBuffer()
buffer.writeBytes(element.bytes)
#expect(chunk == buffer)
} else {
Issue.record("Received a nil element before the request body was completely read")
}
}
}

try await bodyReader.read { element in
if element != nil {
Issue.record("Received a non-nil element after the request body was completely read")
}
}
}

#expect(finalElement == trailers)
}
}
}

@Test("Throw while reading request")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testThrowingWhileReadingRequest() async throws {
let (stream, source) = NIOAsyncChannelInboundStream<HTTPRequestPart>.makeTestingStream()

let bodyChunks = (0..<10).map { i in ByteBuffer(bytes: [i]) }
for chunk in bodyChunks {
source.yield(.body(chunk))
}
source.yield(.end([.cookie: "test"]))
source.finish()

// Check that the read error is propagated
try await #require(throws: TestError.errorWhileReading) {
let requestReader = HTTPRequestConcludingAsyncReader(
iterator: stream.makeAsyncIterator(),
readerState: .init()
)

_ = try await requestReader.consumeAndConclude { bodyReader in
var bodyReader = bodyReader

try await bodyReader.read { element in
throw TestError.errorWhileReading
}
}
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
@Test("More bytes available than consumption limit")
func testCollectMoreBytesThanAvailable() async throws {
let (stream, source) = NIOAsyncChannelInboundStream<HTTPRequestPart>.makeTestingStream()

// Write 10 bytes
source.yield(.body(.init(repeating: 5, count: 10)))
source.finish()

let requestReader = HTTPRequestConcludingAsyncReader(iterator: stream.makeAsyncIterator(), readerState: .init())

_ = try await requestReader.consumeAndConclude { requestBodyReader in
var requestBodyReader = requestBodyReader

// Attempting to collect a maximum of 9 bytes should result in a LimitExceeded error.
await #expect(throws: LimitExceeded.self) {
try await requestBodyReader.collect(upTo: 9) { element in
()
}
}
}
}
}
129 changes: 129 additions & 0 deletions Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
@testable import HTTPServer
import HTTPTypes
import NIOCore
import NIOHTTPTypes
import Testing

@Suite
struct HTTPResponseConcludingAsyncWriterTests {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test where we throw in the produceAndConclude closure?

let bodySampleOne: [UInt8] = [1, 2]
let bodySampleTwo: [UInt8] = [3, 4]
let trailerSampleOne: HTTPFields = [.serverTiming: "test"]
let trailerSampleTwo: HTTPFields = [.serverTiming: "test", .cookie: "cookie"]

@Test("Write single body element")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testSingleWriteAndConclude() async throws {
let (writer, sink) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()
let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init())

try await responseWriter.writeAndConclude(element: self.bodySampleOne.span, finalElement: self.trailerSampleOne)

// Now read the response
var responseIterator = sink.makeAsyncIterator()

let element = try #require(await responseIterator.next())
#expect(element == .body(.init(bytes: self.bodySampleOne)))
let trailer = try #require(await responseIterator.next())
#expect(trailer == .end(self.trailerSampleOne))
}

@Test("Write multiple body elements")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testProduceMultipleElementsAndSingleTrailer() async throws {
let (writer, sink) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()
let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init())

try await responseWriter.produceAndConclude { bodyWriter in
var bodyWriter = bodyWriter

// Write multiple elements
try await bodyWriter.write(self.bodySampleOne.span)
try await bodyWriter.write(self.bodySampleTwo.span)

return self.trailerSampleOne
}

var responseIterator = sink.makeAsyncIterator()

let firstElement = try #require(await responseIterator.next())
let secondElement = try #require(await responseIterator.next())
#expect(firstElement == .body(.init(bytes: self.bodySampleOne)))
#expect(secondElement == .body(.init(bytes: self.bodySampleTwo)))

let trailer = try #require(await responseIterator.next())
#expect(trailer == .end(self.trailerSampleOne))
}

@Test("Throw while writing response")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testThrowWhileProducing() async throws {
let (writer, sink) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()

// Check that the write error is propagated
try await #require(throws: TestError.errorWhileWriting) {
let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init())
try await responseWriter.produceAndConclude { bodyWriter in
var bodyWriter = bodyWriter

// Write an element
try await bodyWriter.write(self.bodySampleOne.span)
// Then throw
throw TestError.errorWhileWriting
}
}

var responseIterator = sink.makeAsyncIterator()

let firstElement = try #require(await responseIterator.next())
#expect(firstElement == .body(.init(bytes: self.bodySampleOne)))
}

@Test("Write multiple elements and multiple trailers")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testProduceMultipleElementsAndMultipleTrailers() async throws {
let (writer, sink) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()
let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init())

try await responseWriter.produceAndConclude { bodyWriter in
var bodyWriter = bodyWriter

// Write multiple elements
try await bodyWriter.write(self.bodySampleOne.span)
try await bodyWriter.write(self.bodySampleTwo.span)

return self.trailerSampleTwo
}

var responseIterator = sink.makeAsyncIterator()

let firstElement = try #require(await responseIterator.next())
let secondElement = try #require(await responseIterator.next())
#expect(firstElement == .body(.init(bytes: self.bodySampleOne)))
#expect(secondElement == .body(.init(bytes: self.bodySampleTwo)))

let trailer = try #require(await responseIterator.next())
#expect(trailer == .end(self.trailerSampleTwo))
}

@Test("No body, just trailers")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testNoBodyJustTrailers() async throws {
let (writer, sink) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()
let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init())

try await responseWriter.produceAndConclude { bodyWriter in
return self.trailerSampleTwo
}

var responseIterator = sink.makeAsyncIterator()
let trailer = try #require(await responseIterator.next())
#expect(trailer == .end(self.trailerSampleTwo))
}
}

extension HTTPField.Name {
static var serverTiming: Self {
Self("Server-Timing")!
}
}
74 changes: 74 additions & 0 deletions Tests/HTTPServerTests/HTTPResponseSenderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
@testable import HTTPServer
import NIOCore
import NIOHTTPTypes
import HTTPTypes
import Testing

@Suite
struct HTTPResponseSenderTests {
@Test("Informational header without informational status code")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testInformationalResponseStatusCodePrecondition() async throws {
// Sending an informational header with a non-1xx status code shouldn't be allowed
try await #require(processExitsWith: .failure) {
let (outboundWriter, _) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()
let sender = HTTPResponseSender { response in
try await outboundWriter.write(.head(response))
return HTTPResponseConcludingAsyncWriter(
writer: outboundWriter,
writerState: .init()
)
} sendInformational: { response in
try await outboundWriter.write(.head(response))
}

try await sender.sendInformational(.init(status: .ok, headerFields: [.contentType: "application/json"]))
}
}

@Test("Multiple informational responses before final response")
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
func testSendMultipleInformationalResponses() async throws {
let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter<HTTPResponsePart>.makeTestingWriter()
let sender = HTTPResponseSender { response in
try await outboundWriter.write(.head(response))
return HTTPResponseConcludingAsyncWriter(
writer: outboundWriter,
writerState: .init()
)
} sendInformational: { response in
try await outboundWriter.write(.head(response))
}

// Send two informational responses
let firstInfoHead = HTTPResponse(status: .continue, headerFields: [.contentType: "application/json"])
let secondInfoHead = HTTPResponse(status: .earlyHints, headerFields: [.contentType: "application/json"])
try await sender.sendInformational(firstInfoHead)
try await sender.sendInformational(secondInfoHead)

// Then send the final response
let finalResponseHead = HTTPResponse(status: .ok, headerFields: [:])
let finalResponseBody = [UInt8]([1, 2])
let finalResponseTrailer: HTTPFields = [.cookie: "cookie"]

let responseWriter = try await sender.send(.init(status: .ok, headerFields: [:]))
try await responseWriter.produceAndConclude { bodyTrailerWriter in
var bodyTrailerWriter = bodyTrailerWriter
try await bodyTrailerWriter.write(finalResponseBody.span)
return finalResponseTrailer
}

var responseIterator = sink.makeAsyncIterator()
let firstHead = try #require(await responseIterator.next())
let secondHead = try #require(await responseIterator.next())
let finalHead = try #require(await responseIterator.next())
let body = try #require(await responseIterator.next())
let trailer = try #require(await responseIterator.next())

#expect(firstHead == .head(firstInfoHead))
#expect(secondHead == .head(secondInfoHead))
#expect(finalHead == .head(finalResponseHead))
#expect(body == .body(ByteBuffer(bytes: finalResponseBody)))
#expect(trailer == .end(finalResponseTrailer))
}
}
5 changes: 5 additions & 0 deletions Tests/HTTPServerTests/Utilities/TestError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// An error type for use in tests
enum TestError: Error {
case errorWhileReading
case errorWhileWriting
}
Loading