diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift index b95ce563..1e03fc75 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes +import Foundation /// A sequence that parses multipart frames from bytes. struct MultipartBytesToFramesSequence: Sendable @@ -65,3 +66,338 @@ extension MultipartBytesToFramesSequence: AsyncSequence { mutating func next() async throws -> MultipartFrame? { try await parser.next { try await upstream.next() } } } } + +/// A parser of multipart frames from bytes. +struct MultipartParser { + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// Creates a new parser. + /// - Parameter boundary: The boundary that separates parts. + init(boundary: String) { self.stateMachine = .init(boundary: boundary) } + + /// Parses the next frame. + /// - Parameter fetchChunk: A closure that is called when the parser + /// needs more bytes to parse the next frame. + /// - Returns: A parsed frame, or nil at the end of the message. + /// - Throws: When a parsing error is encountered. + mutating func next(_ fetchChunk: () async throws -> ArraySlice?) async throws -> MultipartFrame? { + while true { + switch stateMachine.readNextPart() { + case .none: continue + case .emitError(let actionError): throw ParserError(error: actionError) + case .returnNil: return nil + case .emitHeaderFields(let httpFields): return .headerFields(httpFields) + case .emitBodyChunk(let bodyChunk): return .bodyChunk(bodyChunk) + case .needsMore: + let chunk = try await fetchChunk() + switch stateMachine.receivedChunk(chunk) { + case .none: continue + case .returnNil: return nil + case .emitError(let actionError): throw ParserError(error: actionError) + } + } + } + } +} + +extension MultipartParser { + + /// An error thrown by the parser. + struct ParserError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + let error: MultipartParser.StateMachine.ActionError + + var description: String { + switch error { + case .invalidInitialBoundary: return "Invalid initial boundary." + case .invalidCRLFAtStartOfHeaderField: return "Invalid CRLF at the start of a header field." + case .missingColonAfterHeaderName: return "Missing colon after header field name." + case .invalidCharactersInHeaderFieldName: return "Invalid characters in a header field name." + case .incompleteMultipartMessage: return "Incomplete multipart message." + case .receivedChunkWhenFinished: return "Received a chunk after being finished." + } + } + + var errorDescription: String? { description } + } +} + +extension MultipartParser { + + /// A state machine representing the byte to multipart frame parser. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet fully parsed the initial boundary. + case parsingInitialBoundary([UInt8]) + + /// A substate when parsing a part. + enum PartState: Hashable { + + /// Accumulating part headers. + case parsingHeaderFields(HTTPFields) + + /// Forwarding body chunks. + case parsingBody + } + + /// Is parsing a part. + case parsingPart([UInt8], PartState) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// The bytes of the boundary. + private let boundary: ArraySlice + + /// The bytes of the boundary with the double dash prepended. + private let dashDashBoundary: ArraySlice + + /// The bytes of the boundary prepended by CRLF + double dash. + private let crlfDashDashBoundary: ArraySlice + + /// Creates a new state machine. + /// - Parameter boundary: The boundary used to separate parts. + init(boundary: String) { + self.state = .parsingInitialBoundary([]) + self.boundary = ArraySlice(boundary.utf8) + self.dashDashBoundary = ASCII.dashes + self.boundary + self.crlfDashDashBoundary = ASCII.crlf + dashDashBoundary + } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The initial boundary is malformed. + case invalidInitialBoundary + + /// The expected CRLF at the start of a header is missing. + case invalidCRLFAtStartOfHeaderField + + /// A header field name contains an invalid character. + case invalidCharactersInHeaderFieldName + + /// The header field name is not followed by a colon. + case missingColonAfterHeaderName + + /// More bytes were received after completion. + case receivedChunkWhenFinished + + /// Ran out of bytes without the message being complete. + case incompleteMultipartMessage + } + + /// An action returned by the `readNextPart` method. + enum ReadNextPartAction: Hashable { + + /// No action, call `readNextPart` again. + case none + + /// Throw the provided error. + case emitError(ActionError) + + /// Return nil to the caller, no more frames. + case returnNil + + /// Emit a frame with the provided header fields. + case emitHeaderFields(HTTPFields) + + /// Emit a frame with the provided part body chunk. + case emitBodyChunk(ArraySlice) + + /// Needs more bytes to parse the next frame. + case needsMore + } + + /// Read the next frame from the accumulated bytes. + /// - Returns: An action to perform. + mutating func readNextPart() -> ReadNextPartAction { + switch state { + case .mutating: preconditionFailure("Invalid state: \(state)") + case .finished: return .returnNil + case .parsingInitialBoundary(var buffer): + state = .mutating + // These first bytes must be the boundary already, otherwise this is a malformed multipart body. + switch buffer.firstIndexAfterPrefix(dashDashBoundary) { + case .index(let index): + buffer.removeSubrange(buffer.startIndex...Index + switch buffer.firstIndexAfterPrefix(ASCII.crlf) { + case .index(let index): indexAfterFirstCRLF = index + case .reachedEndOfSelf: + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + case .unexpectedPrefix: + state = .finished + return .emitError(.invalidCRLFAtStartOfHeaderField) + } + // If CRLF is here, this is the end of header fields section. + switch buffer[indexAfterFirstCRLF...].firstIndexAfterPrefix(ASCII.crlf) { + case .index(let index): + buffer.removeSubrange(buffer.startIndex...Index + // Check that what follows is a colon, otherwise this is a malformed header field line. + // Source: RFC 7230, section 3.2.4. + switch buffer[endHeaderNameIndex...].firstIndexAfterPrefix([ASCII.colon]) { + case .index(let index): startHeaderValueWithWhitespaceIndex = index + case .reachedEndOfSelf: + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + case .unexpectedPrefix: + state = .finished + return .emitError(.missingColonAfterHeaderName) + } + guard + let startHeaderValueIndex = buffer[startHeaderValueWithWhitespaceIndex...] + .firstIndex(where: { !ASCII.optionalWhitespace.contains($0) }) + else { + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + } + + // Find the CRLF first, then remove any trailing whitespace. + guard + let endHeaderValueWithWhitespaceRange = buffer[startHeaderValueIndex...] + .firstRange(of: ASCII.crlf) + else { + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + } + let headerFieldValueBytes = buffer[ + startHeaderValueIndex..?) -> ReceivedChunkAction { + switch state { + case .parsingInitialBoundary(var buffer): + guard let chunk else { return .emitError(.incompleteMultipartMessage) } + state = .mutating + buffer.append(contentsOf: chunk) + state = .parsingInitialBoundary(buffer) + return .none + case .parsingPart(var buffer, let part): + guard let chunk else { return .emitError(.incompleteMultipartMessage) } + state = .mutating + buffer.append(contentsOf: chunk) + state = .parsingPart(buffer, part) + return .none + case .finished: + guard chunk == nil else { return .emitError(.receivedChunkWhenFinished) } + return .returnNil + case .mutating: preconditionFailure("Invalid state: \(state)") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift index e1d55542..441c85fd 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes +import Foundation /// A sequence that serializes multipart frames into bytes. struct MultipartFramesToBytesSequence: Sendable @@ -69,3 +70,247 @@ extension MultipartFramesToBytesSequence: AsyncSequence { } } } + +/// A serializer of multipart frames into bytes. +struct MultipartSerializer { + + /// The boundary that separates parts. + private let boundary: ArraySlice + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// The buffer of bytes ready to be written out. + private var outBuffer: [UInt8] + + /// Creates a new serializer. + /// - Parameter boundary: The boundary that separates parts. + init(boundary: String) { + self.boundary = ArraySlice(boundary.utf8) + self.stateMachine = .init() + self.outBuffer = [] + } + /// Requests the next byte chunk. + /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. + /// - Returns: A byte chunk. + /// - Throws: When a serialization error is encountered. + mutating func next(_ fetchFrame: () async throws -> MultipartFrame?) async throws -> ArraySlice? { + + func flushedBytes() -> ArraySlice { + let outChunk = ArraySlice(outBuffer) + outBuffer.removeAll(keepingCapacity: true) + return outChunk + } + + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitStart: + emitStart() + return flushedBytes() + case .needsMore: + let frame = try await fetchFrame() + switch stateMachine.receivedFrame(frame) { + case .returnNil: return nil + case .emitEvents(let events): + for event in events { + switch event { + case .headerFields(let headerFields): emitHeaders(headerFields) + case .bodyChunk(let chunk): emitBodyChunk(chunk) + case .endOfPart: emitEndOfPart() + case .start: emitStart() + case .end: emitEnd() + } + } + return flushedBytes() + case .emitError(let error): throw SerializerError(error: error) + } + } + } + } +} + +extension MultipartSerializer { + + /// An error thrown by the serializer. + struct SerializerError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + var error: StateMachine.ActionError + + var description: String { + switch error { + case .noHeaderFieldsAtStart: return "No header fields found at the start of the multipart body." + } + } + + var errorDescription: String? { description } + } +} + +extension MultipartSerializer { + + /// Writes the provided header fields into the buffer. + /// - Parameter headerFields: The header fields to serialize. + private mutating func emitHeaders(_ headerFields: HTTPFields) { + outBuffer.append(contentsOf: ASCII.crlf) + let sortedHeaders = headerFields.sorted { a, b in a.name.canonicalName < b.name.canonicalName } + for headerField in sortedHeaders { + outBuffer.append(contentsOf: headerField.name.canonicalName.utf8) + outBuffer.append(contentsOf: ASCII.colonSpace) + outBuffer.append(contentsOf: headerField.value.utf8) + outBuffer.append(contentsOf: ASCII.crlf) + } + outBuffer.append(contentsOf: ASCII.crlf) + } + + /// Writes the part body chunk into the buffer. + /// - Parameter bodyChunk: The body chunk to write. + private mutating func emitBodyChunk(_ bodyChunk: ArraySlice) { outBuffer.append(contentsOf: bodyChunk) } + + /// Writes an end of part boundary into the buffer. + private mutating func emitEndOfPart() { + outBuffer.append(contentsOf: ASCII.crlf) + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: boundary) + } + + /// Writes the start boundary into the buffer. + private mutating func emitStart() { + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: boundary) + } + + /// Writes the end double dash to the buffer. + private mutating func emitEnd() { + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: ASCII.crlf) + outBuffer.append(contentsOf: ASCII.crlf) + } +} + +extension MultipartSerializer { + + /// A state machine representing the multipart frame serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet written any bytes. + case initial + + /// Emitted start, but no frames yet. + case startedNothingEmittedYet + + /// Finished, the terminal state. + case finished + + /// Last emitted a header fields frame. + case emittedHeaders + + /// Last emitted a part body chunk frame. + case emittedBodyChunk + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The first frame from upstream was not a header fields frame. + case noHeaderFieldsAtStart + } + + /// An action returned by the `next` method. + enum NextAction: Hashable { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit the initial boundary. + case emitStart + + /// Ready for the next frame. + case needsMore + } + + /// Read the next byte chunk serialized from upstream frames. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .initial: + state = .startedNothingEmittedYet + return .emitStart + case .finished: return .returnNil + case .startedNothingEmittedYet, .emittedHeaders, .emittedBodyChunk: return .needsMore + } + } + + /// An event to serialize to bytes. + enum Event: Hashable { + + /// The header fields of a part. + case headerFields(HTTPFields) + + /// A byte chunk of a part. + case bodyChunk(ArraySlice) + + /// A boundary between parts. + case endOfPart + + /// The initial boundary. + case start + + /// The final dashes. + case end + } + + /// An action returned by the `receivedFrame` method. + enum ReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Write the provided events as bytes. + case emitEvents([Event]) + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Ingest the provided frame. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func receivedFrame(_ frame: MultipartFrame?) -> ReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Invalid state: \(state)") + case .finished: return .returnNil + case .startedNothingEmittedYet, .emittedHeaders, .emittedBodyChunk: break + } + switch (state, frame) { + case (.initial, _), (.finished, _): preconditionFailure("Already handled above.") + case (_, .none): + state = .finished + return .emitEvents([.endOfPart, .end]) + case (.startedNothingEmittedYet, .headerFields(let headerFields)): + state = .emittedHeaders + return .emitEvents([.headerFields(headerFields)]) + case (.startedNothingEmittedYet, .bodyChunk): + state = .finished + return .emitError(.noHeaderFieldsAtStart) + case (.emittedHeaders, .headerFields(let headerFields)), + (.emittedBodyChunk, .headerFields(let headerFields)): + state = .emittedHeaders + return .emitEvents([.endOfPart, .headerFields(headerFields)]) + case (.emittedHeaders, .bodyChunk(let bodyChunk)), (.emittedBodyChunk, .bodyChunk(let bodyChunk)): + state = .emittedBodyChunk + return .emitEvents([.bodyChunk(bodyChunk)]) + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift new file mode 100644 index 00000000..3345c088 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift @@ -0,0 +1,380 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +import Foundation + +/// A sequence that parses raw multipart parts from multipart frames. +struct MultipartFramesToRawPartsSequence: Sendable +where Upstream.Element == MultipartFrame { + + /// The source of multipart frames. + var upstream: Upstream +} + +extension MultipartFramesToRawPartsSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + typealias Element = MultipartRawPart + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + func makeAsyncIterator() -> Iterator { Iterator(makeUpstreamIterator: { upstream.makeAsyncIterator() }) } + + /// An iterator that pulls frames from the upstream iterator and provides + /// raw multipart parts. + struct Iterator: AsyncIteratorProtocol { + + /// The underlying shared iterator. + var shared: SharedIterator + + /// The closure invoked to fetch the next byte chunk of the part's body. + var bodyClosure: @Sendable () async throws -> ArraySlice? + + /// Creates a new iterator. + /// - Parameter makeUpstreamIterator: A closure that creates the upstream source of frames. + init(makeUpstreamIterator: @Sendable () -> Upstream.AsyncIterator) { + let shared = SharedIterator(makeUpstreamIterator: makeUpstreamIterator) + self.shared = shared + self.bodyClosure = { try await shared.nextFromBodySubsequence() } + } + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + mutating func next() async throws -> Element? { + try await shared.nextFromPartSequence(bodyClosure: bodyClosure) + } + } +} + +extension HTTPBody { + + /// Creates a new body from the provided header fields and body closure. + /// - Parameters: + /// - headerFields: The header fields to inspect for a `content-length` header. + /// - bodyClosure: A closure invoked to fetch the next byte chunk of the body. + fileprivate convenience init( + headerFields: HTTPFields, + bodyClosure: @escaping @Sendable () async throws -> ArraySlice? + ) { + let stream = AsyncThrowingStream(unfolding: bodyClosure) + let length: HTTPBody.Length + if let contentLengthString = headerFields[.contentLength], let contentLength = Int(contentLengthString) { + length = .known(contentLength) + } else { + length = .unknown + } + self.init(stream, length: length) + } +} + +extension MultipartFramesToRawPartsSequence { + + /// A state machine representing the frame to raw part parser. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not started parsing any parts yet. + case initial + + /// Waiting to send header fields to start a new part. + /// + /// Associated value is optional headers. + /// If they're non-nil, they arrived already, so just send them right away. + /// If they're nil, you need to fetch the next frame to get them. + case waitingToSendHeaders(HTTPFields?) + + /// In the process of streaming the byte chunks of a part body. + case streamingBody + + /// Finished, the terminal state. + case finished + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The outer, raw part sequence called next before the current part's body was fully consumed. + /// + /// This is a usage error by the consumer of the sequence. + case partSequenceNextCalledBeforeBodyWasConsumed + + /// The first frame received was a body chunk instead of header fields, which is invalid. + /// + /// This indicates an issue in the source of frames. + case receivedBodyChunkInInitial + + /// Received a body chunk when waiting for header fields, which is invalid. + /// + /// This indicates an issue in the source of frames. + case receivedBodyChunkWhenWaitingForHeaders + + /// Received another frame before having had a chance to send out header fields, this is an error caused + /// by the driver of the state machine. + case receivedFrameWhenAlreadyHasUnsentHeaders + } + + /// An action returned by the `nextFromPartSequence` method. + enum NextFromPartSequenceAction: Hashable { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Fetch the next frame. + case fetchFrame + + /// Throw the provided error. + case emitError(ActionError) + + /// Emit a part with the provided header fields. + case emitPart(HTTPFields) + } + + /// Read the next part from the upstream frames. + /// - Returns: An action to perform. + mutating func nextFromPartSequence() -> NextFromPartSequenceAction { + switch state { + case .initial: + state = .waitingToSendHeaders(nil) + return .fetchFrame + case .waitingToSendHeaders(.some(let headers)): + state = .streamingBody + return .emitPart(headers) + case .waitingToSendHeaders(.none), .streamingBody: + state = .finished + return .emitError(.partSequenceNextCalledBeforeBodyWasConsumed) + case .finished: return .returnNil + } + } + + /// An action returned by the `partReceivedFrame` method. + enum PartReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Throw the provided error. + case emitError(ActionError) + + /// Emit a part with the provided header fields. + case emitPart(HTTPFields) + } + + /// Ingest the provided frame, requested by the part sequence. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func partReceivedFrame(_ frame: MultipartFrame?) -> PartReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Haven't asked for a part chunk, how did we receive one?") + case .waitingToSendHeaders(.some): + state = .finished + return .emitError(.receivedFrameWhenAlreadyHasUnsentHeaders) + case .waitingToSendHeaders(.none): + if let frame { + switch frame { + case .headerFields(let headers): + state = .streamingBody + return .emitPart(headers) + case .bodyChunk: + state = .finished + return .emitError(.receivedBodyChunkWhenWaitingForHeaders) + } + } else { + state = .finished + return .returnNil + } + case .streamingBody: + state = .finished + return .emitError(.partSequenceNextCalledBeforeBodyWasConsumed) + case .finished: return .returnNil + } + } + + /// An action returned by the `nextFromBodySubsequence` method. + enum NextFromBodySubsequenceAction: Hashable { + + /// Return nil to the caller, no more byte chunks. + case returnNil + + /// Fetch the next frame. + case fetchFrame + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Read the next byte chunk requested by the current part's body sequence. + /// - Returns: An action to perform. + mutating func nextFromBodySubsequence() -> NextFromBodySubsequenceAction { + switch state { + case .initial: + state = .finished + return .emitError(.receivedBodyChunkInInitial) + case .waitingToSendHeaders: + state = .finished + return .emitError(.receivedBodyChunkWhenWaitingForHeaders) + case .streamingBody: return .fetchFrame + case .finished: return .returnNil + } + } + + /// An action returned by the `bodyReceivedFrame` method. + enum BodyReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more byte chunks. + case returnNil + + /// Return the provided byte chunk. + case returnChunk(ArraySlice) + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Ingest the provided frame, requested by the body sequence. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func bodyReceivedFrame(_ frame: MultipartFrame?) -> BodyReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Haven't asked for a frame, how did we receive one?") + case .waitingToSendHeaders: + state = .finished + return .emitError(.receivedBodyChunkWhenWaitingForHeaders) + case .streamingBody: + if let frame { + switch frame { + case .headerFields(let headers): + state = .waitingToSendHeaders(headers) + return .returnNil + case .bodyChunk(let bodyChunk): return .returnChunk(bodyChunk) + } + } else { + state = .finished + return .returnNil + } + case .finished: return .returnNil + } + } + } +} + +extension MultipartFramesToRawPartsSequence { + + /// A type-safe iterator shared by the outer part sequence iterator and an inner body sequence iterator. + /// + /// It enforces that when a new part is emitted by the outer sequence, that the new part's body is then fully + /// consumed before the outer sequence is asked for the next part. + /// + /// This is required as the source of bytes is a single stream, so without the current part's body being consumed, + /// we can't move on to the next part. + actor SharedIterator { + + /// The upstream source of frames. + private var upstream: Upstream.AsyncIterator + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// Creates a new iterator. + /// - Parameter makeUpstreamIterator: A closure that creates the upstream source of frames. + init(makeUpstreamIterator: @Sendable () -> Upstream.AsyncIterator) { + let upstream = makeUpstreamIterator() + self.upstream = upstream + self.stateMachine = .init() + } + + /// An error thrown by the shared iterator. + struct IteratorError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + let error: StateMachine.ActionError + + var description: String { + switch error { + case .partSequenceNextCalledBeforeBodyWasConsumed: + return + "The outer part sequence was asked for the next element before the current part's inner body sequence was fully consumed." + case .receivedBodyChunkInInitial: + return + "Received a body chunk from the upstream sequence as the first element, instead of header fields." + case .receivedBodyChunkWhenWaitingForHeaders: + return "Received a body chunk from the upstream sequence when expecting header fields." + case .receivedFrameWhenAlreadyHasUnsentHeaders: + return "Received another frame before the current frame with header fields was written out." + } + } + + var errorDescription: String? { description } + } + + /// Request the next element from the outer part sequence. + /// - Parameter bodyClosure: The closure invoked to fetch the next byte chunk of the part's body. + /// - Returns: The next element, or `nil` if finished. + /// - Throws: When a parsing error is encountered. + func nextFromPartSequence(bodyClosure: @escaping @Sendable () async throws -> ArraySlice?) async throws + -> Element? + { + switch stateMachine.nextFromPartSequence() { + case .returnNil: return nil + case .fetchFrame: + var upstream = upstream + let frame = try await upstream.next() + self.upstream = upstream + switch stateMachine.partReceivedFrame(frame) { + case .returnNil: return nil + case .emitError(let error): throw IteratorError(error: error) + case .emitPart(let headers): + let body = HTTPBody(headerFields: headers, bodyClosure: bodyClosure) + return .init(headerFields: headers, body: body) + } + case .emitError(let error): throw IteratorError(error: error) + case .emitPart(let headers): + let body = HTTPBody(headerFields: headers, bodyClosure: bodyClosure) + return .init(headerFields: headers, body: body) + } + } + + /// Request the next element from the inner body bytes sequence. + /// - Returns: The next element, or `nil` if finished. + func nextFromBodySubsequence() async throws -> ArraySlice? { + switch stateMachine.nextFromBodySubsequence() { + case .returnNil: return nil + case .fetchFrame: + var upstream = upstream + let frame = try await upstream.next() + self.upstream = upstream + switch stateMachine.bodyReceivedFrame(frame) { + case .returnNil: return nil + case .returnChunk(let bodyChunk): return bodyChunk + case .emitError(let error): throw IteratorError(error: error) + } + case .emitError(let error): throw IteratorError(error: error) + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift b/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift deleted file mode 100644 index d98db13e..00000000 --- a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift +++ /dev/null @@ -1,350 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import HTTPTypes - -/// A parser of multipart frames from bytes. -struct MultipartParser { - - /// The underlying state machine. - private var stateMachine: StateMachine - - /// Creates a new parser. - /// - Parameter boundary: The boundary that separates parts. - init(boundary: String) { self.stateMachine = .init(boundary: boundary) } - - /// Parses the next frame. - /// - Parameter fetchChunk: A closure that is called when the parser - /// needs more bytes to parse the next frame. - /// - Returns: A parsed frame, or nil at the end of the message. - /// - Throws: When a parsing error is encountered. - mutating func next(_ fetchChunk: () async throws -> ArraySlice?) async throws -> MultipartFrame? { - while true { - switch stateMachine.readNextPart() { - case .none: continue - case .emitError(let actionError): throw ParserError(error: actionError) - case .returnNil: return nil - case .emitHeaderFields(let httpFields): return .headerFields(httpFields) - case .emitBodyChunk(let bodyChunk): return .bodyChunk(bodyChunk) - case .needsMore: - let chunk = try await fetchChunk() - switch stateMachine.receivedChunk(chunk) { - case .none: continue - case .returnNil: return nil - case .emitError(let actionError): throw ParserError(error: actionError) - } - } - } - } -} -extension MultipartParser { - - /// An error thrown by the parser. - struct ParserError: Swift.Error, CustomStringConvertible, LocalizedError { - - /// The underlying error emitted by the state machine. - let error: MultipartParser.StateMachine.ActionError - - var description: String { - switch error { - case .invalidInitialBoundary: return "Invalid initial boundary." - case .invalidCRLFAtStartOfHeaderField: return "Invalid CRLF at the start of a header field." - case .missingColonAfterHeaderName: return "Missing colon after header field name." - case .invalidCharactersInHeaderFieldName: return "Invalid characters in a header field name." - case .incompleteMultipartMessage: return "Incomplete multipart message." - case .receivedChunkWhenFinished: return "Received a chunk after being finished." - } - } - - var errorDescription: String? { description } - } -} - -extension MultipartParser { - - /// A state machine representing the byte to multipart frame parser. - struct StateMachine { - - /// The possible states of the state machine. - enum State: Hashable { - - /// Has not yet fully parsed the initial boundary. - case parsingInitialBoundary([UInt8]) - - /// A substate when parsing a part. - enum PartState: Hashable { - - /// Accumulating part headers. - case parsingHeaderFields(HTTPFields) - - /// Forwarding body chunks. - case parsingBody - } - - /// Is parsing a part. - case parsingPart([UInt8], PartState) - - /// Finished, the terminal state. - case finished - - /// Helper state to avoid copy-on-write copies. - case mutating - } - - /// The current state of the state machine. - private(set) var state: State - - /// The bytes of the boundary. - private let boundary: ArraySlice - - /// The bytes of the boundary with the double dash prepended. - private let dashDashBoundary: ArraySlice - - /// The bytes of the boundary prepended by CRLF + double dash. - private let crlfDashDashBoundary: ArraySlice - - /// Creates a new state machine. - /// - Parameter boundary: The boundary used to separate parts. - init(boundary: String) { - self.state = .parsingInitialBoundary([]) - self.boundary = ArraySlice(boundary.utf8) - self.dashDashBoundary = ASCII.dashes + self.boundary - self.crlfDashDashBoundary = ASCII.crlf + dashDashBoundary - } - - /// An error returned by the state machine. - enum ActionError: Hashable { - - /// The initial boundary is malformed. - case invalidInitialBoundary - - /// The expected CRLF at the start of a header is missing. - case invalidCRLFAtStartOfHeaderField - - /// A header field name contains an invalid character. - case invalidCharactersInHeaderFieldName - - /// The header field name is not followed by a colon. - case missingColonAfterHeaderName - - /// More bytes were received after completion. - case receivedChunkWhenFinished - - /// Ran out of bytes without the message being complete. - case incompleteMultipartMessage - } - - /// An action returned by the `readNextPart` method. - enum ReadNextPartAction: Hashable { - - /// No action, call `readNextPart` again. - case none - - /// Throw the provided error. - case emitError(ActionError) - - /// Return nil to the caller, no more frames. - case returnNil - - /// Emit a frame with the provided header fields. - case emitHeaderFields(HTTPFields) - - /// Emit a frame with the provided part body chunk. - case emitBodyChunk(ArraySlice) - - /// Needs more bytes to parse the next frame. - case needsMore - } - - /// Read the next part from the accumulated bytes. - /// - Returns: An action to perform. - mutating func readNextPart() -> ReadNextPartAction { - switch state { - case .mutating: preconditionFailure("Invalid state: \(state)") - case .finished: return .returnNil - case .parsingInitialBoundary(var buffer): - state = .mutating - // These first bytes must be the boundary already, otherwise this is a malformed multipart body. - switch buffer.firstIndexAfterPrefix(dashDashBoundary) { - case .index(let index): - buffer.removeSubrange(buffer.startIndex...Index - switch buffer.firstIndexAfterPrefix(ASCII.crlf) { - case .index(let index): indexAfterFirstCRLF = index - case .reachedEndOfSelf: - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - case .unexpectedPrefix: - state = .finished - return .emitError(.invalidCRLFAtStartOfHeaderField) - } - // If CRLF is here, this is the end of header fields section. - switch buffer[indexAfterFirstCRLF...].firstIndexAfterPrefix(ASCII.crlf) { - case .index(let index): - buffer.removeSubrange(buffer.startIndex...Index - // Check that what follows is a colon, otherwise this is a malformed header field line. - // Source: RFC 7230, section 3.2.4. - switch buffer[endHeaderNameIndex...].firstIndexAfterPrefix([ASCII.colon]) { - case .index(let index): startHeaderValueWithWhitespaceIndex = index - case .reachedEndOfSelf: - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - case .unexpectedPrefix: - state = .finished - return .emitError(.missingColonAfterHeaderName) - } - guard - let startHeaderValueIndex = buffer[startHeaderValueWithWhitespaceIndex...] - .firstIndex(where: { !ASCII.optionalWhitespace.contains($0) }) - else { - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - } - - // Find the CRLF first, then remove any trailing whitespace. - guard - let endHeaderValueWithWhitespaceRange = buffer[startHeaderValueIndex...] - .firstRange(of: ASCII.crlf) - else { - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - } - let headerFieldValueBytes = buffer[ - startHeaderValueIndex..?) -> ReceivedChunkAction { - switch state { - case .parsingInitialBoundary(var buffer): - guard let chunk else { return .emitError(.incompleteMultipartMessage) } - state = .mutating - buffer.append(contentsOf: chunk) - state = .parsingInitialBoundary(buffer) - return .none - case .parsingPart(var buffer, let part): - guard let chunk else { return .emitError(.incompleteMultipartMessage) } - state = .mutating - buffer.append(contentsOf: chunk) - state = .parsingPart(buffer, part) - return .none - case .finished: - guard chunk == nil else { return .emitError(.receivedChunkWhenFinished) } - return .returnNil - case .mutating: preconditionFailure("Invalid state: \(state)") - } - } - } -} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift new file mode 100644 index 00000000..213dcfb6 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import HTTPTypes + +/// A raw multipart part containing the header fields and the body stream. +public struct MultipartRawPart: Sendable, Hashable { + + /// The header fields contained in this part, such as `content-disposition`. + public var headerFields: HTTPFields + + /// The body stream of this part. + public var body: HTTPBody + + /// Creates a new part. + /// - Parameters: + /// - headerFields: The header fields contained in this part, such as `content-disposition`. + /// - body: The body stream of this part. + public init(headerFields: HTTPFields, body: HTTPBody) { + self.headerFields = headerFields + self.body = body + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift b/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift deleted file mode 100644 index 8f744784..00000000 --- a/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift +++ /dev/null @@ -1,260 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import HTTPTypes - -/// A serializer of multipart frames into bytes. -struct MultipartSerializer { - - /// The boundary that separates parts. - private let boundary: ArraySlice - - /// The underlying state machine. - private var stateMachine: StateMachine - - /// The buffer of bytes ready to be written out. - private var outBuffer: [UInt8] - - /// Creates a new serializer. - /// - Parameter boundary: The boundary that separates parts. - init(boundary: String) { - self.boundary = ArraySlice(boundary.utf8) - self.stateMachine = .init() - self.outBuffer = [] - } - /// Requests the next byte chunk. - /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. - /// - Returns: A byte chunk. - /// - Throws: When a serialization error is encountered. - mutating func next(_ fetchFrame: () async throws -> MultipartFrame?) async throws -> ArraySlice? { - - func flushedBytes() -> ArraySlice { - let outChunk = ArraySlice(outBuffer) - outBuffer.removeAll(keepingCapacity: true) - return outChunk - } - - while true { - switch stateMachine.next() { - case .returnNil: return nil - case .emitStart: - emitStart() - return flushedBytes() - case .needsMore: - let frame = try await fetchFrame() - switch stateMachine.receivedFrame(frame) { - case .returnNil: return nil - case .emitEvents(let events): - for event in events { - switch event { - case .headerFields(let headerFields): emitHeaders(headerFields) - case .bodyChunk(let chunk): emitBodyChunk(chunk) - case .endOfPart: emitEndOfPart() - case .start: emitStart() - case .end: emitEnd() - } - } - return flushedBytes() - case .emitError(let error): throw SerializerError(error: error) - } - } - } - } -} - -extension MultipartSerializer { - - /// An error thrown by the serializer. - struct SerializerError: Swift.Error, CustomStringConvertible, LocalizedError { - - /// The underlying error emitted by the state machine. - var error: StateMachine.ActionError - - var description: String { - switch error { - case .noHeaderFieldsAtStart: return "No header fields found at the start of the multipart body." - } - } - - var errorDescription: String? { description } - } -} - -extension MultipartSerializer { - - /// Writes the provided header fields into the buffer. - /// - Parameter headerFields: The header fields to serialize. - private mutating func emitHeaders(_ headerFields: HTTPFields) { - outBuffer.append(contentsOf: ASCII.crlf) - let sortedHeaders = headerFields.sorted { a, b in a.name.canonicalName < b.name.canonicalName } - for headerField in sortedHeaders { - outBuffer.append(contentsOf: headerField.name.canonicalName.utf8) - outBuffer.append(contentsOf: ASCII.colonSpace) - outBuffer.append(contentsOf: headerField.value.utf8) - outBuffer.append(contentsOf: ASCII.crlf) - } - outBuffer.append(contentsOf: ASCII.crlf) - } - - /// Writes the part body chunk into the buffer. - /// - Parameter bodyChunk: The body chunk to write. - private mutating func emitBodyChunk(_ bodyChunk: ArraySlice) { outBuffer.append(contentsOf: bodyChunk) } - - /// Writes an end of part boundary into the buffer. - private mutating func emitEndOfPart() { - outBuffer.append(contentsOf: ASCII.crlf) - outBuffer.append(contentsOf: ASCII.dashes) - outBuffer.append(contentsOf: boundary) - } - - /// Writes the start boundary into the buffer. - private mutating func emitStart() { - outBuffer.append(contentsOf: ASCII.dashes) - outBuffer.append(contentsOf: boundary) - } - - /// Writes the end double dash to the buffer. - private mutating func emitEnd() { - outBuffer.append(contentsOf: ASCII.dashes) - outBuffer.append(contentsOf: ASCII.crlf) - outBuffer.append(contentsOf: ASCII.crlf) - } -} - -extension MultipartSerializer { - - /// A state machine representing the multipart frame serializer. - struct StateMachine { - - /// The possible states of the state machine. - enum State: Hashable { - - /// Has not yet written any bytes. - case initial - - /// Emitted start, but no frames yet. - case emittedStart - - /// Finished, the terminal state. - case finished - - /// Last emitted a header fields frame. - case emittedHeaders - - /// Last emitted a part body chunk frame. - case emittedBodyChunk - } - - /// The current state of the state machine. - private(set) var state: State - - /// Creates a new state machine. - init() { self.state = .initial } - - /// An error returned by the state machine. - enum ActionError: Hashable { - - /// The first frame from upstream was not a header fields frame. - case noHeaderFieldsAtStart - } - - /// An action returned by the `next` method. - enum NextAction: Hashable { - - /// Return nil to the caller, no more bytes. - case returnNil - - /// Emit the initial boundary. - case emitStart - - /// Ready for the next frame. - case needsMore - } - - /// Read the next byte chunk serialized from upstream frames. - /// - Returns: An action to perform. - mutating func next() -> NextAction { - switch state { - case .initial: - state = .emittedStart - return .emitStart - case .finished: return .returnNil - case .emittedStart, .emittedHeaders, .emittedBodyChunk: return .needsMore - } - } - - /// An event to serialize to bytes. - enum Event: Hashable { - - /// The header fields of a part. - case headerFields(HTTPFields) - - /// A byte chunk of a part. - case bodyChunk(ArraySlice) - - /// A boundary between parts. - case endOfPart - - /// The initial boundary. - case start - - /// The final dashes. - case end - } - - /// An action returned by the `receivedFrame` method. - enum ReceivedFrameAction: Hashable { - - /// Return nil to the caller, no more bytes. - case returnNil - - /// Write the provided events as bytes. - case emitEvents([Event]) - - /// Throw the provided error. - case emitError(ActionError) - } - - /// Ingest the provided frame. - /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. - /// - Returns: An action to perform. - mutating func receivedFrame(_ frame: MultipartFrame?) -> ReceivedFrameAction { - switch state { - case .initial: preconditionFailure("Invalid state: \(state)") - case .finished: return .returnNil - case .emittedStart, .emittedHeaders, .emittedBodyChunk: break - } - switch (state, frame) { - case (.initial, _), (.finished, _): preconditionFailure("Already handled above.") - case (_, .none): - state = .finished - return .emitEvents([.endOfPart, .end]) - case (.emittedStart, .headerFields(let headerFields)): - state = .emittedHeaders - return .emitEvents([.headerFields(headerFields)]) - case (.emittedStart, .bodyChunk): - state = .finished - return .emitError(.noHeaderFieldsAtStart) - case (.emittedHeaders, .headerFields(let headerFields)), - (.emittedBodyChunk, .headerFields(let headerFields)): - state = .emittedHeaders - return .emitEvents([.endOfPart, .headerFields(headerFields)]) - case (.emittedHeaders, .bodyChunk(let bodyChunk)), (.emittedBodyChunk, .bodyChunk(let bodyChunk)): - state = .emittedBodyChunk - return .emitEvents([.bodyChunk(bodyChunk)]) - } - } - } -} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift index 88036301..acdee3f4 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift @@ -36,3 +36,146 @@ final class Test_MultipartBytesToFramesSequence: Test_Runtime { ) } } + +final class Test_MultipartParser: Test_Runtime { + func test() async throws { + var chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + var parser = MultipartParser(boundary: "__abcd__") + let next: () async throws -> ArraySlice? = { + if let first = chunk.first { + let out: ArraySlice = [first] + chunk = chunk.dropFirst() + return out + } else { + return nil + } + } + var frames: [MultipartFrame] = [] + while let frame = try await parser.next(next) { frames.append(frame) } + XCTAssertEqual( + frames, + [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + ) + } +} + +private func newStateMachine() -> MultipartParser.StateMachine { .init(boundary: "__abcd__") } + +final class Test_MultipartParserStateMachine: Test_Runtime { + + func testInvalidInitialBoundary() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("invalid")), .none) + XCTAssertEqual(stateMachine.readNextPart(), .emitError(.invalidInitialBoundary)) + } + + func testHeaderFields() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("--__ab")), .none) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(bufferFromString("--__ab"))) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__", addCRLFs: 1)), .none) + XCTAssertEqual(stateMachine.readNextPart(), .none) + XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a], .parsingHeaderFields(.init()))) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(#"Content-Disposi"#)), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart([0x0d, 0x0a] + bufferFromString(#"Content-Disposi"#), .parsingHeaderFields(.init())) + ) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual( + stateMachine.receivedChunk(chunkFromString(#"tion: form-data; name="name""#, addCRLFs: 2)), + .none + ) + XCTAssertEqual( + stateMachine.state, + .parsingPart( + [0x0d, 0x0a] + bufferFromString(#"Content-Disposition: form-data; name="name""#) + [ + 0x0d, 0x0a, 0x0d, 0x0a, + ], + .parsingHeaderFields(.init()) + ) + ) + // Reads the first header field. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Reads the end of the header fields section. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) + } + + func testPartBody() throws { + var stateMachine = newStateMachine() + let chunk = chunkFromStringLines(["--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24"]) + XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) + XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(Array(chunk))) + // Parse the initial boundary and first header field. + for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .parsingPart(bufferFromString(#"24"#) + [0x0d, 0x0a], .parsingBody)) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(".42")), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42"), .parsingBody) + ) + XCTAssertEqual( + stateMachine.readNextPart(), + .emitBodyChunk(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42")) + ) + XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) + XCTAssertEqual(stateMachine.receivedChunk([0x0d, 0x0a] + chunkFromString("--__ab")), .none) + XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a] + chunkFromString("--__ab"), .parsingBody)) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__--", addCRLFs: 1)), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart([0x0d, 0x0a] + chunkFromString("--__abcd__--", addCRLFs: 1), .parsingBody) + ) + // Parse the final boundary. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Parse the trailing two dashes. + XCTAssertEqual(stateMachine.readNextPart(), .returnNil) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + let chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) + // Parse the initial boundary and first header field. + for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + // Parse the first part's body. + XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("24"))) + // Parse the boundary. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="info""#]) + ) + // Parse the second part's body. + XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("{}"))) + // Parse the trailing two dashes. + XCTAssertEqual(stateMachine.readNextPart(), .returnNil) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift index 257c9614..de487ed6 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift @@ -34,3 +34,66 @@ final class Test_MultipartFramesToBytesSequence: Test_Runtime { XCTAssertEqualData(bytes, expectedBytes) } } + +final class Test_MultipartSerializer: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var serializer = MultipartSerializer(boundary: "__abcd__") + var iterator = frames.makeIterator() + var bytes: [UInt8] = [] + while let chunk = try await serializer.next({ iterator.next() }) { bytes.append(contentsOf: chunk) } + let expectedBytes = chunkFromStringLines([ + "--__abcd__", #"content-disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"content-disposition: form-data; name="info""#, "", "{}", "--__abcd__--", "", + ]) + XCTAssertEqualData(bytes, expectedBytes) + } +} + +private func newStateMachine() -> MultipartSerializer.StateMachine { .init() } + +final class Test_MultipartSerializerStateMachine: Test_Runtime { + + func testInvalidFirstFrame() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.next(), .emitStart) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual(stateMachine.receivedFrame(.bodyChunk([])), .emitError(.noHeaderFieldsAtStart)) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.next(), .emitStart) + XCTAssertEqual(stateMachine.state, .startedNothingEmittedYet) + XCTAssertEqual( + stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), + .emitEvents([.headerFields([.contentDisposition: #"form-data; name="name""#])]) + ) + XCTAssertEqual(stateMachine.state, .emittedHeaders) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.bodyChunk(chunkFromString("24"))), + .emitEvents([.bodyChunk(chunkFromString("24"))]) + ) + XCTAssertEqual(stateMachine.state, .emittedBodyChunk) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), + .emitEvents([.endOfPart, .headerFields([.contentDisposition: #"form-data; name="info""#])]) + ) + XCTAssertEqual(stateMachine.state, .emittedHeaders) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.bodyChunk(chunkFromString("{}"))), + .emitEvents([.bodyChunk(chunkFromString("{}"))]) + ) + XCTAssertEqual(stateMachine.state, .emittedBodyChunk) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual(stateMachine.receivedFrame(nil), .emitEvents([.endOfPart, .end])) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift new file mode 100644 index 00000000..4a75b727 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import XCTest +@_spi(Generated) @testable import OpenAPIRuntime +import Foundation + +final class Test_MultipartFramesToRawPartsSequence: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var upstreamIterator = frames.makeIterator() + let upstream = AsyncStream { upstreamIterator.next() } + let sequence = MultipartFramesToRawPartsSequence(upstream: upstream) + var iterator = sequence.makeAsyncIterator() + guard let part1 = try await iterator.next() else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part1.headerFields, [.contentDisposition: #"form-data; name="name""#]) + try await XCTAssertEqualStringifiedData(part1.body, "24") + guard let part2 = try await iterator.next() else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part2.headerFields, [.contentDisposition: #"form-data; name="info""#]) + try await XCTAssertEqualStringifiedData(part2.body, "{}") + + let part3 = try await iterator.next() + XCTAssertNil(part3) + } +} + +final class Test_MultipartFramesToRawPartsSequenceIterator: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var upstreamSyncIterator = frames.makeIterator() + let upstream = AsyncStream { upstreamSyncIterator.next() } + let sharedIterator = MultipartFramesToRawPartsSequence> + .SharedIterator(makeUpstreamIterator: { upstream.makeAsyncIterator() }) + let bodyClosure: @Sendable () async throws -> ArraySlice? = { + try await sharedIterator.nextFromBodySubsequence() + } + guard let part1 = try await sharedIterator.nextFromPartSequence(bodyClosure: bodyClosure) else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part1.headerFields, [.contentDisposition: #"form-data; name="name""#]) + try await XCTAssertEqualStringifiedData(part1.body, "24") + guard let part2 = try await sharedIterator.nextFromPartSequence(bodyClosure: bodyClosure) else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part2.headerFields, [.contentDisposition: #"form-data; name="info""#]) + try await XCTAssertEqualStringifiedData(part2.body, "{}") + + let part3 = try await sharedIterator.nextFromPartSequence(bodyClosure: bodyClosure) + XCTAssertNil(part3) + } +} + +private func newStateMachine() -> MultipartFramesToRawPartsSequence>.StateMachine { + .init() +} + +final class Test_MultipartFramesToRawPartsSequenceIteratorStateMachine: Test_Runtime { + + func testInvalidFirstFrame() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.nextFromPartSequence(), .fetchFrame) + XCTAssertEqual(stateMachine.state, .waitingToSendHeaders(nil)) + XCTAssertEqual( + stateMachine.partReceivedFrame(.bodyChunk([])), + .emitError(.receivedBodyChunkWhenWaitingForHeaders) + ) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.nextFromPartSequence(), .fetchFrame) + XCTAssertEqual(stateMachine.state, .waitingToSendHeaders(nil)) + XCTAssertEqual( + stateMachine.partReceivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), + .emitPart([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual( + stateMachine.bodyReceivedFrame(.bodyChunk(chunkFromString("24"))), + .returnChunk(chunkFromString("24")) + ) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual( + stateMachine.bodyReceivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), + .returnNil + ) + XCTAssertEqual(stateMachine.state, .waitingToSendHeaders([.contentDisposition: #"form-data; name="info""#])) + XCTAssertEqual( + stateMachine.nextFromPartSequence(), + .emitPart([.contentDisposition: #"form-data; name="info""#]) + ) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual( + stateMachine.bodyReceivedFrame(.bodyChunk(chunkFromString("{}"))), + .returnChunk(chunkFromString("{}")) + ) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual(stateMachine.bodyReceivedFrame(nil), .returnNil) + XCTAssertEqual(stateMachine.state, .finished) + XCTAssertEqual(stateMachine.nextFromPartSequence(), .returnNil) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift deleted file mode 100644 index 5587868b..00000000 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift +++ /dev/null @@ -1,159 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -import XCTest -@_spi(Generated) @testable import OpenAPIRuntime -import Foundation - -final class Test_MultipartParser: Test_Runtime { - func test() async throws { - var chunk = chunkFromStringLines([ - "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", - #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", - ]) - var parser = MultipartParser(boundary: "__abcd__") - let next: () async throws -> ArraySlice? = { - if let first = chunk.first { - let out: ArraySlice = [first] - chunk = chunk.dropFirst() - return out - } else { - return nil - } - } - var frames: [MultipartFrame] = [] - while let frame = try await parser.next(next) { frames.append(frame) } - XCTAssertEqual( - frames, - [ - .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), - .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), - .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), - ] - ) - } -} - -private func newStateMachine() -> MultipartParser.StateMachine { .init(boundary: "__abcd__") } - -final class Test_MultipartParserStateMachine: Test_Runtime { - - func testInvalidInitialBoundary() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("invalid")), .none) - XCTAssertEqual(stateMachine.readNextPart(), .emitError(.invalidInitialBoundary)) - } - - func testHeaderFields() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("--__ab")), .none) - XCTAssertEqual(stateMachine.readNextPart(), .needsMore) - XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(bufferFromString("--__ab"))) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__", addCRLFs: 1)), .none) - XCTAssertEqual(stateMachine.readNextPart(), .none) - XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a], .parsingHeaderFields(.init()))) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(#"Content-Disposi"#)), .none) - XCTAssertEqual( - stateMachine.state, - .parsingPart([0x0d, 0x0a] + bufferFromString(#"Content-Disposi"#), .parsingHeaderFields(.init())) - ) - XCTAssertEqual(stateMachine.readNextPart(), .needsMore) - XCTAssertEqual( - stateMachine.receivedChunk(chunkFromString(#"tion: form-data; name="name""#, addCRLFs: 2)), - .none - ) - XCTAssertEqual( - stateMachine.state, - .parsingPart( - [0x0d, 0x0a] + bufferFromString(#"Content-Disposition: form-data; name="name""#) + [ - 0x0d, 0x0a, 0x0d, 0x0a, - ], - .parsingHeaderFields(.init()) - ) - ) - // Reads the first header field. - XCTAssertEqual(stateMachine.readNextPart(), .none) - // Reads the end of the header fields section. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) - ) - XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) - } - - func testPartBody() throws { - var stateMachine = newStateMachine() - let chunk = chunkFromStringLines(["--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24"]) - XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) - XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(Array(chunk))) - // Parse the initial boundary and first header field. - for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } - // Parse the end of header fields. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) - ) - XCTAssertEqual(stateMachine.state, .parsingPart(bufferFromString(#"24"#) + [0x0d, 0x0a], .parsingBody)) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(".42")), .none) - XCTAssertEqual( - stateMachine.state, - .parsingPart(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42"), .parsingBody) - ) - XCTAssertEqual( - stateMachine.readNextPart(), - .emitBodyChunk(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42")) - ) - XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) - XCTAssertEqual(stateMachine.receivedChunk([0x0d, 0x0a] + chunkFromString("--__ab")), .none) - XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a] + chunkFromString("--__ab"), .parsingBody)) - XCTAssertEqual(stateMachine.readNextPart(), .needsMore) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__--", addCRLFs: 1)), .none) - XCTAssertEqual( - stateMachine.state, - .parsingPart([0x0d, 0x0a] + chunkFromString("--__abcd__--", addCRLFs: 1), .parsingBody) - ) - // Parse the final boundary. - XCTAssertEqual(stateMachine.readNextPart(), .none) - // Parse the trailing two dashes. - XCTAssertEqual(stateMachine.readNextPart(), .returnNil) - } - - func testTwoParts() throws { - var stateMachine = newStateMachine() - let chunk = chunkFromStringLines([ - "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", - #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", - ]) - XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) - // Parse the initial boundary and first header field. - for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } - // Parse the end of header fields. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) - ) - // Parse the first part's body. - XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("24"))) - // Parse the boundary. - XCTAssertEqual(stateMachine.readNextPart(), .none) - // Parse the end of header fields. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="info""#]) - ) - // Parse the second part's body. - XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("{}"))) - // Parse the trailing two dashes. - XCTAssertEqual(stateMachine.readNextPart(), .returnNil) - } -} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift deleted file mode 100644 index 7dd96a64..00000000 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift +++ /dev/null @@ -1,79 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -import XCTest -@_spi(Generated) @testable import OpenAPIRuntime -import Foundation - -final class Test_MultipartSerializer: Test_Runtime { - func test() async throws { - let frames: [MultipartFrame] = [ - .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), - .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), - .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), - ] - var serializer = MultipartSerializer(boundary: "__abcd__") - var iterator = frames.makeIterator() - var bytes: [UInt8] = [] - while let chunk = try await serializer.next({ iterator.next() }) { bytes.append(contentsOf: chunk) } - let expectedBytes = chunkFromStringLines([ - "--__abcd__", #"content-disposition: form-data; name="name""#, "", "24", "--__abcd__", - #"content-disposition: form-data; name="info""#, "", "{}", "--__abcd__--", "", - ]) - XCTAssertEqualData(bytes, expectedBytes) - } -} - -private func newStateMachine() -> MultipartSerializer.StateMachine { .init() } - -final class Test_MultipartSerializerStateMachine: Test_Runtime { - - func testInvalidFirstFrame() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.next(), .emitStart) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual(stateMachine.receivedFrame(.bodyChunk([])), .emitError(.noHeaderFieldsAtStart)) - } - - func testTwoParts() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.state, .initial) - XCTAssertEqual(stateMachine.next(), .emitStart) - XCTAssertEqual(stateMachine.state, .emittedStart) - XCTAssertEqual( - stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), - .emitEvents([.headerFields([.contentDisposition: #"form-data; name="name""#])]) - ) - XCTAssertEqual(stateMachine.state, .emittedHeaders) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual( - stateMachine.receivedFrame(.bodyChunk(chunkFromString("24"))), - .emitEvents([.bodyChunk(chunkFromString("24"))]) - ) - XCTAssertEqual(stateMachine.state, .emittedBodyChunk) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual( - stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), - .emitEvents([.endOfPart, .headerFields([.contentDisposition: #"form-data; name="info""#])]) - ) - XCTAssertEqual(stateMachine.state, .emittedHeaders) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual( - stateMachine.receivedFrame(.bodyChunk(chunkFromString("{}"))), - .emitEvents([.bodyChunk(chunkFromString("{}"))]) - ) - XCTAssertEqual(stateMachine.state, .emittedBodyChunk) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual(stateMachine.receivedFrame(nil), .emitEvents([.endOfPart, .end])) - } -} diff --git a/docker/docker-compose.2204.510.yaml b/docker/docker-compose.2204.510.yaml index d031df5a..02e5d46e 100644 --- a/docker/docker-compose.2204.510.yaml +++ b/docker/docker-compose.2204.510.yaml @@ -12,7 +12,9 @@ services: environment: - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete + # Disabled strict concurrency checking as currently it's not possible to iterate an async sequence + # from inside an actor without warnings. + # - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete shell: image: *image