From 2ecc6dd76bb3ab172b676e3a78969abf60df94e1 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 20 Nov 2023 11:51:07 +0100 Subject: [PATCH 1/8] [Multipart] Validation sequence --- .../Base/ContentDisposition.swift | 128 ++++++++ .../MultipartPublicTypesExtensions.swift | 78 +++++ .../Multipart/MultipartValidation.swift | 282 +++++++++++++++++ .../Base/Test_ContentDisposition.swift | 85 ++++++ .../Test_MultipartValidationSequence.swift | 283 ++++++++++++++++++ 5 files changed, 856 insertions(+) create mode 100644 Sources/OpenAPIRuntime/Base/ContentDisposition.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift create mode 100644 Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift 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"))) + } +} From 679068932320de39eb2f32c1c2c981dce950dd6c Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 20 Nov 2023 12:38:14 +0100 Subject: [PATCH 2/8] [Multipart] Add public types --- .../Conversion/Configuration.swift | 15 +- .../Deprecated/Deprecated.swift | 11 + .../Interface/AsyncSequenceCommon.swift | 120 +++++++ .../OpenAPIRuntime/Interface/HTTPBody.swift | 126 ++----- .../MultipartBoundaryGenerator.swift | 75 ++++ .../Multipart/MultipartPublicTypes.swift | 327 ++++++++++++++++++ .../Test_MultipartBoundaryGenerator.swift | 36 ++ 7 files changed, 603 insertions(+), 107 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 93b00f32..6cff9130 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -74,9 +74,20 @@ public struct Configuration: Sendable { /// The transcoder used when converting between date and string values. public var dateTranscoder: any DateTranscoder + /// The generator to use when creating mutlipart bodies. + public var multipartBoundaryGenerator: any MultipartBoundaryGenerator + /// Creates a new configuration with the specified values. /// - /// - Parameter dateTranscoder: The transcoder to use when converting between date + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date /// and string values. - public init(dateTranscoder: any DateTranscoder = .iso8601) { self.dateTranscoder = dateTranscoder } + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + ) { + self.dateTranscoder = dateTranscoder + self.multipartBoundaryGenerator = multipartBoundaryGenerator + } } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 323da60f..087532eb 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -195,3 +195,14 @@ extension DecodingError { } } } + +extension Configuration { + /// Creates a new configuration with the specified values. + /// + /// - Parameter dateTranscoder: The transcoder to use when converting between date + /// and string values. + @available(*, deprecated, renamed: "init(dateTranscoder:multipartBoundaryGenerator:)") @_disfavoredOverload + public init(dateTranscoder: any DateTranscoder) { + self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: .random) + } +} diff --git a/Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift b/Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift new file mode 100644 index 00000000..392eead8 --- /dev/null +++ b/Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Describes how many times the provided sequence can be iterated. +public enum IterationBehavior: Sendable { + + /// The input sequence can only be iterated once. + /// + /// If a retry or a redirect is encountered, fail the call with + /// a descriptive error. + case single + + /// The input sequence can be iterated multiple times. + /// + /// Supports retries and redirects, as a new iterator is created each + /// time. + case multiple +} + +// MARK: - Internal + +/// A type-erasing closure-based iterator. +@usableFromInline struct AnyIterator: AsyncIteratorProtocol { + + /// The closure that produces the next element. + private let produceNext: () async throws -> Element? + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline init(_ iterator: Iterator) where Iterator.Element == Element { + var iterator = iterator + self.produceNext = { try await iterator.next() } + } + + /// Advances the iterator to the next element and returns it asynchronously. + /// + /// - Returns: The next element in the sequence, or `nil` if there are no more elements. + /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. + public mutating func next() async throws -> Element? { try await produceNext() } +} + +/// A type-erased async sequence that wraps input sequences. +@usableFromInline struct AnySequence: AsyncSequence, Sendable { + + /// The type of the type-erased iterator. + @usableFromInline typealias AsyncIterator = AnyIterator + + /// A closure that produces a new iterator. + @usableFromInline let produceIterator: @Sendable () -> AsyncIterator + + /// Creates a new sequence. + /// - Parameter sequence: The input sequence to type-erase. + @usableFromInline init(_ sequence: Upstream) + where Upstream.Element == Element, Upstream: Sendable { + self.produceIterator = { .init(sequence.makeAsyncIterator()) } + } + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } +} + +/// An async sequence wrapper for a sync sequence. +@usableFromInline struct WrappedSyncSequence: AsyncSequence, Sendable +where Upstream.Element: Sendable { + + /// The type of the iterator. + @usableFromInline typealias AsyncIterator = Iterator + + /// The element type. + @usableFromInline typealias Element = Upstream.Element + + /// An iterator type that wraps a sync sequence iterator. + @usableFromInline struct Iterator: AsyncIteratorProtocol { + + /// The element type. + @usableFromInline typealias Element = IteratorElement + + /// The underlying sync sequence iterator. + var iterator: any IteratorProtocol + + @usableFromInline mutating func next() async throws -> IteratorElement? { iterator.next() } + } + + /// The underlying sync sequence. + @usableFromInline let sequence: Upstream + + /// Creates a new async sequence with the provided sync sequence. + /// - Parameter sequence: The sync sequence to wrap. + @usableFromInline init(sequence: Upstream) { self.sequence = sequence } + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { Iterator(iterator: sequence.makeIterator()) } +} + +/// An empty async sequence. +@usableFromInline struct EmptySequence: AsyncSequence, Sendable { + + /// The type of the empty iterator. + @usableFromInline typealias AsyncIterator = EmptyIterator + + /// An async iterator of an empty sequence. + @usableFromInline struct EmptyIterator: AsyncIteratorProtocol { + + @usableFromInline mutating func next() async throws -> IteratorElement? { nil } + } + + /// Creates a new empty async sequence. + @usableFromInline init() {} + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { EmptyIterator() } +} diff --git a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index b97906ba..4486ca12 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -122,24 +122,16 @@ public final class HTTPBody: @unchecked Sendable { public typealias ByteChunk = ArraySlice /// Describes how many times the provided sequence can be iterated. - public enum IterationBehavior: Sendable { - - /// The input sequence can only be iterated once. - /// - /// If a retry or a redirect is encountered, fail the call with - /// a descriptive error. - case single - - /// The input sequence can be iterated multiple times. - /// - /// Supports retries and redirects, as a new iterator is created each - /// time. - case multiple - } - - /// The body's iteration behavior, which controls how many times + @available( + *, + deprecated, + renamed: "IterationBehavior", + message: "Use the top level IterationBehavior directly instead of HTTPBody.IterationBehavior." + ) public typealias IterationBehavior = OpenAPIRuntime.IterationBehavior + + /// The iteration behavior, which controls how many times /// the input sequence can be iterated. - public let iterationBehavior: IterationBehavior + public let iterationBehavior: OpenAPIRuntime.IterationBehavior /// Describes the total length of the body, if known. public enum Length: Sendable, Equatable { @@ -155,7 +147,7 @@ public final class HTTPBody: @unchecked Sendable { public let length: Length /// The underlying type-erased async sequence. - private let sequence: BodySequence + private let sequence: AnySequence /// A lock for shared mutable state. private let lock: NSLock = { @@ -205,7 +197,11 @@ public final class HTTPBody: @unchecked Sendable { /// length of all the byte chunks. /// - iterationBehavior: The sequence's iteration behavior, which /// indicates whether the sequence can be iterated multiple times. - @usableFromInline init(_ sequence: BodySequence, length: Length, iterationBehavior: IterationBehavior) { + @usableFromInline init( + _ sequence: AnySequence, + length: Length, + iterationBehavior: OpenAPIRuntime.IterationBehavior + ) { self.sequence = sequence self.length = length self.iterationBehavior = iterationBehavior @@ -220,7 +216,7 @@ public final class HTTPBody: @unchecked Sendable { @usableFromInline convenience init( _ byteChunks: some Sequence & Sendable, length: Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) { self.init( .init(WrappedSyncSequence(sequence: byteChunks)), @@ -281,7 +277,7 @@ extension HTTPBody { @inlinable public convenience init( _ bytes: some Sequence & Sendable, length: Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) { self.init([ArraySlice(bytes)], length: length, iterationBehavior: iterationBehavior) } /// Creates a new body with the provided byte collection. @@ -323,7 +319,7 @@ extension HTTPBody { @inlinable public convenience init( _ sequence: Bytes, length: HTTPBody.Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) where Bytes.Element == ByteChunk, Bytes: Sendable { self.init(.init(sequence), length: length, iterationBehavior: iterationBehavior) } @@ -337,7 +333,7 @@ extension HTTPBody { @inlinable public convenience init( _ sequence: Bytes, length: HTTPBody.Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) where Bytes: Sendable, Bytes.Element: Sequence & Sendable, Bytes.Element.Element == UInt8 { self.init(sequence.map { ArraySlice($0) }, length: length, iterationBehavior: iterationBehavior) } @@ -356,7 +352,7 @@ extension HTTPBody: AsyncSequence { public func makeAsyncIterator() -> AsyncIterator { // The crash on error is intentional here. try! tryToMarkIteratorCreated() - return sequence.makeAsyncIterator() + return .init(sequence.makeAsyncIterator()) } } @@ -482,7 +478,7 @@ extension HTTPBody { @inlinable public convenience init( _ sequence: Strings, length: HTTPBody.Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) where Strings.Element: StringProtocol & Sendable, Strings: Sendable { self.init(.init(sequence.map { ByteChunk.init($0) }), length: length, iterationBehavior: iterationBehavior) } @@ -583,83 +579,3 @@ extension HTTPBody { public mutating func next() async throws -> Element? { try await produceNext() } } } - -extension HTTPBody { - - /// A type-erased async sequence that wraps input sequences. - @usableFromInline struct BodySequence: AsyncSequence, Sendable { - - /// The type of the type-erased iterator. - @usableFromInline typealias AsyncIterator = HTTPBody.Iterator - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// A closure that produces a new iterator. - @usableFromInline let produceIterator: @Sendable () -> AsyncIterator - - /// Creates a new sequence. - /// - Parameter sequence: The input sequence to type-erase. - @inlinable init(_ sequence: Bytes) where Bytes.Element == Element, Bytes: Sendable { - self.produceIterator = { .init(sequence.makeAsyncIterator()) } - } - - @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } - } - - /// An async sequence wrapper for a sync sequence. - @usableFromInline struct WrappedSyncSequence: AsyncSequence, Sendable - where Bytes.Element == ByteChunk, Bytes.Iterator.Element == ByteChunk, Bytes: Sendable { - - /// The type of the iterator. - @usableFromInline typealias AsyncIterator = Iterator - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// An iterator type that wraps a sync sequence iterator. - @usableFromInline struct Iterator: AsyncIteratorProtocol { - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// The underlying sync sequence iterator. - var iterator: any IteratorProtocol - - @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { iterator.next() } - } - - /// The underlying sync sequence. - @usableFromInline let sequence: Bytes - - /// Creates a new async sequence with the provided sync sequence. - /// - Parameter sequence: The sync sequence to wrap. - @inlinable init(sequence: Bytes) { self.sequence = sequence } - - @usableFromInline func makeAsyncIterator() -> Iterator { Iterator(iterator: sequence.makeIterator()) } - } - - /// An empty async sequence. - @usableFromInline struct EmptySequence: AsyncSequence, Sendable { - - /// The type of the empty iterator. - @usableFromInline typealias AsyncIterator = EmptyIterator - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// An async iterator of an empty sequence. - @usableFromInline struct EmptyIterator: AsyncIteratorProtocol { - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { nil } - } - - /// Creates a new empty async sequence. - @inlinable init() {} - - @usableFromInline func makeAsyncIterator() -> EmptyIterator { EmptyIterator() } - } -} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift new file mode 100644 index 00000000..72b89434 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// +// 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 generator of a new boundary string used by multipart messages to separate parts. +public protocol MultipartBoundaryGenerator: Sendable { + + /// Generates a boundary string for a multipart message. + /// - Returns: A boundary string. + func makeBoundary() -> String +} + +extension MultipartBoundaryGenerator where Self == ConstantMultipartBoundaryGenerator { + + /// A generator that always returns the same boundary string. + public static var constant: Self { ConstantMultipartBoundaryGenerator() } +} + +extension MultipartBoundaryGenerator where Self == RandomMultipartBoundaryGenerator { + + /// A generator that produces a random boundary every time. + public static var random: Self { RandomMultipartBoundaryGenerator() } +} + +/// A generator that always returns the same constant boundary string. +public struct ConstantMultipartBoundaryGenerator: MultipartBoundaryGenerator { + + /// The boundary string to return. + public let boundary: String + /// Creates a new generator. + /// - Parameter boundary: The boundary string to return every time. + public init(boundary: String = "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__") { self.boundary = boundary } + + /// Generates a boundary string for a multipart message. + /// - Returns: A boundary string. + public func makeBoundary() -> String { boundary } +} + +/// A generator that returns a boundary containg a constant prefix and a random suffix. +public struct RandomMultipartBoundaryGenerator: MultipartBoundaryGenerator { + + /// The constant prefix of each boundary. + public let boundaryPrefix: String + /// The length, in bytes, of the random boundary suffix. + public let randomNumberSuffixLenght: Int + + /// The options for the random bytes suffix. + private let values: [UInt8] = Array("0123456789".utf8) + + /// Create a new generator. + /// - Parameters: + /// - boundaryPrefix: The constant prefix of each boundary. + /// - randomNumberSuffixLenght: The length, in bytes, of the random boundary suffix. + public init(boundaryPrefix: String = "__X_SWIFT_OPENAPI_", randomNumberSuffixLenght: Int = 20) { + self.boundaryPrefix = boundaryPrefix + self.randomNumberSuffixLenght = randomNumberSuffixLenght + } + /// Generates a boundary string for a multipart message. + /// - Returns: A boundary string. + public func makeBoundary() -> String { + var randomSuffix = [UInt8](repeating: 0, count: randomNumberSuffixLenght) + for i in randomSuffix.startIndex..: Sendable, Hashable { + + /// The underlying typed part payload, which has a statically known part name. + public var payload: Payload + + /// A file name parameter provided in the `content-disposition` part header field. + public var filename: String? + + /// Creates a new wrapper. + /// - Parameters: + /// - payload: The underlying typed part payload, which has a statically known part name. + /// - filename: A file name parameter provided in the `content-disposition` part header field. + public init(payload: Payload, filename: String? = nil) { + self.payload = payload + self.filename = filename + } +} + +/// A wrapper of a typed part without a statically known name that adds +/// dynamic `content-disposition` parameter values, such as `name` and `filename`. +public struct MultipartDynamicallyNamedPart: Sendable, Hashable { + + /// The underlying typed part payload, which has a statically known part name. + public var payload: Payload + + /// A file name parameter provided in the `content-disposition` part header field. + public var filename: String? + + /// A name parameter provided in the `content-disposition` part header field. + public var name: String? + + /// Creates a new wrapper. + /// - Parameters: + /// - payload: The underlying typed part payload, which has a statically known part name. + /// - filename: A file name parameter provided in the `content-disposition` part header field. + /// - name: A name parameter provided in the `content-disposition` part header field. + public init(payload: Payload, filename: String? = nil, name: String? = nil) { + self.payload = payload + self.filename = filename + self.name = name + } +} + +/// The body of multipart requests and responses. +/// +/// `MultipartBody` represents an async sequence of multipart parts of a specific type. +/// +/// The `Part` generic type parameter is usually a generated enum representing +/// the different values documented for this multipart body. +/// +/// ## Creating a body from buffered parts +/// +/// Create a body from an array of values of type `Part`: +/// +/// ```swift +/// let body: MultipartBody = [ +/// .myCaseA(...), +/// .myCaseB(...), +/// ] +/// ``` +/// +/// ## Creating a body from an async sequence of parts +/// +/// The body type also supports initialization from an async sequence. +/// +/// ```swift +/// let producingSequence = ... // an AsyncSequence of MyPartType +/// let body = MultipartBody( +/// producingSequence, +/// iterationBehavior: .single // or .multiple +/// ) +/// ``` +/// +/// In addition to the async sequence, also specify whether the sequence is safe +/// to be iterated multiple times, or can only be iterated once. +/// +/// Sequences that can be iterated multiple times work better when an HTTP +/// request needs to be retried, or if a redirect is encountered. +/// +/// In addition to providing the async sequence, you can also produce the body +/// using an `AsyncStream` or `AsyncThrowingStream`: +/// +/// ```swift +/// let (stream, continuation) = AsyncStream.makeStream(of: MyPartType.self) +/// // Pass the continuation to another task that produces the parts asynchronously. +/// Task { +/// continuation.yield(.myCaseA(...)) +/// // ... later +/// continuation.yield(.myCaseB(...)) +/// continuation.finish() +/// } +/// let body = MultipartBody(stream) +/// ``` +/// +/// ## Consuming a body as an async sequence +/// +/// The `MultipartBody` type conforms to `AsyncSequence` and uses a generic element type, +/// so it can be consumed in a streaming fashion, without ever buffering the whole body +/// in your process. +/// +/// ```swift +/// let multipartBody: MultipartBody = ... +/// for try await part in multipartBody { +/// switch part { +/// case .myCaseA(let myCaseAValue): +/// // Handle myCaseAValue. +/// case .myCaseB(let myCaseBValue): +/// // Handle myCaseBValue, which is a raw type with a streaming part body. +/// // +/// // Option 1: Process the part body bytes in chunks. +/// for try await bodyChunk in myCaseBValue.body { +/// // Handle bodyChunk. +/// } +/// // Option 2: Accumulate the body into a byte array. +/// // (For other convenience initializers, check out ``HTTPBody``. +/// let fullPartBody = try await [UInt8](collecting: myCaseBValue.body, upTo: 1024) +/// // ... +/// } +/// } +/// ``` +/// +/// Multipart parts of different names can arrive in any order, and the order is not significant. +/// +/// Consuming the multipart body should be resilient to parts of different names being reordered. +/// +/// However, multiple parts of the same name, if allowed by the OpenAPI document by defining it as an array, +/// should be treated as an ordered array of values, and those cannot be reordered without changing +/// the message's meaning. +/// +/// > Important: Parts that contain a raw streaming body (of type ``HTTPBody``) must +/// have their bodies fully consumed before the multipart body sequence is asked for +/// the next part. The multipart body sequence does not buffer internally, and since +/// the parts and their bodies arrive in a single stream of bytes, you cannot move on +/// to the next part until the current one is consumed. +public final class MultipartBody: @unchecked Sendable { + + /// The iteration behavior, which controls how many times the input sequence can be iterated. + public let iterationBehavior: IterationBehavior + + /// The underlying type-erased async sequence. + private let sequence: AnySequence + + /// A lock for shared mutable state. + private let lock: NSLock = { + let lock = NSLock() + lock.name = "com.apple.swift-openapi-generator.runtime.multipart-body" + return lock + }() + + /// A flag indicating whether an iterator has already been created. + private var locked_iteratorCreated: Bool = false + + /// A flag indicating whether an iterator has already been created, only + /// used for testing. + internal var testing_iteratorCreated: Bool { + lock.lock() + defer { lock.unlock() } + return locked_iteratorCreated + } + + /// An error thrown by the collecting initializer when another iteration of + /// the body is not allowed. + private struct TooManyIterationsError: Error, CustomStringConvertible, LocalizedError { + + /// A textual representation of this instance. + var description: String { + "OpenAPIRuntime.MultipartBody attempted to create a second iterator, but the underlying sequence is only safe to be iterated once." + } + + /// A localized message describing what error occurred. + var errorDescription: String? { description } + } + + /// Verifying that creating another iterator is allowed based on the values of `iterationBehavior` + /// and `locked_iteratorCreated`. + /// - Throws: If another iterator is not allowed to be created. + internal func checkIfCanCreateIterator() throws { + lock.lock() + defer { lock.unlock() } + guard iterationBehavior == .single else { return } + if locked_iteratorCreated { throw TooManyIterationsError() } + } + + /// Tries to mark an iterator as created, verifying that it is allowed based on the values + /// of `iterationBehavior` and `locked_iteratorCreated`. + /// - Throws: If another iterator is not allowed to be created. + private func tryToMarkIteratorCreated() throws { + lock.lock() + defer { + locked_iteratorCreated = true + lock.unlock() + } + guard iterationBehavior == .single else { return } + if locked_iteratorCreated { throw TooManyIterationsError() } + } + + /// Creates a new sequence. + /// - Parameters: + /// - sequence: The input sequence providing the parts. + /// - iterationBehavior: The sequence's iteration behavior, which indicates whether the sequence + /// can be iterated multiple times. + @usableFromInline init(_ sequence: AnySequence, iterationBehavior: IterationBehavior) { + self.sequence = sequence + self.iterationBehavior = iterationBehavior + } +} + +extension MultipartBody: Equatable { + + /// Compares two OpenAPISequence instances for equality by comparing their object identifiers. + /// + /// - Parameters: + /// - lhs: The left-hand side OpenAPISequence. + /// - rhs: The right-hand side OpenAPISequence. + /// + /// - Returns: `true` if the object identifiers of the two OpenAPISequence instances are equal, + /// indicating that they are the same object in memory; otherwise, returns `false`. + public static func == (lhs: MultipartBody, rhs: MultipartBody) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } +} + +extension MultipartBody: Hashable { + + /// Hashes the OpenAPISequence instance by combining its object identifier into the provided hasher. + /// + /// - Parameter hasher: The hasher used to combine the hash value. + public func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } +} + +// MARK: - Creating the MultipartBody. + +extension MultipartBody { + + /// Creates a new sequence with the provided async sequence of parts. + /// - Parameters: + /// - sequence: An async sequence that provides the parts. + /// - iterationBehavior: The iteration behavior of the sequence, which indicates whether it + /// can be iterated multiple times. + @inlinable public convenience init(_ sequence: Input, iterationBehavior: IterationBehavior) + where Input.Element == Element { self.init(.init(sequence), iterationBehavior: iterationBehavior) } + + /// Creates a new sequence with the provided sequence parts. + /// - Parameters: + /// - elements: A sequence of parts. + /// - iterationBehavior: The iteration behavior of the sequence, which indicates whether it + /// can be iterated multiple times. + @usableFromInline convenience init( + _ elements: some Sequence & Sendable, + iterationBehavior: IterationBehavior + ) { self.init(.init(WrappedSyncSequence(sequence: elements)), iterationBehavior: iterationBehavior) } + + /// Creates a new sequence with the provided collection of parts. + /// - Parameter elements: A collection of parts. + @inlinable public convenience init(_ elements: some Collection & Sendable) { + self.init(elements, iterationBehavior: .multiple) + } + + /// Creates a new sequence with the provided async throwing stream. + /// - Parameter stream: An async throwing stream that provides the parts. + @inlinable public convenience init(_ stream: AsyncThrowingStream) { + self.init(.init(stream), iterationBehavior: .single) + } + + /// Creates a new sequence with the provided async stream. + /// - Parameter stream: An async stream that provides the parts. + @inlinable public convenience init(_ stream: AsyncStream) { + self.init(.init(stream), iterationBehavior: .single) + } +} + +// MARK: - Conversion from literals +extension MultipartBody: ExpressibleByArrayLiteral { + + /// The type of the elements of an array literal. + public typealias ArrayLiteralElement = Element + + /// Creates an instance initialized with the given elements. + public convenience init(arrayLiteral elements: Element...) { self.init(elements) } +} + +// MARK: - Consuming the sequence +extension MultipartBody: AsyncSequence { + + /// The type of the element. + public typealias Element = Part + + /// Represents an asynchronous iterator over a sequence of elements. + public typealias AsyncIterator = Iterator + + /// Creates and returns an asynchronous iterator + /// + /// - Returns: An asynchronous iterator for parts. + public func makeAsyncIterator() -> AsyncIterator { + // The crash on error is intentional here. + try! tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } +} + +// MARK: - Underlying async sequences +extension MultipartBody { + + /// An async iterator of both input async sequences and of the sequence itself. + public struct Iterator: AsyncIteratorProtocol { + + /// The closure that produces the next element. + private let produceNext: () async throws -> Element? + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline init(_ iterator: Iterator) + where Iterator.Element == Element { + var iterator = iterator + self.produceNext = { try await iterator.next() } + } + + /// Advances the iterator to the next element and returns it asynchronously. + /// + /// - Returns: The next element in the sequence, or `nil` if there are no more elements. + /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. + public mutating func next() async throws -> Element? { try await produceNext() } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift new file mode 100644 index 00000000..352223aa --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartBoundaryGenerator: Test_Runtime { + + func testConstant() throws { + let generator = ConstantMultipartBoundaryGenerator(boundary: "__abcd__") + let firstBoundary = generator.makeBoundary() + let secondBoundary = generator.makeBoundary() + XCTAssertEqual(firstBoundary, "__abcd__") + XCTAssertEqual(secondBoundary, "__abcd__") + } + + func testRandom() throws { + let generator = RandomMultipartBoundaryGenerator(boundaryPrefix: "__abcd__", randomNumberSuffixLenght: 8) + let firstBoundary = generator.makeBoundary() + let secondBoundary = generator.makeBoundary() + XCTAssertNotEqual(firstBoundary, secondBoundary) + XCTAssertTrue(firstBoundary.hasPrefix("__abcd__")) + XCTAssertTrue(secondBoundary.hasPrefix("__abcd__")) + } +} From dd803c79e5f20bc952dcfc5f47595f3612364ee8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 20 Nov 2023 17:19:06 +0100 Subject: [PATCH 3/8] Fix sendability for an input sequence --- Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift index da5c88ac..f92ab6e9 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -275,7 +275,7 @@ extension MultipartBody { /// - sequence: An async sequence that provides the parts. /// - iterationBehavior: The iteration behavior of the sequence, which indicates whether it /// can be iterated multiple times. - @inlinable public convenience init(_ sequence: Input, iterationBehavior: IterationBehavior) + @inlinable public convenience init(_ sequence: Input, iterationBehavior: IterationBehavior) where Input.Element == Element { self.init(.init(sequence), iterationBehavior: iterationBehavior) } /// Creates a new sequence with the provided sequence parts. From ff2df42429eef388f9330b76728b30261bdc073b Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 20 Nov 2023 17:20:42 +0100 Subject: [PATCH 4/8] Fix issues breaking builds with library evolution enabled --- Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift index 58de90e0..f876666e 100644 --- a/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift +++ b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift @@ -26,7 +26,7 @@ /// Creates a new storage with the provided initial value. /// - Parameter value: The initial value to store in the box. - @inlinable init(value: Wrapped) { self.value = value } + @usableFromInline init(value: Wrapped) { self.value = value } } /// The internal storage of the box. @@ -34,7 +34,7 @@ /// Creates a new box. /// - Parameter value: The value to store in the box. - @inlinable public init(value: Wrapped) { self.storage = .init(value: value) } + public init(value: Wrapped) { self.storage = .init(value: value) } /// The stored value whose accessors enforce copy-on-write semantics. @inlinable public var value: Wrapped { From 93dd6428c881603072380d23a6e48178a4f29a6b Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 20 Nov 2023 17:23:10 +0100 Subject: [PATCH 5/8] FIx formatting --- Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift index f92ab6e9..2e4234e6 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -275,8 +275,10 @@ extension MultipartBody { /// - sequence: An async sequence that provides the parts. /// - iterationBehavior: The iteration behavior of the sequence, which indicates whether it /// can be iterated multiple times. - @inlinable public convenience init(_ sequence: Input, iterationBehavior: IterationBehavior) - where Input.Element == Element { self.init(.init(sequence), iterationBehavior: iterationBehavior) } + @inlinable public convenience init( + _ sequence: Input, + iterationBehavior: IterationBehavior + ) where Input.Element == Element { self.init(.init(sequence), iterationBehavior: iterationBehavior) } /// Creates a new sequence with the provided sequence parts. /// - Parameters: From f79153ea7bd32ee18f9d5dc3fbf80c90a4d993bc Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 21 Nov 2023 10:09:48 +0100 Subject: [PATCH 6/8] [Multipart] Add converter SPI methods --- .../Conversion/Converter+Client.swift | 98 ++++++++++++++++++- .../Conversion/Converter+Common.swift | 23 +++++ .../Conversion/Converter+Server.swift | 97 +++++++++++++++++- .../Conversion/CurrencyExtensions.swift | 55 +++++++++-- .../OpenAPIRuntime/Errors/RuntimeError.swift | 10 ++ .../Multipart/OpenAPIMIMEType+Multipart.swift | 30 ++++++ .../Conversion/Test_Converter+Client.swift | 40 ++++++++ .../Conversion/Test_Converter+Common.swift | 31 ++++++ .../Conversion/Test_Converter+Server.swift | 41 ++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 74 +++++++++++++- 10 files changed, 488 insertions(+), 11 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 4b723cac..fb292c7e 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -140,7 +140,7 @@ extension Converter { /// - Throws: An error if setting the request body as binary fails. public func setOptionalRequestBodyAsBinary(_ value: HTTPBody?, headerFields: inout HTTPFields, contentType: String) throws -> HTTPBody? - { try setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + { setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } /// Sets a required request body as binary in the specified header fields and returns an `HTTPBody`. /// @@ -154,7 +154,7 @@ extension Converter { /// - Throws: An error if setting the request body as binary fails. public func setRequiredRequestBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws -> HTTPBody - { try setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + { setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } /// Sets an optional request body as URL-encoded form data in the specified header fields and returns an `HTTPBody`. /// @@ -202,6 +202,56 @@ extension Converter { ) } + /// Sets a required request body as multipart and returns the streaming body. + /// + /// - Parameters: + /// - value: The multipart body to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - encode: A closure that transforms the type-safe part into a raw part. + /// - Returns: A streaming body representing the multipart-encoded request body. + /// - Throws: Currently never, but might in the future. + public func setRequiredRequestBodyAsMultipart( + _ value: MultipartBody, + headerFields: inout HTTPFields, + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart + ) throws -> HTTPBody { + let boundary = configuration.multipartBoundaryGenerator.makeBoundary() + let contentTypeWithBoundary = contentType + "; boundary=\(boundary)" + return setRequiredRequestBody( + value, + headerFields: &headerFields, + contentType: contentTypeWithBoundary, + convert: { value in + convertMultipartToBytes( + value, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + boundary: boundary, + encode: encode + ) + } + ) + } + /// Retrieves the response body as JSON and transforms it into a specified type. /// /// - Parameters: @@ -244,4 +294,48 @@ extension Converter { guard let data else { throw RuntimeError.missingRequiredResponseBody } return try getResponseBody(type, from: data, transforming: transform, convert: { $0 }) } + /// Returns an async sequence of multipart parts parsed from the provided body stream. + /// + /// - Parameters: + /// - type: The type representing the type-safe multipart body. + /// - data: The HTTP body data to transform. + /// - boundary: The multipart boundary string. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - transform: A closure that transforms the multipart body into the output type. + /// - decoder: A closure that parses a raw part into a type-safe part. + /// - Returns: A value of the output type. + /// - Throws: If the transform closure throws. + public func getResponseBodyAsMultipart( + _ type: MultipartBody.Type, + from data: HTTPBody?, + boundary: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, + decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) throws -> C { + guard let data else { throw RuntimeError.missingRequiredResponseBody } + let multipart = convertBytesToMultipart( + data, + boundary: boundary, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + transform: decoder + ) + return try transform(multipart) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index a7f8e979..dc908e75 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -65,6 +65,29 @@ extension Converter { return bestContentType } + /// Verifies the MIME type from the content-type header, if present. + /// - Parameters: + /// - headerFields: The header fields to inspect for the content type header. + /// - match: The content type to verify. + /// - Throws: If the content type is incompatible or malformed. + public func verifyContentTypeIfPresent(in headerFields: HTTPFields, matches match: String) throws { + guard let rawValue = headerFields[.contentType] else { return } + _ = try bestContentType(received: .init(rawValue), options: [match]) + } + + /// Returns the name and file name parameter values from the `content-disposition` header field, if found. + /// - Parameter headerFields: The header fields to inspect for a `content-disposition` header field. + /// - Returns: A tuple of the name and file name string values. + /// - Throws: Currently doesn't, but might in the future. + public func extractContentDispositionNameAndFilename(in headerFields: HTTPFields) throws -> ( + name: String?, filename: String? + ) { + guard let rawValue = headerFields[.contentDisposition], + let contentDisposition = ContentDisposition(rawValue: rawValue) + else { return (nil, nil) } + return (contentDisposition.name, contentDisposition.filename) + } + // MARK: - Converter helper methods /// Sets a header field with an optional value, encoding it as a URI component if not nil. diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index c354d4aa..d1094c66 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -284,6 +284,51 @@ extension Converter { ) } + /// Returns an async sequence of multipart parts parsed from the provided body stream. + /// + /// - Parameters: + /// - type: The type representing the type-safe multipart body. + /// - data: The HTTP body data to transform. + /// - boundary: The multipart boundary string. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - transform: A closure that transforms the multipart body into the output type. + /// - decoder: A closure that parses a raw part into a type-safe part. + /// - Returns: A value of the output type. + /// - Throws: If the transform closure throws. + public func getRequiredRequestBodyAsMultipart( + _ type: MultipartBody.Type, + from data: HTTPBody?, + boundary: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, + decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) throws -> C { + guard let data else { throw RuntimeError.missingRequiredRequestBody } + let multipart = convertBytesToMultipart( + data, + boundary: boundary, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + transform: decoder + ) + return try transform(multipart) + } + /// Sets the response body as JSON data, serializing the provided value. /// /// - Parameters: @@ -313,5 +358,55 @@ extension Converter { /// - Throws: An error if there are issues setting the response body or updating the header fields. public func setResponseBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws -> HTTPBody - { try setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + { setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + + /// Sets a response body as multipart and returns the streaming body. + /// + /// - Parameters: + /// - value: The multipart body to be set as the response body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - encode: A closure that transforms the type-safe part into a raw part. + /// - Returns: A streaming body representing the multipart-encoded response body. + /// - Throws: Currently never, but might in the future. + public func setResponseBodyAsMultipart( + _ value: MultipartBody, + headerFields: inout HTTPFields, + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart + ) throws -> HTTPBody { + let boundary = configuration.multipartBoundaryGenerator.makeBoundary() + let contentTypeWithBoundary = contentType + "; boundary=\(boundary)" + return setResponseBody( + value, + headerFields: &headerFields, + contentType: contentTypeWithBoundary, + convert: { value in + convertMultipartToBytes( + value, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + boundary: boundary, + encode: encode + ) + } + ) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 55765921..df6caf04 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -179,6 +179,52 @@ extension Converter { return HTTPBody(encodedString) } + /// Returns a serialized multipart body stream. + /// - Parameters: + /// - multipart: The multipart body. + /// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence. + /// - boundary: The multipart boundary string. + /// - encode: A closure that converts a typed part into a raw part. + /// - Returns: The serialized body stream. + func convertMultipartToBytes( + _ multipart: MultipartBody, + requirements: MultipartBodyRequirements, + boundary: String, + encode: @escaping @Sendable (Part) throws -> MultipartRawPart + ) -> HTTPBody { + let untyped = multipart.map { part in + var untypedPart = try encode(part) + if case .known(let byteCount) = untypedPart.body.length { + untypedPart.headerFields[.contentLength] = String(byteCount) + } + return untypedPart + } + let validated = MultipartValidationSequence(upstream: untyped, requirements: requirements) + let frames = MultipartRawPartsToFramesSequence(upstream: validated) + let bytes = MultipartFramesToBytesSequence(upstream: frames, boundary: boundary) + return HTTPBody(bytes, length: .unknown, iterationBehavior: multipart.iterationBehavior) + } + + /// Returns a parsed multipart body. + /// - Parameters: + /// - bytes: The multipart body byte stream. + /// - boundary: The multipart boundary string. + /// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence. + /// - transform: A closure that converts a raw part into a typed part. + /// - Returns: The typed multipart body stream. + func convertBytesToMultipart( + _ bytes: HTTPBody, + boundary: String, + requirements: MultipartBodyRequirements, + transform: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) -> MultipartBody { + let frames = MultipartBytesToFramesSequence(upstream: bytes, boundary: boundary) + let raw = MultipartFramesToRawPartsSequence(upstream: frames) + let validated = MultipartValidationSequence(upstream: raw, requirements: requirements) + let typed = validated.map(transform) + return .init(typed, iterationBehavior: bytes.iterationBehavior) + } + /// Returns a JSON string for the provided encodable value. /// - Parameter value: The value to encode. /// - Returns: A JSON string. @@ -383,13 +429,12 @@ extension Converter { /// - contentType: The content type value. /// - convert: The closure that encodes the value into a raw body. /// - Returns: The body. - /// - Throws: An error if an issue occurs while encoding the request body or setting the content type. func setRequiredRequestBody( _ value: T, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody { + ) rethrows -> HTTPBody { headerFields[.contentType] = contentType return try convert(value) } @@ -402,13 +447,12 @@ extension Converter { /// - contentType: The content type value. /// - convert: The closure that encodes the value into a raw body. /// - Returns: The body, if value was not nil. - /// - Throws: An error if an issue occurs while encoding the request body or setting the content type. func setOptionalRequestBody( _ value: T?, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody? { + ) rethrows -> HTTPBody? { guard let value else { return nil } return try setRequiredRequestBody( value, @@ -547,13 +591,12 @@ extension Converter { /// - contentType: The content type value. /// - convert: The closure that encodes the value into a raw body. /// - Returns: The body, if value was not nil. - /// - Throws: An error if an issue occurs while encoding the request body. func setResponseBody( _ value: T, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody { + ) rethrows -> HTTPBody { headerFields[.contentType] = contentType return try convert(value) } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index ffb39ab7..150b804c 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -39,6 +39,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case unexpectedContentTypeHeader(String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) + case missingOrMalformedContentDispositionName // Path case missingRequiredPathParameter(String) @@ -51,6 +52,10 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case missingRequiredRequestBody case missingRequiredResponseBody + // Multipart + case missingRequiredMultipartFormDataContentType + case missingMultipartBoundaryContentTypeParameter + // Transport/Handler case transportFailed(any Error) case middlewareFailed(middlewareType: Any.Type, any Error) @@ -90,11 +95,16 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case .unexpectedContentTypeHeader(let contentType): return "Unexpected Content-Type header: \(contentType)" case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" case .malformedAcceptHeader(let accept): return "Malformed Accept header: \(accept)" + case .missingOrMalformedContentDispositionName: + return "Missing or malformed Content-Disposition header or it's missing a name." case .missingRequiredPathParameter(let name): return "Missing required path parameter named: \(name)" case .pathUnset: return "Path was not set on the request." case .missingRequiredQueryParameter(let name): return "Missing required query parameter named: \(name)" case .missingRequiredRequestBody: return "Missing required request body" case .missingRequiredResponseBody: return "Missing required response body" + case .missingRequiredMultipartFormDataContentType: return "Expected a 'multipart/form-data' content type." + case .missingMultipartBoundaryContentTypeParameter: + return "Missing 'boundary' parameter in the 'multipart/form-data' content type." case .transportFailed: return "Transport threw an error." case .middlewareFailed(middlewareType: let type, _): return "Middleware of type '\(type)' threw an error." case .handlerFailed: return "User handler threw an error." diff --git a/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift b/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift new file mode 100644 index 00000000..4d8b2f25 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +@_spi(Generated) extension Optional where Wrapped == OpenAPIMIMEType { + + /// Unwraps the boundary parameter from the parsed MIME type. + /// - Returns: The boundary value. + /// - Throws: If self is nil, or if the MIME type isn't a `multipart/form-data` + /// with a boundary parameter. + public func requiredBoundary() throws -> String { + guard let self else { throw RuntimeError.missingRequiredMultipartFormDataContentType } + guard case .concrete(type: "multipart", subtype: "form-data") = self.kind else { + throw RuntimeError.missingRequiredMultipartFormDataContentType + } + guard let boundary = self.parameters["boundary"] else { + throw RuntimeError.missingMultipartBoundaryContentTypeParameter + } + return boundary + } +} diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 135bdf46..99b6dc72 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -175,6 +175,28 @@ final class Test_ClientConverterExtensions: Test_Runtime { XCTAssertEqual(headerFields, [.contentType: "application/octet-stream"]) } + // | client | set | request body | multipart | required | setRequiredRequestBodyAsMultipart | + func test_setRequiredRequestBodyAsMultipart() async throws { + let multipartBody: MultipartBody = .init(MultipartTestPart.all) + var headerFields: HTTPFields = [:] + let body = try converter.setRequiredRequestBodyAsMultipart( + multipartBody, + headerFields: &headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in part.rawPart } + ) + try await XCTAssertEqualData(body, testMultipartStringBytes) + XCTAssertEqual( + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + } + // | client | get | response body | JSON | required | getResponseBodyAsJSON | func test_getResponseBodyAsJSON_codable() async throws { let value: TestPet = try await converter.getResponseBodyAsJSON( @@ -194,6 +216,24 @@ final class Test_ClientConverterExtensions: Test_Runtime { ) try await XCTAssertEqualStringifiedData(value, testString) } + // | client | get | response body | multipart | required | getResponseBodyAsMultipart | + func test_getResponseBodyAsMultipart() async throws { + let value = try converter.getResponseBodyAsMultipart( + MultipartBody.self, + from: .init(testMultipartStringBytes), + boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + transforming: { $0 }, + decoding: { part in try await .init(part) } + ) + var parts: [MultipartTestPart] = [] + for try await part in value { parts.append(part) } + XCTAssertEqual(parts, MultipartTestPart.all) + } } /// Asserts that the string representation of binary data is equal to an expected string. diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index da68208f..bca29837 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -107,6 +107,37 @@ final class Test_CommonConverterExtensions: Test_Runtime { ) } + func testVerifyContentTypeIfPresent() throws { + func testCase(received: String?, match: String, file: StaticString = #file, line: UInt = #line) throws { + let headerFields: HTTPFields + if let received { headerFields = [.contentType: received] } else { headerFields = [:] } + try converter.verifyContentTypeIfPresent(in: headerFields, matches: match) + } + try testCase(received: nil, match: "application/json") + try testCase(received: "application/json", match: "application/json") + try testCase(received: "application/json", match: "application/*") + try testCase(received: "application/json", match: "*/*") + } + + func testExtractContentDispositionNameAndFilename() throws { + func testCase(value: String?, name: String?, filename: String?, file: StaticString = #file, line: UInt = #line) + throws + { + let headerFields: HTTPFields + if let value { headerFields = [.contentDisposition: value] } else { headerFields = [:] } + let (actualName, actualFilename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + XCTAssertEqual(actualName, name, file: file, line: line) + XCTAssertEqual(actualFilename, filename, file: file, line: line) + } + try testCase(value: nil, name: nil, filename: nil) + try testCase(value: "form-data", name: nil, filename: nil) + try testCase(value: "form-data; filename=\"foo.txt\"", name: nil, filename: "foo.txt") + try testCase(value: "form-data; name=\"Foo and Bar\"", name: "Foo and Bar", filename: nil) + try testCase(value: "form-data; filename=foo.txt", name: nil, filename: "foo.txt") + try testCase(value: "form-data; name=Foo", name: "Foo", filename: nil) + try testCase(value: "form-data; filename=\"foo.txt\"; name=\"Foo\"", name: "Foo", filename: "foo.txt") + } + // MARK: Converter helper methods // | common | set | header field | URI | both | setHeaderFieldAsURI | diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 91525af4..7ec59ae6 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -288,6 +288,25 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(body, testString) } + // | server | get | request body | multipart | required | getRequiredRequestBodyAsMultipart | + func test_getRequiredRequestBodyAsMultipart() async throws { + let value = try converter.getRequiredRequestBodyAsMultipart( + MultipartBody.self, + from: .init(testMultipartStringBytes), + boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + transforming: { $0 }, + decoding: { part in try await .init(part) } + ) + var parts: [MultipartTestPart] = [] + for try await part in value { parts.append(part) } + XCTAssertEqual(parts, MultipartTestPart.all) + } + // | server | set | response body | JSON | required | setResponseBodyAsJSON | func test_setResponseBodyAsJSON_codable() async throws { var headers: HTTPFields = [:] @@ -311,4 +330,26 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(data, testString) XCTAssertEqual(headers, [.contentType: "application/octet-stream"]) } + + // | server | set | response body | multipart | required | setResponseBodyAsMultipart | + func test_setResponseBodyAsMultipart() async throws { + let multipartBody: MultipartBody = .init(MultipartTestPart.all) + var headerFields: HTTPFields = [:] + let body = try converter.setResponseBodyAsMultipart( + multipartBody, + headerFields: &headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in part.rawPart } + ) + try await XCTAssertEqualData(body, testMultipartStringBytes) + XCTAssertEqual( + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 2e6d386e..0d7d108e 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@_spi(Generated) @testable import OpenAPIRuntime import HTTPTypes class Test_Runtime: XCTestCase { @@ -26,7 +26,7 @@ class Test_Runtime: XCTestCase { var serverURL: URL { get throws { try URL(validatingOpenAPIServerURL: "/api") } } - var configuration: Configuration { .init() } + var configuration: Configuration { .init(multipartBoundaryGenerator: .constant) } var converter: Converter { .init(configuration: configuration) } @@ -52,6 +52,34 @@ class Test_Runtime: XCTestCase { var testStringData: Data { Data(testString.utf8) } + var testMultipartString: String { "hello" } + + var testMultipartStringBytes: ArraySlice { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="foo.txt"; name="hello""#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-length: 5"#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "hello".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="bar.txt"; name="world""#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-length: 5"#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "world".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__--".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + return ArraySlice(bytes) + } + var testQuotedString: String { "\"hello\"" } var testQuotedStringData: Data { Data(testQuotedString.utf8) } @@ -196,6 +224,31 @@ enum TestHabitat: String, Codable, Equatable { case air } +enum MultipartTestPart: Hashable { + case hello(payload: String, filename: String?) + case world(payload: String, filename: String?) + var rawPart: MultipartRawPart { + switch self { + case .hello(let payload, let filename): + return .init(name: "hello", filename: filename, headerFields: [:], body: .init(payload)) + case .world(let payload, let filename): + return .init(name: "world", filename: filename, headerFields: [:], body: .init(payload)) + } + } + init(_ rawPart: MultipartRawPart) async throws { + switch rawPart.name { + case "hello": + self = .hello(payload: try await String(collecting: rawPart.body, upTo: .max), filename: rawPart.filename) + case "world": + self = .world(payload: try await String(collecting: rawPart.body, upTo: .max), filename: rawPart.filename) + default: preconditionFailure("Unexpected part: \(rawPart.name ?? "")") + } + } + static var all: [MultipartTestPart] { + [.hello(payload: "hello", filename: "foo.txt"), .world(payload: "world", filename: "bar.txt")] + } +} + /// Injects an authentication header to every request. struct AuthenticationMiddleware: ClientMiddleware { @@ -292,6 +345,7 @@ fileprivate extension UInt8 { return String(format: "%02x \(original)", self) } } + /// Asserts that the data matches the expected value. public func XCTAssertEqualData( _ expression1: @autoclosure () throws -> C1?, @@ -339,3 +393,19 @@ public func XCTAssertEqualData( ) } catch { XCTFail(error.localizedDescription, file: file, line: line) } } + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> C, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) async throws where C.Element == UInt8 { + guard let actualBytesBody = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let actualBytes = try await [UInt8](collecting: actualBytesBody, upTo: .max) + XCTAssertEqualData(actualBytes, try expression2(), file: file, line: line) +} From 6112714ec3c5e9cfdc3501167f2a4598263815c0 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 22 Nov 2023 16:41:51 +0100 Subject: [PATCH 7/8] Move converter parameters for easier generation --- Sources/OpenAPIRuntime/Conversion/Converter+Client.swift | 4 ++-- Sources/OpenAPIRuntime/Conversion/Converter+Server.swift | 4 ++-- .../Conversion/Test_Converter+Client.swift | 2 +- .../Conversion/Test_Converter+Server.swift | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index fb292c7e..ea575002 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -299,6 +299,7 @@ extension Converter { /// - Parameters: /// - type: The type representing the type-safe multipart body. /// - data: The HTTP body data to transform. + /// - transform: A closure that transforms the multipart body into the output type. /// - boundary: The multipart boundary string. /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names /// should be pass through. If `false`, encountering an unknown part throws an error @@ -307,20 +308,19 @@ extension Converter { /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. /// - atMostOncePartNames: The list of part names that can appear at most once. /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. - /// - transform: A closure that transforms the multipart body into the output type. /// - decoder: A closure that parses a raw part into a type-safe part. /// - Returns: A value of the output type. /// - Throws: If the transform closure throws. public func getResponseBodyAsMultipart( _ type: MultipartBody.Type, from data: HTTPBody?, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, boundary: String, allowsUnknownParts: Bool, requiredExactlyOncePartNames: Set, requiredAtLeastOncePartNames: Set, atMostOncePartNames: Set, zeroOrMoreTimesPartNames: Set, - transforming transform: @escaping @Sendable (MultipartBody) throws -> C, decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part ) throws -> C { guard let data else { throw RuntimeError.missingRequiredResponseBody } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index d1094c66..e8f36306 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -289,6 +289,7 @@ extension Converter { /// - Parameters: /// - type: The type representing the type-safe multipart body. /// - data: The HTTP body data to transform. + /// - transform: A closure that transforms the multipart body into the output type. /// - boundary: The multipart boundary string. /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names /// should be pass through. If `false`, encountering an unknown part throws an error @@ -297,20 +298,19 @@ extension Converter { /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. /// - atMostOncePartNames: The list of part names that can appear at most once. /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. - /// - transform: A closure that transforms the multipart body into the output type. /// - decoder: A closure that parses a raw part into a type-safe part. /// - Returns: A value of the output type. /// - Throws: If the transform closure throws. public func getRequiredRequestBodyAsMultipart( _ type: MultipartBody.Type, from data: HTTPBody?, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, boundary: String, allowsUnknownParts: Bool, requiredExactlyOncePartNames: Set, requiredAtLeastOncePartNames: Set, atMostOncePartNames: Set, zeroOrMoreTimesPartNames: Set, - transforming transform: @escaping @Sendable (MultipartBody) throws -> C, decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part ) throws -> C { guard let data else { throw RuntimeError.missingRequiredRequestBody } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 99b6dc72..57c11580 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -221,13 +221,13 @@ final class Test_ClientConverterExtensions: Test_Runtime { let value = try converter.getResponseBodyAsMultipart( MultipartBody.self, from: .init(testMultipartStringBytes), + transforming: { $0 }, boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", allowsUnknownParts: true, requiredExactlyOncePartNames: ["hello"], requiredAtLeastOncePartNames: ["world"], atMostOncePartNames: [], zeroOrMoreTimesPartNames: [], - transforming: { $0 }, decoding: { part in try await .init(part) } ) var parts: [MultipartTestPart] = [] diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 7ec59ae6..d70a58d7 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -293,13 +293,13 @@ final class Test_ServerConverterExtensions: Test_Runtime { let value = try converter.getRequiredRequestBodyAsMultipart( MultipartBody.self, from: .init(testMultipartStringBytes), + transforming: { $0 }, boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", allowsUnknownParts: true, requiredExactlyOncePartNames: ["hello"], requiredAtLeastOncePartNames: ["world"], atMostOncePartNames: [], zeroOrMoreTimesPartNames: [], - transforming: { $0 }, decoding: { part in try await .init(part) } ) var parts: [MultipartTestPart] = [] From 35b0c106b1d6c5098fe4c6ff9dea7d5d056aba55 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 24 Nov 2023 11:24:28 +0100 Subject: [PATCH 8/8] PR feedback --- Sources/OpenAPIRuntime/Deprecated/Deprecated.swift | 10 ++++++++++ Sources/OpenAPIRuntime/Interface/HTTPBody.swift | 8 -------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 087532eb..5dfee0b0 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -206,3 +206,13 @@ extension Configuration { self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: .random) } } + +extension HTTPBody { + /// Describes how many times the provided sequence can be iterated. + @available( + *, + deprecated, + renamed: "IterationBehavior", + message: "Use the top level IterationBehavior directly instead of HTTPBody.IterationBehavior." + ) public typealias IterationBehavior = OpenAPIRuntime.IterationBehavior +} diff --git a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index 4486ca12..eb163459 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -121,14 +121,6 @@ public final class HTTPBody: @unchecked Sendable { /// The underlying byte chunk type. public typealias ByteChunk = ArraySlice - /// Describes how many times the provided sequence can be iterated. - @available( - *, - deprecated, - renamed: "IterationBehavior", - message: "Use the top level IterationBehavior directly instead of HTTPBody.IterationBehavior." - ) public typealias IterationBehavior = OpenAPIRuntime.IterationBehavior - /// The iteration behavior, which controls how many times /// the input sequence can be iterated. public let iterationBehavior: OpenAPIRuntime.IterationBehavior