diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift new file mode 100644 index 00000000..c0b25074 --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A parsed representation of the `content-disposition` header described by RFC 6266 containing only +/// the features relevant to OpenAPI multipart bodies. +struct ContentDisposition: Hashable { + + /// A `disposition-type` parameter value. + enum DispositionType: Hashable { + + /// A form data value. + case formData + + /// Any other value. + case other(String) + + /// Creates a new disposition type value. + /// - Parameter rawValue: A string representation of the value. + init(rawValue: String) { + switch rawValue.lowercased() { + case "form-data": self = .formData + default: self = .other(rawValue) + } + } + + /// A string representation of the value. + var rawValue: String { + switch self { + case .formData: return "form-data" + case .other(let string): return string + } + } + } + + /// The disposition type value. + var dispositionType: DispositionType + + /// A content disposition parameter name. + enum ParameterName: Hashable { + + /// The name parameter. + case name + + /// The filename parameter. + case filename + + /// Any other parameter. + case other(String) + + /// Creates a new parameter name. + /// - Parameter rawValue: A string representation of the name. + init(rawValue: String) { + switch rawValue.lowercased() { + case "name": self = .name + case "filename": self = .filename + default: self = .other(rawValue) + } + } + + /// A string representation of the name. + var rawValue: String { + switch self { + case .name: return "name" + case .filename: return "filename" + case .other(let string): return string + } + } + } + + /// The parameters of the content disposition value. + var parameters: [ParameterName: String] = [:] + + /// The name parameter value. + var name: String? { + get { parameters[.name] } + set { parameters[.name] = newValue } + } + + /// The filename parameter value. + var filename: String? { + get { parameters[.filename] } + set { parameters[.filename] = newValue } + } +} + +extension ContentDisposition: RawRepresentable { + + /// Creates a new instance with the specified raw value. + /// + /// https://datatracker.ietf.org/doc/html/rfc6266#section-4.1 + /// - Parameter rawValue: The raw value to use for the new instance. + init?(rawValue: String) { + var components = rawValue.split(separator: ";").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !components.isEmpty else { return nil } + self.dispositionType = DispositionType(rawValue: components.removeFirst()) + let parameterTuples: [(ParameterName, String)] = components.compactMap { component in + let parameterComponents = component.split(separator: "=", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard parameterComponents.count == 2 else { return nil } + let valueWithoutQuotes = parameterComponents[1].trimmingCharacters(in: ["\""]) + return (.init(rawValue: parameterComponents[0]), valueWithoutQuotes) + } + self.parameters = Dictionary(parameterTuples, uniquingKeysWith: { a, b in a }) + } + + /// The corresponding value of the raw type. + var rawValue: String { + var string = "" + string.append(dispositionType.rawValue) + if !parameters.isEmpty { + for (key, value) in parameters.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { + string.append("; \(key.rawValue)=\"\(value)\"") + } + } + return string + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift new file mode 100644 index 00000000..ac9d9d5f --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +// MARK: - Extensions + +extension MultipartRawPart { + + /// Creates a new raw part by injecting the provided name and filename into + /// the `content-disposition` header field. + /// - Parameters: + /// - name: The name of the part. + /// - filename: The file name of the part. + /// - headerFields: The header fields of the part. + /// - body: The body stream of the part. + public init(name: String?, filename: String? = nil, headerFields: HTTPFields, body: HTTPBody) { + var parameters: [ContentDisposition.ParameterName: String] = [:] + if let name { parameters[.name] = name } + if let filename { parameters[.filename] = filename } + let contentDisposition = ContentDisposition(dispositionType: .formData, parameters: parameters) + var headerFields = headerFields + headerFields[.contentDisposition] = contentDisposition.rawValue + self.init(headerFields: headerFields, body: body) + } + + /// Returns the parameter value for the provided name. + /// - Parameter name: The parameter name. + /// - Returns: The parameter value. Nil if not found in the content disposition header field. + private func getParameter(_ name: ContentDisposition.ParameterName) -> String? { + guard let contentDispositionString = headerFields[.contentDisposition], + let contentDisposition = ContentDisposition(rawValue: contentDispositionString) + else { return nil } + return contentDisposition.parameters[name] + } + + /// Sets the parameter name to the provided value. + /// - Parameters: + /// - name: The parameter name. + /// - value: The value of the parameter. + private mutating func setParameter(_ name: ContentDisposition.ParameterName, _ value: String?) { + guard let contentDispositionString = headerFields[.contentDisposition], + var contentDisposition = ContentDisposition(rawValue: contentDispositionString) + else { + if let value { + headerFields[.contentDisposition] = + ContentDisposition(dispositionType: .formData, parameters: [name: value]).rawValue + } + return + } + contentDisposition.parameters[name] = value + headerFields[.contentDisposition] = contentDisposition.rawValue + } + + /// The name of the part stored in the `content-disposition` header field. + public var name: String? { + get { getParameter(.name) } + set { setParameter(.name, newValue) } + } + + /// The file name of the part stored in the `content-disposition` header field. + public var filename: String? { + get { getParameter(.filename) } + set { setParameter(.filename, newValue) } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift b/Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift new file mode 100644 index 00000000..dbac2bc8 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift @@ -0,0 +1,282 @@ +//===----------------------------------------------------------------------===// +// +// 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 container for multipart body requirements. +struct MultipartBodyRequirements: Sendable, Hashable { + + /// A Boolean value indicating whether unknown part names are allowed. + var allowsUnknownParts: Bool + + /// A set of known part names that must appear exactly once. + var requiredExactlyOncePartNames: Set + + /// A set of known part names that must appear at least once. + var requiredAtLeastOncePartNames: Set + + /// A set of known part names that can appear at most once. + var atMostOncePartNames: Set + + /// A set of known part names that can appear any number of times. + var zeroOrMoreTimesPartNames: Set +} + +/// A sequence that validates that the raw parts passing through the sequence match the provided semantics. +struct MultipartValidationSequence: Sendable +where Upstream.Element == MultipartRawPart { + + /// The source of raw parts. + var upstream: Upstream + + /// The requirements to enforce. + var requirements: MultipartBodyRequirements +} + +extension MultipartValidationSequence: 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(upstream: upstream.makeAsyncIterator(), requirements: requirements) + } + + /// An iterator that pulls raw parts from the upstream iterator and validates their semantics. + struct Iterator: AsyncIteratorProtocol { + + /// The iterator that provides the raw parts. + var upstream: Upstream.AsyncIterator + + /// The underlying requirements validator. + var validator: Validator + + /// Creates a new iterator. + /// - Parameters: + /// - upstream: The iterator that provides the raw parts. + /// - requirements: The requirements to enforce. + init(upstream: Upstream.AsyncIterator, requirements: MultipartBodyRequirements) { + self.upstream = upstream + self.validator = .init(requirements: requirements) + } + + /// 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 validator.next(upstream.next()) } + } +} + +extension MultipartValidationSequence { + + /// A state machine representing the validator. + struct StateMachine { + + /// The state of the state machine. + struct State: Hashable { + + /// A Boolean value indicating whether unknown part names are allowed. + let allowsUnknownParts: Bool + + /// A set of known part names that must appear exactly once. + let exactlyOncePartNames: Set + + /// A set of known part names that must appear at least once. + let atLeastOncePartNames: Set + + /// A set of known part names that can appear at most once. + let atMostOncePartNames: Set + + /// A set of known part names that can appear any number of times. + let zeroOrMoreTimesPartNames: Set + + /// The remaining part names that must appear exactly once. + var remainingExactlyOncePartNames: Set + + /// The remaining part names that must appear at least once. + var remainingAtLeastOncePartNames: Set + + /// The remaining part names that can appear at most once. + var remainingAtMostOncePartNames: Set + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + /// - Parameters: + /// - allowsUnknownParts: A Boolean value indicating whether unknown part names are allowed. + /// - requiredExactlyOncePartNames: A set of known part names that must appear exactly once. + /// - requiredAtLeastOncePartNames: A set of known part names that must appear at least once. + /// - atMostOncePartNames: A set of known part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: A set of known part names that can appear any number of times. + init( + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set + ) { + self.state = .init( + allowsUnknownParts: allowsUnknownParts, + exactlyOncePartNames: requiredExactlyOncePartNames, + atLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames, + remainingExactlyOncePartNames: requiredExactlyOncePartNames, + remainingAtLeastOncePartNames: requiredAtLeastOncePartNames, + remainingAtMostOncePartNames: atMostOncePartNames + ) + } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The sequence finished without encountering at least one required part. + case missingRequiredParts(expectedExactlyOnce: Set, expectedAtLeastOnce: Set) + + /// The validator encountered a part without a name, but `allowsUnknownParts` is set to `false`. + case receivedUnnamedPart + + /// The validator encountered a part with an unknown name, but `allowsUnknownParts` is set to `false`. + case receivedUnknownPart(String) + + /// The validator encountered a repeated part of the provided name, even though the part + /// is only allowed to appear at most once. + case receivedMultipleValuesForSingleValuePart(String) + } + + /// An action returned by the `next` method. + enum NextAction: Hashable { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Fetch the next part. + case emitError(ActionError) + + /// Return the part to the caller. + case emitPart(MultipartRawPart) + } + + /// Read the next part from the upstream and validate it. + /// - Returns: An action to perform. + mutating func next(_ part: MultipartRawPart?) -> NextAction { + guard let part else { + guard state.remainingExactlyOncePartNames.isEmpty && state.remainingAtLeastOncePartNames.isEmpty else { + return .emitError( + .missingRequiredParts( + expectedExactlyOnce: state.remainingExactlyOncePartNames, + expectedAtLeastOnce: state.remainingAtLeastOncePartNames + ) + ) + } + return .returnNil + } + guard let name = part.name else { + guard state.allowsUnknownParts else { return .emitError(.receivedUnnamedPart) } + return .emitPart(part) + } + if state.remainingExactlyOncePartNames.contains(name) { + state.remainingExactlyOncePartNames.remove(name) + return .emitPart(part) + } + if state.remainingAtLeastOncePartNames.contains(name) { + state.remainingAtLeastOncePartNames.remove(name) + return .emitPart(part) + } + if state.remainingAtMostOncePartNames.contains(name) { + state.remainingAtMostOncePartNames.remove(name) + return .emitPart(part) + } + if state.exactlyOncePartNames.contains(name) || state.atMostOncePartNames.contains(name) { + return .emitError(.receivedMultipleValuesForSingleValuePart(name)) + } + if state.atLeastOncePartNames.contains(name) { return .emitPart(part) } + if state.zeroOrMoreTimesPartNames.contains(name) { return .emitPart(part) } + guard state.allowsUnknownParts else { return .emitError(.receivedUnknownPart(name)) } + return .emitPart(part) + } + } +} + +extension MultipartValidationSequence { + + /// A validator of multipart raw parts. + struct Validator { + + /// The underlying state machine. + private var stateMachine: StateMachine + /// Creates a new validator. + /// - Parameter requirements: The requirements to validate. + init(requirements: MultipartBodyRequirements) { + self.stateMachine = .init( + allowsUnknownParts: requirements.allowsUnknownParts, + requiredExactlyOncePartNames: requirements.requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requirements.requiredAtLeastOncePartNames, + atMostOncePartNames: requirements.atMostOncePartNames, + zeroOrMoreTimesPartNames: requirements.zeroOrMoreTimesPartNames + ) + } + + /// Ingests the next part. + /// - Parameter part: A part provided by the upstream sequence. Nil if the sequence is finished. + /// - Returns: The validated part. Nil if the incoming part was nil. + /// - Throws: When a validation error is encountered. + mutating func next(_ part: MultipartRawPart?) async throws -> MultipartRawPart? { + switch stateMachine.next(part) { + case .returnNil: return nil + case .emitPart(let outPart): return outPart + case .emitError(let error): throw ValidatorError(error: error) + } + } + } +} + +extension MultipartValidationSequence { + + /// An error thrown by the validator. + struct ValidatorError: Swift.Error, LocalizedError, CustomStringConvertible { + + /// The underlying error emitted by the state machine. + var error: StateMachine.ActionError + + var description: String { + switch error { + case .missingRequiredParts(let expectedExactlyOnce, let expectedAtLeastOnce): + let allSorted = expectedExactlyOnce.union(expectedAtLeastOnce).sorted() + return "Missing required parts: \(allSorted.joined(separator: ", "))." + case .receivedUnnamedPart: + return + "Received an unnamed part, which is disallowed in the OpenAPI document using \"additionalProperties: false\"." + case .receivedUnknownPart(let name): + return + "Received an unknown part '\(name)', which is disallowed in the OpenAPI document using \"additionalProperties: false\"." + case .receivedMultipleValuesForSingleValuePart(let name): + return + "Received more than one value of the part '\(name)', but according to the OpenAPI document this part can only appear at most once." + } + } + + var errorDescription: String? { description } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift new file mode 100644 index 00000000..b820929d --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +final class Test_ContentDisposition: Test_Runtime { + + func testParsing() { + func _test( + input: String, + parsed: ContentDisposition?, + output: String?, + file: StaticString = #file, + line: UInt = #line + ) { + let value = ContentDisposition(rawValue: input) + XCTAssertEqual(value, parsed, file: file, line: line) + XCTAssertEqual(value?.rawValue, output, file: file, line: line) + } + + // Common + _test(input: "form-data", parsed: ContentDisposition(dispositionType: .formData), output: "form-data") + // With an unquoted name parameter. + _test( + input: "form-data; name=Foo", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]), + output: "form-data; name=\"Foo\"" + ) + + // With a quoted name parameter. + _test( + input: "form-data; name=\"Foo\"", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]), + output: "form-data; name=\"Foo\"" + ) + + // With quoted name and filename parameters. + _test( + input: "form-data; name=\"Foo\"; filename=\"foo.txt\"", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo", .filename: "foo.txt"]), + output: "form-data; filename=\"foo.txt\"; name=\"Foo\"" + ) + + // With an unknown parameter. + _test( + input: "form-data; bar=\"Foo\"", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.other("bar"): "Foo"]), + output: "form-data; bar=\"Foo\"" + ) + + // Other + _test( + input: "attachment", + parsed: ContentDisposition(dispositionType: .other("attachment")), + output: "attachment" + ) + + // Empty + _test(input: "", parsed: nil, output: nil) + } + func testAccessors() { + var value = ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]) + XCTAssertEqual(value.name, "Foo") + XCTAssertNil(value.filename) + value.name = nil + XCTAssertNil(value.name) + XCTAssertNil(value.filename) + value.name = "Foo2" + value.filename = "foo.txt" + XCTAssertEqual(value.name, "Foo2") + XCTAssertEqual(value.filename, "foo.txt") + XCTAssertEqual(value.rawValue, "form-data; filename=\"foo.txt\"; name=\"Foo2\"") + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift new file mode 100644 index 00000000..3951e864 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -0,0 +1,283 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartValidationSequence: Test_Runtime { + func test() async throws { + let firstBody: HTTPBody = "24" + let secondBody: HTTPBody = "{}" + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + var upstreamIterator = parts.makeIterator() + let upstream = AsyncStream { upstreamIterator.next() } + let sequence = MultipartValidationSequence( + upstream: upstream, + requirements: .init( + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [] + ) + ) + var outParts: [MultipartRawPart] = [] + for try await part in sequence { outParts.append(part) } + let expectedParts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + XCTAssertEqual(outParts, expectedParts) + } +} + +final class Test_MultipartValidationSequenceValidator: Test_Runtime { + func test() async throws { + let firstBody: HTTPBody = "24" + let secondBody: HTTPBody = "{}" + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + var validator = MultipartValidationSequence> + .Validator( + requirements: .init( + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [] + ) + ) + let outParts: [MultipartRawPart?] = try await [validator.next(parts[0]), validator.next(parts[1])] + let expectedParts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + XCTAssertEqual(outParts, expectedParts) + } +} + +private func newStateMachine( + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set +) -> MultipartValidationSequence>.StateMachine { + .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ) +} + +final class Test_MultipartValidationSequenceStateMachine: Test_Runtime { + + func testTwoParts() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24"), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: "{}"), + ] + var stateMachine = newStateMachine( + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual( + stateMachine.state, + .init( + allowsUnknownParts: true, + exactlyOncePartNames: ["name"], + atLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [], + remainingExactlyOncePartNames: ["name"], + remainingAtLeastOncePartNames: [], + remainingAtMostOncePartNames: ["info"] + ) + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual( + stateMachine.state, + .init( + allowsUnknownParts: true, + exactlyOncePartNames: ["name"], + atLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [], + remainingExactlyOncePartNames: [], + remainingAtLeastOncePartNames: [], + remainingAtMostOncePartNames: ["info"] + ) + ) + XCTAssertEqual(stateMachine.next(parts[1]), .emitPart(parts[1])) + XCTAssertEqual( + stateMachine.state, + .init( + allowsUnknownParts: true, + exactlyOncePartNames: ["name"], + atLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [], + remainingExactlyOncePartNames: [], + remainingAtLeastOncePartNames: [], + remainingAtMostOncePartNames: [] + ) + ) + XCTAssertEqual(stateMachine.next(nil), .returnNil) + } + func testUnknownWithName() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedUnknownPart("name"))) + } + + func testUnnamed_disallowed() throws { + let parts: [MultipartRawPart] = [.init(headerFields: [.contentDisposition: #"form-data"#], body: "24")] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedUnnamedPart)) + } + func testUnnamed_allowed() throws { + let parts: [MultipartRawPart] = [.init(headerFields: [.contentDisposition: #"form-data"#], body: "24")] + var stateMachine = newStateMachine( + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + func testUnknown_disallowed_zeroOrMore() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: ["name"] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + func testUnknown_allowed() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + + func testMissingRequiredExactlyOnce() throws { + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual( + stateMachine.next(nil), + .emitError(.missingRequiredParts(expectedExactlyOnce: ["name"], expectedAtLeastOnce: [])) + ) + } + + func testMissingRequiredAtLeastOnce_once() throws { + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: ["info"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual( + stateMachine.next(nil), + .emitError(.missingRequiredParts(expectedExactlyOnce: [], expectedAtLeastOnce: ["info"])) + ) + } + func testMissingRequiredAtLeastOnce_multipleTimes() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: ["name"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + + func testMissingRequiredExactlyOnce_multipleTimes() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) + } + + func testMissingRequiredAtMostOnce() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["name"], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) + } +}