diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..5058afd --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentation": { + "spaces": 4 + }, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "lineLength": 140, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + }, + "spacesAroundRangeFormationOperators": false, + "tabWidth": 4, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 4e9210c..bcb6e7f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,34 +1,33 @@ -// swift-tools-version:5.7 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "multipart-kit", platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .tvOS(.v13), - .watchOS(.v6), + .macOS(.v13), + .iOS(.v15), + .tvOS(.v15), + .watchOS(.v8), ], products: [ - .library(name: "MultipartKit", targets: ["MultipartKit"]), + .library(name: "MultipartKit", targets: ["MultipartKit"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), ], targets: [ .target( name: "MultipartKit", dependencies: [ - .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "Collections", package: "swift-collections"), ] ), .testTarget( name: "MultipartKitTests", dependencies: [ - .target(name: "MultipartKit"), + .target(name: "MultipartKit") ] ), ] diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index 365360c..0000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,43 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "multipart-kit", - platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .tvOS(.v13), - .watchOS(.v6), - ], - products: [ - .library(name: "MultipartKit", targets: ["MultipartKit"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), - ], - targets: [ - .target( - name: "MultipartKit", - dependencies: [ - .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOHTTP1", package: "swift-nio"), - .product(name: "Collections", package: "swift-collections"), - ], - swiftSettings: [ - .enableUpcomingFeature("ExistentialAny"), - .enableExperimentalFeature("StrictConcurrency=complete"), - ] - ), - .testTarget( - name: "MultipartKitTests", - dependencies: [ - .target(name: "MultipartKit"), - ], - swiftSettings: [ - .enableUpcomingFeature("ExistentialAny"), - .enableExperimentalFeature("StrictConcurrency=complete"), - ] - ), - ] -) diff --git a/Sources/MultipartKit/Deprecated/MultipartError.swift b/Sources/MultipartKit/Deprecated/MultipartError.swift deleted file mode 100644 index eae2175..0000000 --- a/Sources/MultipartKit/Deprecated/MultipartError.swift +++ /dev/null @@ -1,11 +0,0 @@ -@available(*, deprecated) -public enum MultipartError: Error, CustomStringConvertible { - case invalidFormat - case convertibleType(Any.Type) - case convertiblePart(Any.Type, MultipartPart) - case nesting - case missingPart(String) - case missingFilename - - public var description: String { "" } -} diff --git a/Sources/MultipartKit/Exports.swift b/Sources/MultipartKit/Exports.swift deleted file mode 100644 index 8c5472f..0000000 --- a/Sources/MultipartKit/Exports.swift +++ /dev/null @@ -1,13 +0,0 @@ -#if swift(>=5.8) - -@_documentation(visibility: internal) @_exported import protocol Foundation.DataProtocol -@_documentation(visibility: internal) @_exported import struct NIO.ByteBuffer -@_documentation(visibility: internal) @_exported import struct NIOHTTP1.HTTPHeaders - -#else - -@_exported import protocol Foundation.DataProtocol -@_exported import struct NIO.ByteBuffer -@_exported import struct NIOHTTP1.HTTPHeaders - -#endif diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.Decoder.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+Decoder.swift similarity index 61% rename from Sources/MultipartKit/FormDataDecoder/FormDataDecoder.Decoder.swift rename to Sources/MultipartKit/FormDataDecoder/FormDataDecoder+Decoder.swift index cbc3ae4..e2d1f03 100644 --- a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.Decoder.swift +++ b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+Decoder.swift @@ -1,15 +1,23 @@ extension FormDataDecoder { - struct Decoder { + struct Decoder { let codingPath: [any CodingKey] - let data: MultipartFormData - let userInfo: [CodingUserInfoKey: Any] + let data: MultipartFormData + let sendableUserInfo: [CodingUserInfoKey: any Sendable] let previousCodingPath: [any CodingKey]? let previousType: (any Decodable.Type)? - init(codingPath: [any CodingKey], data: MultipartFormData, userInfo: [CodingUserInfoKey: Any], previousCodingPath: [any CodingKey]? = nil, previousType: (any Decodable.Type)? = nil) { + var userInfo: [CodingUserInfoKey: Any] { sendableUserInfo } + + init( + codingPath: [any CodingKey], + data: MultipartFormData, + userInfo: [CodingUserInfoKey: any Sendable] = [:], + previousCodingPath: [any CodingKey]? = nil, + previousType: (any Decodable.Type)? = nil + ) { self.codingPath = codingPath self.data = data - self.userInfo = userInfo + self.sendableUserInfo = userInfo self.previousCodingPath = previousCodingPath self.previousType = previousType } @@ -37,41 +45,42 @@ extension FormDataDecoder.Decoder: Decoder { } extension FormDataDecoder.Decoder { - func nested(at key: any CodingKey, with data: MultipartFormData) -> Self { - .init(codingPath: codingPath + [key], data: data, userInfo: userInfo) + func nested(at key: any CodingKey, with data: MultipartFormData) -> Self { + .init(codingPath: codingPath + [key], data: data, userInfo: sendableUserInfo) } } -private extension FormDataDecoder.Decoder { - func decodingError(expectedType: String) -> any Error { +extension FormDataDecoder.Decoder { + fileprivate func decodingError(expectedType: String) -> any Error { let encounteredType: Any.Type let encounteredTypeDescription: String switch data { case .nestingDepthExceeded: - return DecodingError.dataCorrupted(.init( - codingPath: codingPath, - debugDescription: "Nesting depth exceeded while expecting \(expectedType).", - underlyingError: nil - )) + return DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Nesting depth exceeded while expecting \(expectedType).", + underlyingError: nil + )) case .array: - encounteredType = [MultipartFormData].self + encounteredType = [MultipartFormData].self encounteredTypeDescription = "array" case .keyed: - encounteredType = MultipartFormData.Keyed.self + encounteredType = MultipartFormData.Keyed.self encounteredTypeDescription = "dictionary" case .single: - encounteredType = MultipartPart.self + encounteredType = MultipartPart.self encounteredTypeDescription = "single value" } return DecodingError.typeMismatch( encounteredType, - .init( - codingPath: codingPath, - debugDescription: "Expected \(expectedType) but encountered \(encounteredTypeDescription).", - underlyingError: nil - ) + .init( + codingPath: codingPath, + debugDescription: "Expected \(expectedType) but encountered \(encounteredTypeDescription).", + underlyingError: nil + ) ) } } diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.KeyedContainer.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+KeyedContainer.swift similarity index 85% rename from Sources/MultipartKit/FormDataDecoder/FormDataDecoder.KeyedContainer.swift rename to Sources/MultipartKit/FormDataDecoder/FormDataDecoder+KeyedContainer.swift index e523e23..53919ea 100644 --- a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.KeyedContainer.swift +++ b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+KeyedContainer.swift @@ -1,7 +1,7 @@ extension FormDataDecoder { - struct KeyedContainer { - let data: MultipartFormData.Keyed - let decoder: FormDataDecoder.Decoder + struct KeyedContainer { + let data: MultipartFormData.Keyed + let decoder: FormDataDecoder.Decoder } } @@ -18,10 +18,11 @@ extension FormDataDecoder.KeyedContainer: KeyedDecodingContainerProtocol { data.keys.contains(key.stringValue) } - func getValue(forKey key: any CodingKey) throws -> MultipartFormData { + func getValue(forKey key: any CodingKey) throws -> MultipartFormData { guard let value = data[key.stringValue] else { throw DecodingError.keyNotFound( - key, .init( + key, + DecodingError.Context( codingPath: codingPath, debugDescription: "No value associated with key \"\(key.stringValue)\"." ) @@ -54,7 +55,7 @@ extension FormDataDecoder.KeyedContainer: KeyedDecodingContainerProtocol { try decoderForKey(key) } - func decoderForKey(_ key: any CodingKey) throws -> FormDataDecoder.Decoder { + func decoderForKey(_ key: any CodingKey) throws -> FormDataDecoder.Decoder { decoder.nested(at: key, with: try getValue(forKey: key)) } } diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+SingleValueContainer.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+SingleValueContainer.swift new file mode 100644 index 0000000..2ba2311 --- /dev/null +++ b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+SingleValueContainer.swift @@ -0,0 +1,63 @@ +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +extension FormDataDecoder.Decoder: SingleValueDecodingContainer { + func decodeNil() -> Bool { + false + } + + func decode(_: T.Type = T.self) throws -> T { + guard let part = data.part else { + guard previousCodingPath?.count != codingPath.count || previousType != T.self else { + throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Decoding caught in recursion loop")) + } + + return try T( + from: FormDataDecoder.Decoder( + codingPath: codingPath, data: data, userInfo: sendableUserInfo, previousCodingPath: codingPath, previousType: T.self + ) + ) + } + + let decoded = + switch T.self { + case is MultipartPart.Type: + part as? T + case is String.Type: + String(bytes: part.body, encoding: .utf8) as? T + case let IntType as any FixedWidthInteger.Type: + String(bytes: part.body, encoding: .utf8).flatMap(IntType.init) as? T + case is Float.Type: + String(bytes: part.body, encoding: .utf8).flatMap(Float.init) as? T + case is Double.Type: + String(bytes: part.body, encoding: .utf8).flatMap(Double.init) as? T + case is Bool.Type: + String(bytes: part.body, encoding: .utf8).flatMap(Bool.init) as? T + case is Data.Type: + Data(part.body) as? T + case is URL.Type: + String(bytes: part.body, encoding: .utf8).flatMap(URL.init(string:)) as? T + default: + T?.none + } + + guard let decoded else { + guard !data.hasExceededNestingDepth else { + throw DecodingError.dataCorrupted( + .init(codingPath: codingPath, debugDescription: "Nesting depth exceeded.", underlyingError: nil) + ) + } + + return try T( + from: FormDataDecoder.Decoder( + codingPath: codingPath, data: data, userInfo: sendableUserInfo, previousCodingPath: codingPath, previousType: T.self + ) + ) + } + + return decoded + } +} diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.UnkeyedContainer.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+UnkeyedContainer.swift similarity index 83% rename from Sources/MultipartKit/FormDataDecoder/FormDataDecoder.UnkeyedContainer.swift rename to Sources/MultipartKit/FormDataDecoder/FormDataDecoder+UnkeyedContainer.swift index d4779bd..ed900d5 100644 --- a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.UnkeyedContainer.swift +++ b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder+UnkeyedContainer.swift @@ -1,8 +1,8 @@ extension FormDataDecoder { - struct UnkeyedContainer { + struct UnkeyedContainer { var currentIndex: Int = 0 - let data: [MultipartFormData] - let decoder: FormDataDecoder.Decoder + let data: [MultipartFormData] + let decoder: FormDataDecoder.Decoder } } @@ -34,15 +34,15 @@ extension FormDataDecoder.UnkeyedContainer: UnkeyedDecodingContainer { try decoderAtIndex() } - mutating func decoderAtIndex() throws -> FormDataDecoder.Decoder { + mutating func decoderAtIndex() throws -> FormDataDecoder.Decoder { defer { currentIndex += 1 } return try decoder.nested(at: index, with: getValue()) } - mutating func getValue() throws -> MultipartFormData { + mutating func getValue() throws -> MultipartFormData { guard !isAtEnd else { throw DecodingError.valueNotFound( - FormDataDecoder.Decoder.self, + FormDataDecoder.Decoder.self, .init( codingPath: codingPath, debugDescription: "Unkeyed container is at end.", diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.SingleValueContainer.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.SingleValueContainer.swift deleted file mode 100644 index 5108ce2..0000000 --- a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.SingleValueContainer.swift +++ /dev/null @@ -1,34 +0,0 @@ -extension FormDataDecoder.Decoder: SingleValueDecodingContainer { - func decodeNil() -> Bool { - false - } - - func decode(_: T.Type = T.self) throws -> T { - guard - let part = data.part, - let Convertible = T.self as? any MultipartPartConvertible.Type - else { - guard previousCodingPath?.count != codingPath.count || previousType != T.self else { - throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Decoding caught in recursion loop")) - } - return try T(from: FormDataDecoder.Decoder(codingPath: codingPath, data: data, userInfo: userInfo, previousCodingPath: codingPath, previousType: T.self)) - } - - guard !data.hasExceededNestingDepth else { - throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Nesting depth exceeded.", underlyingError: nil)) - } - - guard - let decoded = Convertible.init(multipart: part) as? T - else { - let path = codingPath.map(\.stringValue).joined(separator: ".") - throw DecodingError.dataCorrupted( - .init( - codingPath: codingPath, - debugDescription: #"Could not convert value at "\#(path)" to type \#(T.self) from multipart part."# - ) - ) - } - return decoded - } -} diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift index a793ed8..c044719 100644 --- a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift +++ b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift @@ -1,11 +1,10 @@ -import NIOCore -import NIOHTTP1 +import HTTPTypes -/// Decodes `Decodable` types from `multipart/form-data` encoded `Data`. +/// Decodes `Decodable` types from `multipart/form-data` encoded data. /// /// See [RFC#2388](https://tools.ietf.org/html/rfc2388) for more information about `multipart/form-data` encoding. /// -/// Seealso `MultipartParser` for more information about the `multipart` encoding. +/// - Seealso: ``MultipartParser`` for more information about the `multipart` encoding. public struct FormDataDecoder: Sendable { /// Maximum nesting depth to allow when decoding the input. @@ -29,61 +28,34 @@ public struct FormDataDecoder: Sendable { /// /// - Parameters: /// - decodable: Generic `Decodable` type. - /// - data: String to decode. + /// - data: `String` to decode. /// - boundary: Multipart boundary to used in the decoding. /// - Throws: Any errors decoding the model with `Codable` or parsing the data. /// - Returns: An instance of the decoded type `D`. - public func decode(_ decodable: D.Type, from data: String, boundary: String) throws -> D { - try decode(D.self, from: ByteBuffer(string: data), boundary: boundary) + public func decode(_ decodable: D.Type, from string: String, boundary: String) throws -> D { + try decode(D.self, from: Array(string.utf8), boundary: boundary) } - /// Decodes a `Decodable` item from `Data` using the supplied boundary. + /// Decodes a `Decodable` item from some``MultipartPartBodyElement`` using the supplied boundary. /// /// let foo = try FormDataDecoder().decode(Foo.self, from: data, boundary: "123") /// /// - Parameters: /// - decodable: Generic `Decodable` type. - /// - data: Data to decode. + /// - data: some ``MultipartPartBodyElement`` to decode. /// - boundary: Multipart boundary to used in the decoding. /// - Throws: Any errors decoding the model with `Codable` or parsing the data. /// - Returns: An instance of the decoded type `D`. - public func decode(_ decodable: D.Type, from data: [UInt8], boundary: String) throws -> D { - try decode(D.self, from: ByteBuffer(bytes: data), boundary: boundary) - } - - /// Decodes a `Decodable` item from `Data` using the supplied boundary. - /// - /// let foo = try FormDataDecoder().decode(Foo.self, from: data, boundary: "123") - /// - /// - Parameters: - /// - decodable: Generic `Decodable` type. - /// - data: Data to decode. - /// - boundary: Multipart boundary to used in the decoding. - /// - Throws: Any errors decoding the model with `Codable` or parsing the data. - /// - Returns: An instance of the decoded type `D`. - public func decode(_ decodable: D.Type, from buffer: ByteBuffer, boundary: String) throws -> D { - let parser = MultipartParser(boundary: boundary) - - var parts: [MultipartPart] = [] - var headers: HTTPHeaders = .init() - var body: ByteBuffer = ByteBuffer() - - parser.onHeader = { (field, value) in - headers.replaceOrAdd(name: field, value: value) - } - parser.onBody = { new in - body.writeBuffer(&new) - } - parser.onPartComplete = { - let part = MultipartPart(headers: headers, body: body) - headers = [:] - body = ByteBuffer() - parts.append(part) - } - - try parser.execute(buffer) + public func decode( + _ decodable: D.Type, + from buffer: Body, + boundary: String + ) + throws -> D where Body: RangeReplaceableCollection, Body.SubSequence: Equatable & Sendable + { + let parts = try MultipartParser(boundary: boundary).parse(buffer) let data = MultipartFormData(parts: parts, nestingDepth: nestingDepth) - let decoder = Decoder(codingPath: [], data: data, userInfo: userInfo) - return try decoder.decode() + let decoder = FormDataDecoder.Decoder(codingPath: [], data: data, userInfo: userInfo) + return try decoder.decode(D.self) } } diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+Encoder.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+Encoder.swift new file mode 100644 index 0000000..c22a4f5 --- /dev/null +++ b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+Encoder.swift @@ -0,0 +1,38 @@ +extension FormDataEncoder { + struct Encoder where Body: RangeReplaceableCollection { + let codingPath: [any CodingKey] + let storage = Storage() + let sendableUserInfo: [CodingUserInfoKey: any Sendable] + + var userInfo: [CodingUserInfoKey: Any] { sendableUserInfo } + + init(codingPath: [any CodingKey] = [], userInfo: [CodingUserInfoKey: any Sendable] = [:]) { + self.codingPath = codingPath + self.sendableUserInfo = userInfo + } + } +} + +extension FormDataEncoder.Encoder: Encoder { + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + let container = FormDataEncoder.KeyedContainer(encoder: self) + storage.dataContainer = container.dataContainer + return .init(container) + } + + func unkeyedContainer() -> any UnkeyedEncodingContainer { + let container = FormDataEncoder.UnkeyedContainer(encoder: self) + storage.dataContainer = container.dataContainer + return container + } + + func singleValueContainer() -> any SingleValueEncodingContainer { + self + } +} + +extension FormDataEncoder.Encoder { + func nested(at key: any CodingKey) -> FormDataEncoder.Encoder { + .init(codingPath: codingPath + [key], userInfo: sendableUserInfo) + } +} diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.KeyedContainer.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+KeyedContainer.swift similarity index 83% rename from Sources/MultipartKit/FormDataEncoder/FormDataEncoder.KeyedContainer.swift rename to Sources/MultipartKit/FormDataEncoder/FormDataEncoder+KeyedContainer.swift index e72ec72..e15e24f 100644 --- a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.KeyedContainer.swift +++ b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+KeyedContainer.swift @@ -1,7 +1,7 @@ extension FormDataEncoder { - struct KeyedContainer { - let dataContainer = KeyedDataContainer() - let encoder: Encoder + struct KeyedContainer where Body: RangeReplaceableCollection { + let dataContainer = KeyedDataContainer() + let encoder: Encoder } } @@ -34,7 +34,7 @@ extension FormDataEncoder.KeyedContainer: KeyedEncodingContainerProtocol { encoderForKey(key) } - func encoderForKey(_ key: any CodingKey) -> FormDataEncoder.Encoder { + func encoderForKey(_ key: any CodingKey) -> FormDataEncoder.Encoder { let encoder = self.encoder.nested(at: key) dataContainer.value[key.stringValue] = encoder.storage return encoder diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+SingleValueContainer.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+SingleValueContainer.swift new file mode 100644 index 0000000..e5876e5 --- /dev/null +++ b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+SingleValueContainer.swift @@ -0,0 +1,34 @@ +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +extension FormDataEncoder.Encoder: SingleValueEncodingContainer { + func encodeNil() throws { + // skip + } + + func encode(_ value: T) throws { + switch value { + case let multipart as MultipartPart: + storage.dataContainer = SingleValueDataContainer(part: multipart) + case let string as String: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(string.utf8))) + case let int as any FixedWidthInteger: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(int.description.utf8))) + case let float as Float: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(float.description.utf8))) + case let double as Double: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(double.description.utf8))) + case let bool as Bool: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(bool.description.utf8))) + case let data as Data: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(data))) + case let url as URL: + storage.dataContainer = SingleValueDataContainer(part: .init(headerFields: [:], body: Body(url.absoluteString.utf8))) + default: + try value.encode(to: self) + } + } +} diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.UnkeyedContainer.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+UnkeyedContainer.swift similarity index 77% rename from Sources/MultipartKit/FormDataEncoder/FormDataEncoder.UnkeyedContainer.swift rename to Sources/MultipartKit/FormDataEncoder/FormDataEncoder+UnkeyedContainer.swift index 6344a2f..dbe1f7e 100644 --- a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.UnkeyedContainer.swift +++ b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder+UnkeyedContainer.swift @@ -1,7 +1,7 @@ extension FormDataEncoder { - struct UnkeyedContainer { - let dataContainer = UnkeyedDataContainer() - let encoder: FormDataEncoder.Encoder + struct UnkeyedContainer where Body: RangeReplaceableCollection { + let dataContainer = UnkeyedDataContainer() + let encoder: FormDataEncoder.Encoder } } @@ -34,7 +34,7 @@ extension FormDataEncoder.UnkeyedContainer: UnkeyedEncodingContainer { nextEncoder() } - func nextEncoder() -> FormDataEncoder.Encoder { + func nextEncoder() -> FormDataEncoder.Encoder { let encoder = self.encoder.nested(at: BasicCodingKey.index(count)) dataContainer.value.append(encoder.storage) return encoder diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.Encoder.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.Encoder.swift deleted file mode 100644 index 7e0a41c..0000000 --- a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.Encoder.swift +++ /dev/null @@ -1,31 +0,0 @@ -extension FormDataEncoder { - struct Encoder { - let codingPath: [any CodingKey] - let storage = Storage() - let userInfo: [CodingUserInfoKey: Any] - } -} - -extension FormDataEncoder.Encoder: Encoder { - func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { - let container = FormDataEncoder.KeyedContainer(encoder: self) - storage.dataContainer = container.dataContainer - return .init(container) - } - - func unkeyedContainer() -> any UnkeyedEncodingContainer { - let container = FormDataEncoder.UnkeyedContainer(encoder: self) - storage.dataContainer = container.dataContainer - return container - } - - func singleValueContainer() -> any SingleValueEncodingContainer { - self - } -} - -extension FormDataEncoder.Encoder { - func nested(at key: any CodingKey) -> FormDataEncoder.Encoder { - .init(codingPath: codingPath + [key], userInfo: userInfo) - } -} diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.SingleValueContainer.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.SingleValueContainer.swift deleted file mode 100644 index d5c9f60..0000000 --- a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.SingleValueContainer.swift +++ /dev/null @@ -1,16 +0,0 @@ -extension FormDataEncoder.Encoder: SingleValueEncodingContainer { - func encodeNil() throws { - // skip - } - - func encode(_ value: T) throws { - if - let convertible = value as? any MultipartPartConvertible, - let part = convertible.multipart - { - storage.dataContainer = SingleValueDataContainer(part: part) - } else { - try value.encode(to: self) - } - } -} diff --git a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.swift b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.swift index 2579ff7..00e47df 100644 --- a/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.swift +++ b/Sources/MultipartKit/FormDataEncoder/FormDataEncoder.swift @@ -1,17 +1,14 @@ -import NIOCore - /// Encodes `Encodable` items to `multipart/form-data` encoded `Data`. /// /// See [RFC#2388](https://tools.ietf.org/html/rfc2388) for more information about `multipart/form-data` encoding. /// -/// Seealso `MultipartParser` for more information about the `multipart` encoding. +/// - Seealso: ``MultipartParser`` for more information about the `multipart` encoding. public struct FormDataEncoder: Sendable { - /// Any contextual information set by the user for encoding. public var userInfo: [CodingUserInfoKey: any Sendable] = [:] /// Creates a new `FormDataEncoder`. - public init() { } + public init() {} /// Encodes an `Encodable` item to `String` using the supplied boundary. /// @@ -24,10 +21,11 @@ public struct FormDataEncoder: Sendable { /// - throws: Any errors encoding the model with `Codable` or serializing the data. /// - returns: `multipart/form-data`-encoded `String`. public func encode(_ encodable: E, boundary: String) throws -> String { - try MultipartSerializer().serialize(parts: parts(from: encodable), boundary: boundary) + let parts: [MultipartPart<[UInt8]>] = try self.parts(from: encodable) + return try MultipartSerializer(boundary: boundary).serialize(parts: parts) } - /// Encodes an `Encodable` item into a `ByteBuffer` using the supplied boundary. + /// Encodes an `Encodable` item into some ``MultipartPartBodyElement`` using the supplied boundary. /// /// let a = Foo(string: "a", int: 42, double: 3.14, array: [1, 2, 3]) /// var buffer = ByteBuffer() @@ -38,12 +36,18 @@ public struct FormDataEncoder: Sendable { /// - boundary: Multipart boundary to use for encoding. This must not appear anywhere in the encoded data. /// - buffer: Buffer to write to. /// - throws: Any errors encoding the model with `Codable` or serializing the data. - public func encode(_ encodable: E, boundary: String, into buffer: inout ByteBuffer) throws { - try MultipartSerializer().serialize(parts: parts(from: encodable), boundary: boundary, into: &buffer) + public func encode( + _ encodable: E, + boundary: String, + to: Body.Type = Body.self + ) throws -> Body where Body: RangeReplaceableCollection { + let parts: [MultipartPart] = try self.parts(from: encodable) + return try MultipartSerializer(boundary: boundary).serialize(parts: parts) } - private func parts(from encodable: E) throws -> [MultipartPart] { - let encoder = Encoder(codingPath: [], userInfo: userInfo) + private func parts(from encodable: E) throws -> [MultipartPart] + where Body: RangeReplaceableCollection { + let encoder = Encoder(codingPath: [], userInfo: userInfo) try encodable.encode(to: encoder) return encoder.storage.data?.namedParts() ?? [] } diff --git a/Sources/MultipartKit/FormDataEncoder/Storage.swift b/Sources/MultipartKit/FormDataEncoder/Storage.swift index 7a7c01d..ff31ea7 100644 --- a/Sources/MultipartKit/FormDataEncoder/Storage.swift +++ b/Sources/MultipartKit/FormDataEncoder/Storage.swift @@ -1,33 +1,38 @@ import Collections +import Synchronization -final class Storage { - var dataContainer: (any DataContainer)? = nil - var data: MultipartFormData? { +final class Storage { + var dataContainer: (any DataContainer)? + + var data: MultipartFormData? { dataContainer?.data } } -protocol DataContainer { - var data: MultipartFormData { get } +protocol DataContainer { + associatedtype Body: MultipartPartBodyElement + var data: MultipartFormData { get } } -struct SingleValueDataContainer: DataContainer { - init(part: MultipartPart) { +struct SingleValueDataContainer: DataContainer { + init(part: MultipartPart) { data = .single(part) } - let data: MultipartFormData + let data: MultipartFormData } -final class KeyedDataContainer: DataContainer { - var value: OrderedDictionary = [:] - var data: MultipartFormData { +final class KeyedDataContainer: DataContainer { + var value: OrderedDictionary> = [:] + + var data: MultipartFormData { .keyed(value.compactMapValues(\.data)) } } -final class UnkeyedDataContainer: DataContainer { - var value: [Storage] = [] - var data: MultipartFormData { +final class UnkeyedDataContainer: DataContainer { + var value: [Storage] = [] + + var data: MultipartFormData { .array(value.compactMap(\.data)) } } diff --git a/Sources/MultipartKit/MultipartFormData.swift b/Sources/MultipartKit/MultipartFormData.swift index 41d3746..2349fd4 100644 --- a/Sources/MultipartKit/MultipartFormData.swift +++ b/Sources/MultipartKit/MultipartFormData.swift @@ -1,14 +1,15 @@ import Collections +import Foundation -enum MultipartFormData: Equatable, Sendable { +enum MultipartFormData: Equatable, Sendable { typealias Keyed = OrderedDictionary - case single(MultipartPart) + case single(MultipartPart) case array([MultipartFormData]) case keyed(Keyed) case nestingDepthExceeded - init(parts: [MultipartPart], nestingDepth: Int) { + init(parts: [MultipartPart], nestingDepth: Int) { self = parts.reduce(into: .empty) { result, part in result.insert( part, @@ -18,7 +19,9 @@ enum MultipartFormData: Equatable, Sendable { } } - static let empty = MultipartFormData.keyed([:]) + static var empty: Self { + MultipartFormData.keyed([:]) + } var array: [MultipartFormData]? { guard case let .array(array) = self else { return nil } @@ -30,7 +33,7 @@ enum MultipartFormData: Equatable, Sendable { return dict } - var part: MultipartPart? { + var part: MultipartPart? { guard case let .single(part) = self else { return nil } return part } @@ -48,15 +51,16 @@ private func makePath(from string: String) -> ArraySlice { } extension MultipartFormData { - func namedParts() -> [MultipartPart] { + func namedParts() -> [MultipartPart] { Self.namedParts(from: self) } - private static func namedParts(from data: MultipartFormData, path: String? = nil) -> [MultipartPart] { + private static func namedParts(from data: MultipartFormData, path: String? = nil) -> [MultipartPart] { switch data { case .array(let array): return array.enumerated().flatMap { offset, element in - namedParts(from: element, path: path.map { "\($0)[\(offset)]" }) } + namedParts(from: element, path: path.map { "\($0)[\(offset)]" }) + } case .single(var part): part.name = path return [part] @@ -70,12 +74,14 @@ extension MultipartFormData { } } -private extension MultipartFormData { - mutating func insert(_ part: MultipartPart, at path: ArraySlice, remainingNestingDepth: Int) { +extension MultipartFormData { + fileprivate mutating func insert(_ part: MultipartPart, at path: ArraySlice, remainingNestingDepth: Int) { self = inserting(part, at: path, remainingNestingDepth: remainingNestingDepth) } - func inserting(_ part: MultipartPart, at path: ArraySlice, remainingNestingDepth: Int) -> MultipartFormData { + fileprivate func inserting(_ part: MultipartPart, at path: ArraySlice, remainingNestingDepth: Int) + -> MultipartFormData + { guard let head = path.first else { return .single(part) } diff --git a/Sources/MultipartKit/MultipartParser+parse.swift b/Sources/MultipartKit/MultipartParser+parse.swift new file mode 100644 index 0000000..45d49af --- /dev/null +++ b/Sources/MultipartKit/MultipartParser+parse.swift @@ -0,0 +1,57 @@ +import HTTPTypes + +extension MultipartParser { + /// Synchronously parse the multipart data into an array of ``MultipartPart``. + public func parse(_ data: Body) throws -> [MultipartPart] where Body: RangeReplaceableCollection { + var output: [MultipartPart] = [] + var parser = MultipartParser(boundary: self.boundary) + + var currentHeaders: HTTPFields? + var currentBody = Body() + + // Append data to the parser and process the sections + parser.append(buffer: data) + + while true { + switch parser.read() { + case .success(let optionalPart): + switch optionalPart { + case .none: + continue + case .some(let part): + switch part { + case .headerFields(let newFields): + if let headers = currentHeaders { + // Merge multiple header fields into the current headers + currentHeaders = HTTPFields(headers + newFields) + } else { + currentHeaders = newFields + } + case .bodyChunk(let bodyChunk): + // Accumulate body chunks + currentBody.append(contentsOf: bodyChunk) + case .boundary: + // Create a MultipartPart when reaching a boundary + if let headers = currentHeaders { + output.append(MultipartPart(headerFields: headers, body: currentBody)) + } + // Reset for the next part + currentHeaders = nil + currentBody = Body() + } + } + case .needMoreData: + // No more data is available in synchronous parsing, this should never happen + preconditionFailure("More data is needed") + case .error(let error): + throw error + case .finished: + // If finished, add any remaining part + if let headers = currentHeaders { + output.append(MultipartPart(headerFields: headers, body: currentBody)) + } + return output + } + } + } +} diff --git a/Sources/MultipartKit/MultipartParser.swift b/Sources/MultipartKit/MultipartParser.swift index 4a0dace..b0c6096 100644 --- a/Sources/MultipartKit/MultipartParser.swift +++ b/Sources/MultipartKit/MultipartParser.swift @@ -1,318 +1,270 @@ -import NIOCore - -/// Parses multipart-encoded `Data` into `MultipartPart`s. Multipart encoding is a widely-used format for encoding -/// web-form data that includes rich content like files. It allows for arbitrary data to be encoded -/// in each part thanks to a unique delimiter "boundary" that is defined separately. This -/// boundary is guaranteed by the client to not appear anywhere in the data. -/// -/// `multipart/form-data` is a special case of `multipart` encoding where each part contains a `Content-Disposition` -/// header and name. This is used by the `FormDataEncoder` and `FormDataDecoder` to convert `Codable` types to/from -/// multipart data. -/// -/// See [Wikipedia](https://en.wikipedia.org/wiki/MIME#Multipart_messages) for more information. -/// -/// See also `form-urlencoded` encoding where delimiter boundaries are not required. -public final class MultipartParser { - private enum Error: Swift.Error { - case syntax - } - - private enum CRLF { - case cr, lf +import HTTPTypes + +/// Parses any kind of multipart encoded data into ``MultipartSection``s. +public struct MultipartParser where Body: RangeReplaceableCollection { + enum Error: Swift.Error, Equatable { + case invalidBoundary + case invalidHeader(reason: String) + case invalidBody(reason: String) } - private enum HeaderState { - case preHeaders(CRLF = .cr) - case headerName([UInt8] = []) - case headerValue([UInt8] = [], name: [UInt8]) - case postHeaderValue([UInt8], name: [UInt8]) - case postHeaders - } + enum State: Equatable { + enum Part: Equatable { + case boundary + case header + case body + } - private enum State { - case preamble(boundaryMatchIndex: Int = 0) - case headers(state: HeaderState = .preHeaders()) - case body - case boundary(boundaryMatchIndex: Int = 0) - case epilogue + case initial + case parsing(Part, ArraySlice) + case finished } - public var onHeader: (String, String) -> () - public var onBody: (inout ByteBuffer) -> () - public var onPartComplete: () -> () - - private let boundary: [UInt8] - private let boundaryLength: Int + let boundary: ArraySlice private var state: State - private var buffer: ByteBuffer! - /// Creates a new `MultipartParser`. - /// - Parameter boundary: boundary separating parts. Must not be empty nor longer than 70 characters according to rfc1341 but we don't check for the latter. - public init(boundary: String) { - precondition(!boundary.isEmpty) - - self.onHeader = { _, _ in } - self.onBody = { _ in } - self.onPartComplete = { } - - self.boundary = Array("\r\n--\(boundary)".utf8) - self.boundaryLength = self.boundary.count - self.state = .preamble() + init(boundary: some Collection) { + self.boundary = .init(boundary) + self.state = .initial } - public func execute(_ string: String) throws { - try execute(ByteBuffer(string: string)) + public init(boundary: String) { + self.boundary = [45, 45] + ArraySlice(boundary.utf8) + self.state = .initial } - public func execute(_ bytes: [UInt8]) throws { - try execute(ByteBuffer(bytes: bytes)) + enum ReadResult { + case finished + case success(reading: MultipartSection? = nil) + case error(Error) + case needMoreData } - public func execute(_ buffer: ByteBuffer) throws { - self.buffer = buffer - defer { self.buffer = nil } - - try execute() + mutating func append(buffer: Body) { + switch self.state { + case .initial: + self.state = .parsing(.boundary, .init(buffer)) + case .parsing(let part, var existingBuffer): + existingBuffer.append(contentsOf: buffer) + self.state = .parsing(part, existingBuffer) + case .finished: + break + } } - private func execute() throws { - while buffer.readableBytes > 0 { - switch state { - case let .preamble(boundaryMatchIndex): - state = parsePreamble(boundaryMatchIndex: boundaryMatchIndex) - case let .headers(headerState): - state = try parseHeaders(headerState: headerState) + mutating func read() -> ReadResult { + switch self.state { + case .initial: + .needMoreData + case .parsing(let part, let buffer): + switch part { + case .boundary: + parseBoundary(from: buffer) + case .header: + parseHeader(from: buffer) case .body: - state = parseBody() - case let .boundary(boundaryMatchIndex): - state = try parseBoundary(boundaryMatchIndex: boundaryMatchIndex) - case .epilogue: - // ignore any data in epilogue - return + parseBody(from: buffer) } + case .finished: + .finished } } - private func readByte() -> UInt8? { buffer.readInteger() } - - private func parsePreamble(boundaryMatchIndex: Int) -> State { - var boundaryMatchIndex = boundaryMatchIndex - - while boundaryMatchIndex < boundaryLength, let byte = readByte() { - - // allow skipping the initial CRLF - if boundaryMatchIndex == 0, byte == boundary[2] { - boundaryMatchIndex = 3 - // (continues to) match boundary: move on to next index - } else if byte == boundary[boundaryMatchIndex] { - boundaryMatchIndex = boundaryMatchIndex + 1 - // stopped matching boundary but matches with start of boundary: restart at 1 - } else if boundaryMatchIndex > 0, byte == boundary[0] { - boundaryMatchIndex = 1 - // no match at either current position or start of boundary: restart at 0 - } else { - boundaryMatchIndex = 0 + private mutating func parseBoundary(from buffer: ArraySlice) -> ReadResult { + switch buffer.getIndexAfter(boundary) { + case .wrongCharacter: // the boundary is unexpected + return .error(Error.invalidBoundary) + case .prematureEnd: // ask for more data and retry + self.state = .parsing(.boundary, buffer) + return .needMoreData + case let .success(index): + switch buffer[index...].getIndexAfter([45, 45]) { // check if it's the final boundary (ends with "--") + case .success: // if it is, finish + self.state = .finished + return .success(reading: .boundary(end: true)) + case .prematureEnd: + return .needMoreData + case .wrongCharacter: // if it's not, move on to reading headers + self.state = .parsing(.header, buffer[index...]) + return .success(reading: .boundary(end: false)) } } - - if boundaryMatchIndex >= boundaryLength { - return .headers() - } else { - return .preamble(boundaryMatchIndex: boundaryMatchIndex) - } } - private func parseCRLF(_ crlf: CRLF) throws -> CRLF? { - var crlf = crlf - - while let byte = readByte() { - switch (crlf, byte) { - case (.cr, .cr): - crlf = .lf - case (.lf, .lf): - return nil - default: - throw Error.syntax + private mutating func parseBody(from buffer: ArraySlice) -> ReadResult { + // read until CRLF + switch buffer.getFirstRange(of: [13, 10] + boundary) { + case .prematureEnd: // found part of body end, request more data + self.state = .parsing(.body, buffer) + return .needMoreData + case .notFound: // end not in sight, emit body chunk + if buffer.isEmpty { + self.state = .parsing(.body, buffer) + return .needMoreData } + self.state = .parsing(.body, []) + return .success(reading: .bodyChunk(.init(buffer))) + case .success(let range): // end found + let chunk = buffer[.. State { - var headerState = headerState - - while buffer.readableBytes > 0 { - switch headerState { - case let .preHeaders(crlf): - headerState = try parseCRLF(crlf).map(HeaderState.preHeaders) ?? .headerName() - case let .headerName(name): - headerState = try parseHeaderName(name: name) - case let .headerValue(value, name): - headerState = try parseHeaderValue(value, name: name) - case let .postHeaderValue(value, name): - guard readByte() == .lf else { - throw Error.syntax - } - onHeader(String(bytes: name, encoding: .utf8) ?? "", String(bytes: value, encoding: .utf8) ?? "") - headerState = .headerName([]) - case .postHeaders: - guard readByte() == .lf else { - throw Error.syntax - } - return .body - } + private mutating func parseHeader(from buffer: ArraySlice) -> ReadResult { + // check for CRLF + let indexAfterFirstCRLF: ArraySlice.Index + switch buffer.getIndexAfter([13, 10]) { + case .success(let index): + indexAfterFirstCRLF = index + self.state = .parsing(.header, buffer[index...]) + case .wrongCharacter: + return .error(.invalidHeader(reason: "There should be a CRLF here")) + case .prematureEnd: + self.state = .parsing(.header, buffer) + return .needMoreData } - return .headers(state: headerState) - } - - private func parseHeaderName(name: [UInt8]) throws -> HeaderState { - var name = name + // check for second CRLF (end of headers) + switch buffer[indexAfterFirstCRLF...].getIndexAfter([13, 10]) { + case .success(let index): // end of headers found, move to body + self.state = .parsing(.body, buffer[index...]) + return .success() + case .wrongCharacter: // no end of headers + self.state = .parsing(.header, buffer[indexAfterFirstCRLF...]) + case .prematureEnd: // might be end. ask for more data + self.state = .parsing(.header, buffer) + return .needMoreData + } - while let byte = readByte() { - switch byte { - case .colon where !name.isEmpty: - return .headerValue(name: name) - case .cr where name.isEmpty: - return .postHeaders - case _ where byte.isAllowedHeaderFieldNameCharacter: - name.append(byte) - default: - throw Error.syntax - } + // read the header name until ":" or CR + guard + let endOfHeaderNameIndex = buffer[indexAfterFirstCRLF...].firstIndex(where: { element in + element == 58 || element == 13 // ":" || CR + }) + else { + self.state = .parsing(.header, buffer) + return .needMoreData } - return .headerName(name) - } + let headerName = buffer[indexAfterFirstCRLF...Index + switch headerWithoutName.getIndexAfter([58, 32]) { // ": " + case .wrongCharacter(at: let index): + return .error(.invalidHeader(reason: "Expected ': ' after header name, found \(Character(UnicodeScalar(buffer[index])))")) + case .prematureEnd: + self.state = .parsing(.header, buffer) + return .needMoreData + case .success(let index): + indexAfterColonAndSpace = index + } - private func parseHeaderValue(_ value: [UInt8], name: [UInt8]) throws -> HeaderState { - var value = value + // read the header value until CRLF + let headerValue: ArraySlice + switch buffer[indexAfterColonAndSpace...].getFirstRange(of: [13, 10]) { + case .success(let range): + headerValue = buffer[indexAfterColonAndSpace.. State { - var slice = ByteBuffer(buffer.readableBytesView.prefix { $0 != boundary[0] }) +extension ArraySlice where Element == UInt8 { + /// The result of a `getIndexAfter(_:)` call. + /// - success: The slice was found at the given index. The index is the index after the slice. + /// - wrongCharacter: The buffer did not match the slice. The index is the index of the first mismatching character. + /// - prematureEnd: The buffer was too short to contain the slice. The index is the index of the last character. + enum IndexAfterSlice { + case success(ArraySlice.Index) + case wrongCharacter(at: ArraySlice.Index) + case prematureEnd(at: ArraySlice.Index) + } - if slice.readableBytes > 0 { - buffer.moveReaderIndex(forwardBy: slice.readableBytes) - onBody(&slice) + /// Returns the index after the given slice if it matches the start of the buffer. + /// If the buffer is too short, it returns the index of the last character. + /// If the buffer does not match the slice, it returns the index of the first mismatching character. + /// - Parameters: + /// - slice: The slice to match against the buffer. + /// - Returns: The index after the slice if it matches, or the index of the first mismatching character. + func getIndexAfter(_ slice: ArraySlice) -> IndexAfterSlice { + var resultIndex = self.startIndex + for element in slice { + guard resultIndex < self.endIndex else { + return .prematureEnd(at: resultIndex) + } + guard self[resultIndex] == element else { + return .wrongCharacter(at: resultIndex) + } + resultIndex += 1 } - return buffer.readableBytes > 0 ? .boundary() : .body + return .success(resultIndex) } - private func parseBoundary(boundaryMatchIndex: Int) throws -> State { - var boundaryMatchIndex = boundaryMatchIndex + /// The result of a `firstIndexOf(_:)` call. + /// - Parameter success: The slice was found. The associated index is the index before the slice. + /// - Parameter notFound: The slice was not found in the buffer. + enum FirstIndexOfSliceResult { + case success(Range) + case notFound + case prematureEnd + } - while true { - guard let byte = readByte() else { - return .boundary(boundaryMatchIndex: boundaryMatchIndex) + /// Returns the range of the matching slice if it matches. + /// - Parameters: + /// - slice: The slice to match against the buffer. + /// - Returns: The range of the matching slice if it matches, ``FirstIndexOfSliceResult/notFound`` if the slice was not + /// or ``FirstIndexOfSliceResult/prematureEnd`` + func getFirstRange(of slice: ArraySlice) -> FirstIndexOfSliceResult { + guard !slice.isEmpty else { return .notFound } + + var sliceIndex = slice.startIndex + var matchStartIndex: Index? = nil + + for (currentIndex, element) in self.enumerated() { + if sliceIndex == slice.endIndex { + // we've matched the entire slice + let startIndex = self.index(self.startIndex, offsetBy: matchStartIndex!) + let endIndex = self.index(self.startIndex, offsetBy: currentIndex) + return .success(startIndex.. - CTL = - tspecials = "(" | ")" | "<" | ">" | "@" - | "," | ";" | ":" | "\" | DQUOTE - | "/" | "[" | "]" | "?" | "=" - | "{" | "}" | SP | HT - DQUOTE = - SP = - HT = - */ - private static let allowedHeaderFieldNameCharacterFlags: [Bool] = [ - // 0 nul 1 soh 2 stx 3 etx 4 eot 5 enq 6 ack 7 bel - false, false, false, false, false, false, false, false, - // 8 bs 9 ht 10 nl 11 vt 12 np 13 cr 14 so 15 si - false, false, false, false, false, false, false, false, - // 16 dle 17 dc1 18 dc2 19 dc3 20 dc4 21 nak 22 syn 23 etb - false, false, false, false, false, false, false, false, - // 24 can 25 em 26 sub 27 esc 28 fs 29 gs 30 rs 31 us - false, false, false, false, false, false, false, false, - // 32 sp 33 ! 34 " 35 # 36 $ 37 % 38 & 39 - false, true, false, true, true, true, true, true, - // 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 - false, false, true, true, false, true, true, false, - // 48 0 49 1 50 2 51 3 52 4 53 5 54 6 55 7 - true, true, true, true, true, true, true, true, - // 56 8 57 9 58 : 59 ; 60 < 61 = 62 > 63 - true, true, false, false, false, false, false, false, - // 64 @ 65 A 66 B 67 C 68 D 69 E 70 F 71 G - false, true, true, true, true, true, true, true, - // 72 H 73 I 74 J 75 K 76 L 77 M 78 N 79 O - true, true, true, true, true, true, true, true, - // 80 P 81 Q 82 R 83 S 84 T 85 U 86 V 87 W - true, true, true, true, true, true, true, true, - // 88 X 89 Y 90 Z 91 [ 92 \ 93 ] 94 ^ 95 _ - true, true, true, false, false, false, true, true, - // 96 ` 97 a 98 b 99 c 100 d 101 e 102 f 103 g - true, true, true, true, true, true, true, true, - // 104 h 105 i 106 j 107 k 108 l 109 m 110 n 111 o - true, true, true, true, true, true, true, true, - // 112 p 113 q 114 r 115 s 116 t 117 u 118 v 119 w - true, true, true, true, true, true, true, true, - // 120 x 121 y 122 z 123 { 124 | 125 } 126 ~ 127 del - true, true, true, false, true, false, true, false - ] - - var isAllowedHeaderFieldNameCharacter: Bool { - Self.allowedHeaderFieldNameCharacterFlags[Int(self)] + if sliceIndex != slice.startIndex { + return .prematureEnd + } + return .notFound } } diff --git a/Sources/MultipartKit/MultipartParserAsyncSequence.swift b/Sources/MultipartKit/MultipartParserAsyncSequence.swift new file mode 100644 index 0000000..4cf77d1 --- /dev/null +++ b/Sources/MultipartKit/MultipartParserAsyncSequence.swift @@ -0,0 +1,71 @@ +/// A sequence that parses a stream of multipart data into sections asynchronously. +/// +/// This sequence is designed to be used with `AsyncStream` to parse a stream of data asynchronously. +/// The sequence will yield ``MultipartSection`` values as they are parsed from the stream. +/// +/// let boundary = "boundary123" +/// var message = ArraySlice(...) +/// let stream = AsyncStream { continuation in +/// var offset = message.startIndex +/// while offset < message.endIndex { +/// let endIndex = min(message.endIndex, message.index(offset, offsetBy: 16)) +/// continuation.yield(message[offset..: AsyncSequence +where BackingSequence.Element: MultipartPartBodyElement & RangeReplaceableCollection { + private let parser: MultipartParser + private let buffer: BackingSequence + + public init(boundary: String, buffer: BackingSequence) { + self.parser = .init(boundary: boundary) + self.buffer = buffer + } + + public func makeAsyncIterator() -> Iterator { + Iterator(parser: parser, iterator: buffer.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + public typealias Element = MultipartSection + + private var parser: MultipartParser + private var iterator: BackingSequence.AsyncIterator + + init(parser: MultipartParser, iterator: BackingSequence.AsyncIterator) { + self.parser = parser + self.iterator = iterator + } + + public mutating func next() async throws -> MultipartSection? { + while true { + switch parser.read() { + case .success(let optionalPart): + switch optionalPart { + case .none: continue + case .some(let part): return part + } + case .needMoreData: + guard let next = try await iterator.next() else { + return nil + } + parser.append(buffer: next) + case .error(let error): + throw error + case .finished: + return nil + } + } + } + } +} diff --git a/Sources/MultipartKit/MultipartPart.swift b/Sources/MultipartKit/MultipartPart.swift index 8e3a709..fcd947b 100644 --- a/Sources/MultipartKit/MultipartPart.swift +++ b/Sources/MultipartKit/MultipartPart.swift @@ -1,63 +1,30 @@ -import NIOCore -import NIOHTTP1 -import Foundation +import HTTPTypes -/// A single part of a `multipart`-encoded message. -public struct MultipartPart: Equatable, Sendable { - /// The part's headers. - public var headers: HTTPHeaders +public typealias MultipartPartBodyElement = Collection & Equatable & Sendable - /// The part's raw data. - public var body: ByteBuffer - - /// Gets or sets the `name` attribute from the part's `"Content-Disposition"` header. - public var name: String? { - get { self.headers.getParameter("Content-Disposition", "name") } - set { self.headers.setParameter("Content-Disposition", "name", to: newValue, defaultValue: "form-data") } - } +/// Represents a single part of a multipart-encoded message. +public struct MultipartPart: Equatable, Sendable { + /// The header fields for this part. + public var headerFields: HTTPFields - /// Creates a new `MultipartPart`. - /// - /// let part = MultipartPart(headers: ["Content-Type": "text/plain"], body: "hello") - /// - /// - parameters: - /// - headers: The part's headers. - /// - body: The part's data. - public init(headers: HTTPHeaders = .init(), body: String) { - self.init(headers: headers, body: [UInt8](body.utf8)) - } + /// The body of this part. + public var body: Body - /// Creates a new `MultipartPart`. + /// Creates a new ``MultipartPart``. /// - /// let part = MultipartPart(headers: ["Content-Type": "text/plain"], body: "hello") + /// let part = MultipartPart(headerFields: [.contentDisposition: "form-data"], body: Array("Hello, world!".utf8)) /// - /// - parameters: - /// - headers: The part's headers. - /// - body: The part's data. - public init(headers: HTTPHeaders = .init(), body: Data) - where Data: DataProtocol - { - var buffer = ByteBufferAllocator().buffer(capacity: body.count) - buffer.writeBytes(body) - self.init(headers: headers, body: buffer) - } - - public init(headers: HTTPHeaders = .init(), body: ByteBuffer) { - self.headers = headers + /// - Parameters: + /// - headerFields: The header fields for this part. + /// - body: The body of this part. + public init(headerFields: HTTPFields, body: Body) { + self.headerFields = headerFields self.body = body } -} - -// MARK: Array Extensions -extension Array where Element == MultipartPart { - /// Returns the first `MultipartPart` with matching name attribute in `"Content-Disposition"` header. - public func firstPart(named name: String) -> MultipartPart? { - self.first { $0.name == name } - } - - /// Returns all `MultipartPart`s with matching name attribute in `"Content-Disposition"` header. - public func allParts(named name: String) -> [MultipartPart] { - self.filter { $0.name == name } + /// Gets or sets the `name` attribute of the part's `"Content-Disposition"` header. + public var name: String? { + get { self.headerFields.getParameter(.contentDisposition, "name") } + set { self.headerFields.setParameter(.contentDisposition, "name", to: newValue, defaultValue: "form-data") } } } diff --git a/Sources/MultipartKit/MultipartPartConvertible.swift b/Sources/MultipartKit/MultipartPartConvertible.swift deleted file mode 100644 index 5a637c1..0000000 --- a/Sources/MultipartKit/MultipartPartConvertible.swift +++ /dev/null @@ -1,110 +0,0 @@ -import struct Foundation.Data -import struct Foundation.URL - -/// A protocol to provide custom behaviors for parsing and serializing types from and to multipart data. -public protocol MultipartPartConvertible { - var multipart: MultipartPart? { get } - - init?(multipart: MultipartPart) -} - -// MARK: MultipartPart self-conformance - -extension MultipartPart: MultipartPartConvertible { - public var multipart: MultipartPart? { - self - } - - public init?(multipart: MultipartPart) { - self = multipart - } -} - -// MARK: String - -extension String: MultipartPartConvertible { - public var multipart: MultipartPart? { - .init(body: self) - } - - public init?(multipart: MultipartPart) { - self.init(decoding: multipart.body.readableBytesView, as: UTF8.self) - } -} - -// MARK: Numbers - -extension FixedWidthInteger { - public var multipart: MultipartPart? { - .init(body: self.description) - } - - public init?(multipart: MultipartPart) { - self.init(String(multipart: multipart)!) // String.init(multipart:) never returns nil - } -} - -extension Int: MultipartPartConvertible { } -extension Int8: MultipartPartConvertible { } -extension Int16: MultipartPartConvertible { } -extension Int32: MultipartPartConvertible { } -extension Int64: MultipartPartConvertible { } -extension UInt: MultipartPartConvertible { } -extension UInt8: MultipartPartConvertible { } -extension UInt16: MultipartPartConvertible { } -extension UInt32: MultipartPartConvertible { } -extension UInt64: MultipartPartConvertible { } - -extension Float: MultipartPartConvertible { - public var multipart: MultipartPart? { - .init(body: self.description) - } - - public init?(multipart: MultipartPart) { - self.init(String(multipart: multipart)!) // String.init(multipart:) never returns nil - } -} - -extension Double: MultipartPartConvertible { - public var multipart: MultipartPart? { - .init(body: self.description) - } - - public init?(multipart: MultipartPart) { - self.init(String(multipart: multipart)!) // String.init(multipart:) never returns nil - } -} - -// MARK: Bool - -extension Bool: MultipartPartConvertible { - public var multipart: MultipartPart? { - .init(body: self.description) - } - - public init?(multipart: MultipartPart) { - self.init(String(multipart: multipart)!) // String.init(multipart:) never returns nil - } -} - -// MARK: Foundation types - -extension Data: MultipartPartConvertible { - public var multipart: MultipartPart? { - .init(body: self) - } - - public init?(multipart: MultipartPart) { - self.init(multipart.body.readableBytesView) - } -} - -extension URL: MultipartPartConvertible { - public var multipart: MultipartPart? { - .init(body: self.absoluteString) - } - - public init?(multipart: MultipartPart) { - self.init(string: String(multipart: multipart)!) // String.init(multipart:) never returns nil - } -} diff --git a/Sources/MultipartKit/MultipartSection.swift b/Sources/MultipartKit/MultipartSection.swift new file mode 100644 index 0000000..3e2ad14 --- /dev/null +++ b/Sources/MultipartKit/MultipartSection.swift @@ -0,0 +1,7 @@ +import HTTPTypes + +public enum MultipartSection: Equatable, Sendable { + case headerFields(HTTPFields) + case bodyChunk(Body) + case boundary(end: Bool) +} diff --git a/Sources/MultipartKit/MultipartSerializer.swift b/Sources/MultipartKit/MultipartSerializer.swift index a005dc0..00102c2 100644 --- a/Sources/MultipartKit/MultipartSerializer.swift +++ b/Sources/MultipartKit/MultipartSerializer.swift @@ -1,58 +1,67 @@ -import NIOCore +/// Serializes ``MultipartPart``s to some ``MultipartPartBodyElement``. +public struct MultipartSerializer: Sendable { + let boundary: String -/// Serializes `MultipartForm`s to `Data`. -/// -/// See `MultipartParser` for more information about the multipart encoding. -public final class MultipartSerializer: Sendable { + /// Creates a new ``MultipartSerializer``. + public init(boundary: String) { + self.boundary = boundary + } - /// Creates a new `MultipartSerializer`. - public init() { } + /// Serializes some ``MultipartPart``s to some ``MultipartPartBodyElement``. + /// + /// let serialized: ArraySlice = try MultipartSerializer(boundary: "123").serialize(parts: [part]) + /// + /// - Parameters: + /// - parts: One or more ``MultipartPart``s to serialize into some ``MultipartPartBodyElement``. + /// - Throws: Any errors that may occur during serialization. + /// - Returns: some `multipart`-encoded ``MultipartPartBodyElement``. + public func serialize(parts: [MultipartPart]) throws -> Body + where Body: RangeReplaceableCollection { + var buffer = Body() + try self.serialize(parts: parts, into: &buffer) + return buffer + } - /// Serializes the `MultipartForm` to data. + /// Serializes some ``MultipartPartBodyElement`` to a `String`. /// - /// let data = try MultipartSerializer().serialize(parts: [part], boundary: "123") - /// print(data) // multipart-encoded + /// let serialized: String = try MultipartSerializer(boundary: "123").serialize(parts: [part]) /// - /// - parameters: - /// - parts: One or more `MultipartPart`s to serialize into `Data`. - /// - boundary: Multipart boundary to use for encoding. This must not appear anywhere in the encoded data. - /// - throws: Any errors that may occur during serialization. - /// - returns: `multipart`-encoded `Data`. - public func serialize(parts: [MultipartPart], boundary: String) throws -> String { - var buffer = ByteBufferAllocator().buffer(capacity: 0) - try self.serialize(parts: parts, boundary: boundary, into: &buffer) - return String(decoding: buffer.readableBytesView, as: UTF8.self) + /// - Parameters: + /// - parts: One or more ``MultipartPart``s to serialize into some ``MultipartPartBodyElement``. + /// - Throws: Any errors that may occur during serialization. + /// - Returns: a `multipart`-encoded `String`. + public func serialize(parts: [MultipartPart]) throws -> String + where Body: RangeReplaceableCollection { + var buffer = Body() + try self.serialize(parts: parts, into: &buffer) + return String(decoding: buffer, as: UTF8.self) } - /// Serializes the `MultipartForm` into a `ByteBuffer`. + /// Serializes some ``MultipartPart``s to a buffer. /// - /// var buffer = ByteBuffer() - /// try MultipartSerializer().serialize(parts: [part], boundary: "123", into: &buffer) - /// print(String(buffer: buffer)) // multipart-encoded + /// var buffer = ByteBuffer().readableBytesView + /// try MultipartSerializer(boundary: "123").serialize(parts: [part], into: &buffer) /// - /// - parameters: - /// - parts: One or more `MultipartPart`s to serialize into `Data`. - /// - boundary: Multipart boundary to use for encoding. This must not appear anywhere in the encoded data. - /// - buffer: Buffer to write to. - /// - throws: Any errors that may occur during serialization. - public func serialize(parts: [MultipartPart], boundary: String, into buffer: inout ByteBuffer) throws { + /// - Parameters: + /// - parts: One or more ``MultipartPart``s to serialize into a buffer. + /// - buffer: Buffer to write to. + /// - Throws: Any errors that may occur during serialization. + /// - Note: `ByteBuffer` directly won't work because we have no dependency on NIO. + /// You can use `ByteBufferView` via `ByteBuffer.readableBytesView`. + public func serialize( + parts: [MultipartPart], + into buffer: inout OutputBody + ) throws where OutputBody: RangeReplaceableCollection { + let crlf = Array("\r\n".utf8) for part in parts { - buffer.writeString("--") - buffer.writeString(boundary) - buffer.writeString("\r\n") - for (key, val) in part.headers { - buffer.writeString(key) - buffer.writeString(": ") - buffer.writeString(val) - buffer.writeString("\r\n") + buffer.append(contentsOf: Array("--\(boundary)".utf8) + crlf) + for field in part.headerFields { + buffer.append(contentsOf: Array("\(field.description)".utf8) + crlf) } - buffer.writeString("\r\n") - var body = part.body - buffer.writeBuffer(&body) - buffer.writeString("\r\n") + buffer.append(contentsOf: crlf) + buffer.append(contentsOf: part.body) + buffer.append(contentsOf: crlf) } - buffer.writeString("--") - buffer.writeString(boundary) - buffer.writeString("--\r\n") + buffer.append(contentsOf: Array("--\(boundary)--".utf8) + crlf) } } diff --git a/Sources/MultipartKit/Utilities.swift b/Sources/MultipartKit/Utilities.swift index 3586f5d..ad0727c 100644 --- a/Sources/MultipartKit/Utilities.swift +++ b/Sources/MultipartKit/Utilities.swift @@ -1,43 +1,41 @@ import Foundation -import NIOHTTP1 - -extension HTTPHeaders { - func getParameter(_ name: String, _ key: String) -> String? { - return self.headerParts(name: name).flatMap { - $0.filter { $0.hasPrefix("\(key)=") } - .first? - .split(separator: "=") - .last - .flatMap { $0 .trimmingCharacters(in: .quotes)} - } +import HTTPTypes + +extension HTTPFields { + func getParameter(_ name: HTTPField.Name, _ key: String) -> String? { + headerParts(name: name)? + .filter { $0.contains("\(key)=") } + .first? + .split(separator: "=") + .last? + .trimmingCharacters(in: .quotes) } - + mutating func setParameter( - _ name: String, + _ name: HTTPField.Name, _ key: String, to value: String?, defaultValue: String ) { var current: [String] - + if let existing = self.headerParts(name: name) { current = existing.filter { !$0.hasPrefix("\(key)=") } } else { current = [defaultValue] } - + if let value = value { current.append("\(key)=\"\(value)\"") } - + let new = current.joined(separator: "; ").trimmingCharacters(in: .whitespaces) - - self.replaceOrAdd(name: name, value: new) + + self[name] = new } - - func headerParts(name: String) -> [String]? { - return self[name] - .first + + func headerParts(name: HTTPField.Name) -> [String]? { + self[name] .flatMap { $0.split(separator: ";") .map { $0.trimmingCharacters(in: .whitespaces) } diff --git a/Tests/MultipartKitTests/FormDataDecodingTests.swift b/Tests/MultipartKitTests/FormDataDecodingTests.swift new file mode 100644 index 0000000..6ea2da7 --- /dev/null +++ b/Tests/MultipartKitTests/FormDataDecodingTests.swift @@ -0,0 +1,306 @@ +import MultipartKit +import Testing + +@Suite("Form Data Decoding Tests") +struct FormDataDecodingTests { + @Test("W3 Form Data Decoding") + func testFormDataDecoderW3() throws { + /// Content-Type: multipart/form-data; boundary=12345 + let data = """ + --12345\r + Content-Disposition: form-data; name="sometext"\r + \r + some text sent via post...\r + --12345\r + Content-Disposition: form-data; name="files"\r + Content-Type: multipart/mixed; boundary=abcde\r + \r + --abcde\r + Content-Disposition: file; file="picture.jpg"\r + \r + content of jpg...\r + --abcde\r + Content-Disposition: file; file="test.py"\r + \r + content of test.py file ....\r + --abcde--\r + --12345--\r\n + """ + + struct Foo: Decodable { + let sometext: String + let files: String + } + + let foo = try FormDataDecoder().decode(Foo.self, from: data, boundary: "12345") + #expect(foo.sometext == "some text sent via post...") + #expect(foo.files.contains("picture.jpg")) + } + + @Test("Optional Decoding") + func testDecodeOptional() throws { + struct Bar: Decodable { + struct Foo: Decodable { + let int: Int? + } + let foo: Foo? + } + let data = """ + ---\r + Content-Disposition: form-data; name="foo[int]"\r + \r + 1\r + -----\r\n + """ + + let decoder = FormDataDecoder() + let bar = try decoder.decode(Bar?.self, from: data, boundary: "-") + #expect(bar?.foo?.int == 1) + } + + @Test("Decode Multiple Items") + func testFormDataDecoderMultiple() throws { + /// Content-Type: multipart/form-data; boundary=12345 + let data = """ + --hello\r + Content-Disposition: form-data; name="string"\r + \r + string\r + --hello\r + Content-Disposition: form-data; name="int"\r + \r + 42\r + --hello\r + Content-Disposition: form-data; name="double"\r + \r + 3.14\r + --hello\r + Content-Disposition: form-data; name="array[]"\r + \r + 1\r + --hello\r + Content-Disposition: form-data; name="array[]"\r + \r + 2\r + --hello\r + Content-Disposition: form-data; name="array[]"\r + \r + 3\r + --hello\r + Content-Disposition: form-data; name="bool"\r + \r + true\r + --hello--\r\n + """ + + struct Foo: Decodable { + var string: String + var int: Int + var double: Double + var array: [Int] + var bool: Bool + } + + let foo = try FormDataDecoder().decode(Foo.self, from: data, boundary: "hello") + #expect(foo.string == "string") + #expect(foo.int == 42) + #expect(foo.double == 3.14) + #expect(foo.array == [1, 2, 3]) + #expect(foo.bool == true) + } + + @Test("Decode Multiple Items with Missing Data") + func testFormDataDecoderMultipleWithMissingData() throws { + /// Content-Type: multipart/form-data; boundary=hello + let data = """ + --hello\r + Content-Disposition: form-data; name="link"\r + \r + https://google.com\r + --hello--\r\n + """ + + struct Foo: Decodable { + struct Bar: Decodable { + var relative: String + var base: String? + } + var link: Bar + } + + #expect { + try FormDataDecoder().decode(Foo.self, from: data, boundary: "hello") + } throws: { error in + guard let error = error as? DecodingError else { + Issue.record("Was expecting an error of type DecodingError") + return false + } + guard case let DecodingError.typeMismatch(_, context) = error else { + Issue.record("Was expecting an error of type DecodingError.typeMismatch") + return false + } + return context.codingPath.map(\.stringValue) == ["link"] + } + } + + @Test("Nested Decode") + func testNestedDecode() throws { + struct FormData: Decodable, Equatable { + struct NestedFormData: Decodable, Equatable { + struct AnotherNestedFormData: Decodable, Equatable { + let int: Int + let string: String + let strings: [String] + } + let int: String + let string: Int + let strings: [String] + let anotherNestedFormData: AnotherNestedFormData + let anotherNestedFormDataList: [AnotherNestedFormData] + } + let nestedFormData: [NestedFormData] + } + + let data = """ + ---\r + Content-Disposition: form-data; name="nestedFormData[0][int]"\r + \r + 1\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][string]"\r + \r + 1\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][strings][0]"\r + \r + 2\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][strings][1]"\r + \r + 3\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormData][int]"\r + \r + 4\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormData][string]"\r + \r + 5\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormData][strings][0]"\r + \r + 6\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormData][strings][1]"\r + \r + 7\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][0][int]"\r + \r + 10\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][0][string]"\r + \r + 11\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][0][strings][0]"\r + \r + 12\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][0][strings][1]"\r + \r + 13\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][1][int]"\r + \r + 20\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][1][string]"\r + \r + 21\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][1][strings][0]"\r + \r + 22\r + ---\r + Content-Disposition: form-data; name="nestedFormData[0][anotherNestedFormDataList][1][strings][1]"\r + \r + 33\r + -----\r\n + """ + + let decoder = FormDataDecoder() + let formData = try decoder.decode(FormData.self, from: data, boundary: "-") + + #expect( + formData + == FormData( + nestedFormData: [ + .init( + int: "1", + string: 1, + strings: ["2", "3"], + anotherNestedFormData: .init(int: 4, string: "5", strings: ["6", "7"]), + anotherNestedFormDataList: [ + .init(int: 10, string: "11", strings: ["12", "13"]), + .init(int: 20, string: "21", strings: ["22", "33"]), + ] + ) + ] + ) + ) + } + + @Test("Decoding Single Value") + func testDecodingSingleValue() throws { + let data = """ + ---\r + Content-Disposition: form-data;\r + \r + 1\r + -----\r\n + """ + + let decoder = FormDataDecoder() + let foo = try decoder.decode(Int.self, from: data, boundary: "-") + #expect(foo == 1) + } + + @Test("Nesting Depth") + func testNestingDepth() throws { + let nested = """ + ---\r + Content-Disposition: form-data; name=a[]\r + \r + 1\r + -----\r\n + """ + + #expect(throws: Never.self) { + try FormDataDecoder(nestingDepth: 3).decode([String: [Int]].self, from: nested, boundary: "-") + } + + #expect(throws: (any Error).self) { + try FormDataDecoder(nestingDepth: 2).decode([String: [Int]].self, from: nested, boundary: "-") + } + } + + @Test("Decoding Incorrectly Nested Data") + func testIncorrectlyNestedData() throws { + struct TestData: Codable { + var x: String + } + + let multipart = """ + ---\r + Content-Disposition: form-data; name="x[not-present]"\r + \r + foo\r + -----\r + """ + #expect(throws: (any Error).self) { + try FormDataDecoder().decode(TestData.self, from: multipart, boundary: "-") + } + } + +} diff --git a/Tests/MultipartKitTests/FormDataEncodingTests.swift b/Tests/MultipartKitTests/FormDataEncodingTests.swift new file mode 100644 index 0000000..2b5d69e --- /dev/null +++ b/Tests/MultipartKitTests/FormDataEncodingTests.swift @@ -0,0 +1,286 @@ +import MultipartKit +import Testing + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +@Suite("Form Data Encoding Tests") +struct FormDataEncodingTests { + @Test("Encoding") + func encode() throws { + struct Foo: Encodable { + var string: String + var int: Int + var double: Double + var array: [Int] + var bool: Bool + } + let a = Foo(string: "a", int: 42, double: 3.14, array: [1, 2, 3], bool: true) + let data = try FormDataEncoder().encode(a, boundary: "hello", to: [UInt8].self) + #expect( + data + == Array( + """ + --hello\r + Content-Disposition: form-data; name="string"\r + \r + a\r + --hello\r + Content-Disposition: form-data; name="int"\r + \r + 42\r + --hello\r + Content-Disposition: form-data; name="double"\r + \r + 3.14\r + --hello\r + Content-Disposition: form-data; name="array[0]"\r + \r + 1\r + --hello\r + Content-Disposition: form-data; name="array[1]"\r + \r + 2\r + --hello\r + Content-Disposition: form-data; name="array[2]"\r + \r + 3\r + --hello\r + Content-Disposition: form-data; name="bool"\r + \r + true\r + --hello--\r\n + """.utf8 + ) + ) + } + + @Test("Nested Encoding") + func nestedEncode() throws { + struct FormData: Encodable, Equatable { + struct NestedFormdata: Encodable, Equatable { + struct AnotherNestedFormdata: Encodable, Equatable { + let int: Int + let string: String + let strings: [String] + } + let int: String + let string: Int + let strings: [String] + let anotherNestedFormdata: AnotherNestedFormdata + let anotherNestedFormdataList: [AnotherNestedFormdata] + } + let nestedFormdata: [NestedFormdata] + } + + let encoder = FormDataEncoder() + let data = try encoder.encode( + FormData(nestedFormdata: [ + .init( + int: "1", + string: 1, + strings: ["2", "3"], + anotherNestedFormdata: .init(int: 4, string: "5", strings: ["6", "7"]), + anotherNestedFormdataList: [ + .init(int: 10, string: "11", strings: ["12", "13"]), + .init(int: 20, string: "21", strings: ["22", "33"]), + ]) + ]), boundary: "-") + let expected = + """ + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][int]"\r + \r + 1\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][string]"\r + \r + 1\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][strings][0]"\r + \r + 2\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][strings][1]"\r + \r + 3\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][int]"\r + \r + 4\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][string]"\r + \r + 5\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][strings][0]"\r + \r + 6\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][strings][1]"\r + \r + 7\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][int]"\r + \r + 10\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][string]"\r + \r + 11\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][strings][0]"\r + \r + 12\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][strings][1]"\r + \r + 13\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][int]"\r + \r + 20\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][string]"\r + \r + 21\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][strings][0]"\r + \r + 22\r + ---\r + Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][strings][1]"\r + \r + 33\r + -----\r\n + """ + + #expect(data == expected) + } + + @Test("Encoding and Decoding UUID") + func encodeAndDecodeUUID() async throws { + let uuid = try #require(UUID(uuidString: "c0bdd551-0684-4f34-a72e-ed553b4c9732")) + let multipart = """ + ---\r + Content-Disposition: form-data\r + \r + \(uuid.uuidString)\r + -----\r\n + """ + + #expect(try FormDataEncoder().encode(uuid, boundary: "-") == multipart) + #expect(try FormDataDecoder().decode(UUID.self, from: multipart, boundary: "-") == uuid) + } + + // https://github.com/vapor/multipart-kit/issues/65 + @Test("Encoding and Decoding Non-Multipart Part Convertible Codable Types") + func encodeAndDecodeNonMultipartPartConvertibleCodableTypes() async throws { + enum License: String, Codable, CaseIterable, Equatable { + case dme1 + } + let license = License.dme1 + let multipart = """ + ---\r + Content-Disposition: form-data\r + \r + \(license.rawValue)\r + -----\r\n + """ + #expect(try FormDataEncoder().encode(license, boundary: "-") == multipart) + #expect(try FormDataDecoder().decode(License.self, from: multipart, boundary: "-") == license) + } + + @Test("Encoding and Decoding Data Types") + func codeDataTypes() async throws { + struct AllTypes: Codable, Equatable { + let string: String + let int: Int, int8: Int8, int16: Int16, int32: Int32, int64: Int64 + let uint: UInt, uint8: UInt8, uint16: UInt16, uint32: UInt32, uint64: UInt64 + let float: Float, double: Double + let bool: Bool + let data: Data, url: URL + } + let value = AllTypes( + string: "string", + int: 1, int8: 2, int16: 3, int32: 4, int64: 5, + uint: 6, uint8: 7, uint16: 8, uint32: 9, uint64: 0, + float: 1.0, double: -1.0, + bool: false, + data: .init([.init(ascii: "A")]), url: .init(string: "https://apple.com/")! + ) + let multipart = """ + ---\r + Content-Disposition: form-data; name="string"\r + \r + string\r + ---\r + Content-Disposition: form-data; name="int"\r + \r + 1\r + ---\r + Content-Disposition: form-data; name="int8"\r + \r + 2\r + ---\r + Content-Disposition: form-data; name="int16"\r + \r + 3\r + ---\r + Content-Disposition: form-data; name="int32"\r + \r + 4\r + ---\r + Content-Disposition: form-data; name="int64"\r + \r + 5\r + ---\r + Content-Disposition: form-data; name="uint"\r + \r + 6\r + ---\r + Content-Disposition: form-data; name="uint8"\r + \r + 7\r + ---\r + Content-Disposition: form-data; name="uint16"\r + \r + 8\r + ---\r + Content-Disposition: form-data; name="uint32"\r + \r + 9\r + ---\r + Content-Disposition: form-data; name="uint64"\r + \r + 0\r + ---\r + Content-Disposition: form-data; name="float"\r + \r + 1.0\r + ---\r + Content-Disposition: form-data; name="double"\r + \r + -1.0\r + ---\r + Content-Disposition: form-data; name="bool"\r + \r + false\r + ---\r + Content-Disposition: form-data; name="data"\r + \r + A\r + ---\r + Content-Disposition: form-data; name="url"\r + \r + https://apple.com/\r + -----\r\n + """ + + #expect(try FormDataEncoder().encode(value, boundary: "-") == multipart) + #expect(try FormDataDecoder().decode(AllTypes.self, from: multipart, boundary: "-") == value) + } +} diff --git a/Tests/MultipartKitTests/FormDataTests.swift b/Tests/MultipartKitTests/FormDataTests.swift deleted file mode 100644 index 3199f55..0000000 --- a/Tests/MultipartKitTests/FormDataTests.swift +++ /dev/null @@ -1,556 +0,0 @@ -import XCTest -import MultipartKit - -final class FormDataTests: XCTestCase { - func testFormDataEncoder() throws { - struct Foo: Encodable { - var string: String - var int: Int - var double: Double - var array: [Int] - var bool: Bool - } - let a = Foo(string: "a", int: 42, double: 3.14, array: [1, 2, 3], bool: true) - let data = try FormDataEncoder().encode(a, boundary: "hello") - XCTAssertEqual(data, """ - --hello\r - Content-Disposition: form-data; name="string"\r - \r - a\r - --hello\r - Content-Disposition: form-data; name="int"\r - \r - 42\r - --hello\r - Content-Disposition: form-data; name="double"\r - \r - 3.14\r - --hello\r - Content-Disposition: form-data; name="array[0]"\r - \r - 1\r - --hello\r - Content-Disposition: form-data; name="array[1]"\r - \r - 2\r - --hello\r - Content-Disposition: form-data; name="array[2]"\r - \r - 3\r - --hello\r - Content-Disposition: form-data; name="bool"\r - \r - true\r - --hello--\r\n - """) - } - - func testFormDataDecoderW3() throws { - /// Content-Type: multipart/form-data; boundary=12345 - let data = """ - --12345\r - Content-Disposition: form-data; name="sometext"\r - \r - some text sent via post...\r - --12345\r - Content-Disposition: form-data; name="files"\r - Content-Type: multipart/mixed; boundary=abcde\r - \r - --abcde\r - Content-Disposition: file; file="picture.jpg"\r - \r - content of jpg...\r - --abcde\r - Content-Disposition: file; file="test.py"\r - \r - content of test.py file ....\r - --abcde--\r - --12345--\r\n - """ - - struct Foo: Decodable { - let sometext: String - let files: String - } - - let foo = try FormDataDecoder().decode(Foo.self, from: data, boundary: "12345") - XCTAssertEqual(foo.sometext, "some text sent via post...") - XCTAssert(foo.files.contains("picture.jpg")) - } - - func testDecodeOptional() throws { - struct Bar: Decodable { - struct Foo: Decodable { - let int: Int? - } - let foo: Foo? - } - let data = """ - ---\r - Content-Disposition: form-data; name="foo[int]"\r - \r - 1\r - -----\r\n - """ - - let decoder = FormDataDecoder() - let bar = try decoder.decode(Bar?.self, from: data, boundary: "-") - XCTAssertEqual(bar?.foo?.int, 1) - } - - func testFormDataDecoderMultiple() throws { - /// Content-Type: multipart/form-data; boundary=12345 - let data = """ - --hello\r - Content-Disposition: form-data; name="string"\r - \r - string\r - --hello\r - Content-Disposition: form-data; name="int"\r - \r - 42\r - --hello\r - Content-Disposition: form-data; name="double"\r - \r - 3.14\r - --hello\r - Content-Disposition: form-data; name="array[]"\r - \r - 1\r - --hello\r - Content-Disposition: form-data; name="array[]"\r - \r - 2\r - --hello\r - Content-Disposition: form-data; name="array[]"\r - \r - 3\r - --hello\r - Content-Disposition: form-data; name="bool"\r - \r - true\r - --hello--\r\n - """ - - struct Foo: Decodable { - var string: String - var int: Int - var double: Double - var array: [Int] - var bool: Bool - } - - let foo = try FormDataDecoder().decode(Foo.self, from: data, boundary: "hello") - XCTAssertEqual(foo.string, "string") - XCTAssertEqual(foo.int, 42) - XCTAssertEqual(foo.double, 3.14) - XCTAssertEqual(foo.array, [1, 2, 3]) - XCTAssertEqual(foo.bool, true) - } - - func testFormDataDecoderMultipleWithMissingData() { - /// Content-Type: multipart/form-data; boundary=hello - let data = """ - --hello\r - Content-Disposition: form-data; name="link"\r - \r - https://google.com\r - --hello--\r\n - """ - - struct Foo: Decodable { - struct Bar: Decodable { - var relative: String - var base: String? - } - var link: Bar - } - - XCTAssertThrowsError(try FormDataDecoder().decode(Foo.self, from: data, boundary: "hello")) { error in - guard case let DecodingError.typeMismatch(_, context) = error else { - XCTFail("Was expecting an error of type DecodingError.typeMismatch") - return - } - XCTAssertEqual(context.codingPath.map(\.stringValue), ["link"]) - } - } - - func testNestedEncode() throws { - struct Formdata: Encodable, Equatable { - struct NestedFormdata: Encodable, Equatable { - struct AnotherNestedFormdata: Encodable, Equatable { - let int: Int - let string: String - let strings: [String] - } - let int: String - let string: Int - let strings: [String] - let anotherNestedFormdata: AnotherNestedFormdata - let anotherNestedFormdataList: [AnotherNestedFormdata] - } - let nestedFormdata: [NestedFormdata] - } - - let encoder = FormDataEncoder() - let data = try encoder.encode(Formdata(nestedFormdata: [ - .init( - int: "1", - string: 1, - strings: ["2", "3"], - anotherNestedFormdata: .init(int: 4, string: "5", strings: ["6", "7"]), - anotherNestedFormdataList: [ - .init(int: 10, string: "11", strings: ["12", "13"]), - .init(int: 20, string: "21", strings: ["22", "33"]) - ]) - ]), boundary: "-") - let expected = """ - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][int]"\r - \r - 1\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][string]"\r - \r - 1\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][strings][0]"\r - \r - 2\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][strings][1]"\r - \r - 3\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][int]"\r - \r - 4\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][string]"\r - \r - 5\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][strings][0]"\r - \r - 6\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][strings][1]"\r - \r - 7\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][int]"\r - \r - 10\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][string]"\r - \r - 11\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][strings][0]"\r - \r - 12\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][strings][1]"\r - \r - 13\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][int]"\r - \r - 20\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][string]"\r - \r - 21\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][strings][0]"\r - \r - 22\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][strings][1]"\r - \r - 33\r - -----\r\n - """ - - XCTAssertEqual(data, expected) - } - - func testNestedDecode() throws { - struct Formdata: Decodable, Equatable { - struct NestedFormdata: Decodable, Equatable { - struct AnotherNestedFormdata: Decodable, Equatable { - let int: Int - let string: String - let strings: [String] - } - let int: String - let string: Int - let strings: [String] - let anotherNestedFormdata: AnotherNestedFormdata - let anotherNestedFormdataList: [AnotherNestedFormdata] - } - let nestedFormdata: [NestedFormdata] - } - - let data = """ - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][int]"\r - \r - 1\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][string]"\r - \r - 1\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][strings][0]"\r - \r - 2\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][strings][1]"\r - \r - 3\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][int]"\r - \r - 4\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][string]"\r - \r - 5\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][strings][0]"\r - \r - 6\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdata][strings][1]"\r - \r - 7\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][int]"\r - \r - 10\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][string]"\r - \r - 11\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][strings][0]"\r - \r - 12\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][0][strings][1]"\r - \r - 13\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][int]"\r - \r - 20\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][string]"\r - \r - 21\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][strings][0]"\r - \r - 22\r - ---\r - Content-Disposition: form-data; name="nestedFormdata[0][anotherNestedFormdataList][1][strings][1]"\r - \r - 33\r - -----\r\n - """ - - let decoder = FormDataDecoder() - let formdata = try decoder.decode(Formdata.self, from: data, boundary: "-") - - XCTAssertEqual(formdata, Formdata(nestedFormdata: [ - .init( - int: "1", - string: 1, - strings: ["2", "3"], - anotherNestedFormdata: .init(int: 4, string: "5", strings: ["6", "7"]), - anotherNestedFormdataList: [ - .init(int: 10, string: "11", strings: ["12", "13"]), - .init(int: 20, string: "21", strings: ["22", "33"]) - ]) - ])) - } - - func testDecodingSingleValue() throws { - let data = """ - ---\r - \r - 1\r - -----\r\n - """ - - let decoder = FormDataDecoder() - let foo = try decoder.decode(Int.self, from: data, boundary: "-") - XCTAssertEqual(foo, 1) - } - - func testMultiPartConvertibleTakesPrecedenceOverDecodable() throws { - struct Foo: Decodable, MultipartPartConvertible { - var multipart: MultipartPart? { nil } - - let success: Bool - - init(from _: any Decoder) throws { - success = false - } - init?(multipart: MultipartPart) { - success = true - } - } - - let singleValue = """ - ---\r - \r - \r - -----\r\n - """ - let decoder = FormDataDecoder() - let singleFoo = try decoder.decode(Foo.self, from: singleValue, boundary: "-") - XCTAssertTrue(singleFoo.success) - - let array = """ - ---\r - Content-Disposition: form-data; name=""\r - \r - \r - -----\r\n - """ - - let fooArray = try decoder.decode([Foo].self, from: array, boundary: "-") - XCTAssertFalse(fooArray.isEmpty) - XCTAssertTrue(fooArray.allSatisfy(\.success)) - - let keyed = """ - ---\r - Content-Disposition: form-data; name="a"\r - \r - \r - -----\r\n - """ - - let keyedFoos = try decoder.decode([String: Foo].self, from: keyed, boundary: "-") - XCTAssertFalse(keyedFoos.isEmpty) - XCTAssertTrue(keyedFoos.values.allSatisfy(\.success)) - } - - func testNestingDepth() throws { - let nested = """ - ---\r - Content-Disposition: form-data; name=a[]\r - \r - 1\r - -----\r\n - """ - - XCTAssertNoThrow(try FormDataDecoder(nestingDepth: 3).decode([String: [Int]].self, from: nested, boundary: "-")) - XCTAssertThrowsError(try FormDataDecoder(nestingDepth: 2).decode([String: [Int]].self, from: nested, boundary: "-")) - } - - func testFailingToInitializeMultipartConvertableDoesNotCrash() throws { - struct Foo: MultipartPartConvertible, Decodable { - init?(multipart: MultipartPart) { nil } - var multipart: MultipartPart? { nil } - } - - let input = """ - ---\r - \r - \r - null\r - -----\r\n - """ - XCTAssertThrowsError(try FormDataDecoder().decode(Foo.self, from: input, boundary: "-")) - } - - func testEncodingAndDecodingUUID() throws { - let uuid = try XCTUnwrap(UUID(uuidString: "c0bdd551-0684-4f34-a72e-ed553b4c9732")) - let multipart = """ - ---\r - Content-Disposition: form-data\r - \r - \(uuid.uuidString)\r - -----\r\n - """ - - XCTAssertEqual(try FormDataEncoder().encode(uuid, boundary: "-"), multipart) - XCTAssertEqual(try FormDataDecoder().decode(UUID.self, from: multipart, boundary: "-"), uuid) - } - - // https://github.com/vapor/multipart-kit/issues/65 - func testEncodingAndDecodingNonMultipartPartConvertibleCodableTypes() throws { - enum License: String, Codable, CaseIterable, Equatable { - case dme1 - } - let license = License.dme1 - let multipart = """ - ---\r - Content-Disposition: form-data\r - \r - \(license.rawValue)\r - -----\r\n - """ - XCTAssertEqual(try FormDataEncoder().encode(license, boundary: "-"), multipart) - XCTAssertEqual(try FormDataDecoder().decode(License.self, from: multipart, boundary: "-"), license) - } - - func testIncorrectlyNestedData() throws { - struct TestData : Codable { - var x: String - } - let multipart = """ - ---\r - Content-Disposition: form-data; name="x[not-present]"\r - \r - foo\r - -----\r - """ - XCTAssertThrowsError (try FormDataDecoder().decode(TestData.self, from: multipart, boundary: "-")) - } - - func testCodingDataTypes() throws { - struct AllTypes: Codable, Equatable { - let string: String - let int: Int, int8: Int8, int16: Int16, int32: Int32, int64: Int64 - let uint: UInt, uint8: UInt8, uint16: UInt16, uint32: UInt32, uint64: UInt64 - let float: Float, double: Double - let bool: Bool - let data: Data, url: URL - } - let value = AllTypes( - string: "string", - int: 1, int8: 2, int16: 3, int32: 4, int64: 5, - uint: 6, uint8: 7, uint16: 8, uint32: 9, uint64: 0, - float: 1.0, double: -1.0, - bool: false, - data: .init([.init(ascii: "A")]), url: .init(string: "https://apple.com/")! - ) - let multipart = """ - ---\r\nContent-Disposition: form-data; name="string"\r\n\r\nstring\r - ---\r\nContent-Disposition: form-data; name="int"\r\n\r\n1\r - ---\r\nContent-Disposition: form-data; name="int8"\r\n\r\n2\r - ---\r\nContent-Disposition: form-data; name="int16"\r\n\r\n3\r - ---\r\nContent-Disposition: form-data; name="int32"\r\n\r\n4\r - ---\r\nContent-Disposition: form-data; name="int64"\r\n\r\n5\r - ---\r\nContent-Disposition: form-data; name="uint"\r\n\r\n6\r - ---\r\nContent-Disposition: form-data; name="uint8"\r\n\r\n7\r - ---\r\nContent-Disposition: form-data; name="uint16"\r\n\r\n8\r - ---\r\nContent-Disposition: form-data; name="uint32"\r\n\r\n9\r - ---\r\nContent-Disposition: form-data; name="uint64"\r\n\r\n0\r - ---\r\nContent-Disposition: form-data; name="float"\r\n\r\n1.0\r - ---\r\nContent-Disposition: form-data; name="double"\r\n\r\n-1.0\r - ---\r\nContent-Disposition: form-data; name="bool"\r\n\r\nfalse\r - ---\r\nContent-Disposition: form-data; name="data"\r\n\r\nA\r - ---\r\nContent-Disposition: form-data; name="url"\r\n\r\nhttps://apple.com/\r - -----\r\n - """ - - XCTAssertEqual(try FormDataEncoder().encode(value, boundary: "-"), multipart) - XCTAssertEqual(try FormDataDecoder().decode(AllTypes.self, from: multipart, boundary: "-"), value) - - } -} diff --git a/Tests/MultipartKitTests/MultipartTests.swift b/Tests/MultipartKitTests/MultipartTests.swift deleted file mode 100644 index af08574..0000000 --- a/Tests/MultipartKitTests/MultipartTests.swift +++ /dev/null @@ -1,296 +0,0 @@ -import XCTest -import MultipartKit -import NIOCore -import NIOHTTP1 - -final class MultipartTests: XCTestCase { - let named = """ - test123 - aijdisadi>SDASDdwekqie4u219034u129e0wque90qjsd90asffs - - - SDASD [SubSequence] { - precondition(maxLength > 0, "groups must be greater than zero") - var start = startIndex - return stride(from: 0, to: count, by: maxLength).map { _ in - let end = index(start, offsetBy: maxLength, limitedBy: endIndex) ?? endIndex - defer { start = end } - return self[start.. MultipartParserOutputReceiver { - try collectOutput(ByteBuffer(string: data), boundary: boundary) - } - - static func collectOutput(_ data: ByteBuffer, boundary: String) throws -> MultipartParserOutputReceiver { - let output = MultipartParserOutputReceiver() - let parser = MultipartParser(boundary: boundary) - output.setUp(with: parser) - try parser.execute(data) - return output - } - - func setUp(with parser: MultipartParser) { - parser.onHeader = { (field, value) in - self.headers.replaceOrAdd(name: field, value: value) - } - parser.onBody = { new in - self.body.writeBuffer(&new) - } - parser.onPartComplete = { - let part = MultipartPart(headers: self.headers, body: self.body) - self.headers = [:] - self.body = ByteBuffer() - self.parts.append(part) - } - } -} diff --git a/Tests/MultipartKitTests/ParserTests.swift b/Tests/MultipartKitTests/ParserTests.swift new file mode 100644 index 0000000..bfa7b50 --- /dev/null +++ b/Tests/MultipartKitTests/ParserTests.swift @@ -0,0 +1,167 @@ +import HTTPTypes +import MultipartKit +import Testing + +@Suite("Parser Tests") +struct ParserTests { + @Test("Parse Example") + func parseExample() async throws { + let pngData: [UInt8] = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x3C, 0x08, 0x02, 0x00, 0x00, 0x00, 0xE9, 0x14, 0x0D, + 0x01, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x0E, 0xC4, 0x00, 0x00, 0x0E, + 0xC4, 0x01, 0x95, 0x2B, 0x0E, 0x1B, 0x00, 0x00, 0x01, 0x3B, 0x49, 0x44, 0x41, 0x54, 0x48, 0x89, + 0xB5, 0x96, 0xCB, 0x16, 0x83, 0x30, 0x08, 0x44, 0xA1, 0xC7, 0xFF, 0xFF, 0x65, 0xBA, 0xD0, 0x26, + 0x86, 0xD7, 0x40, 0xAC, 0x59, 0xF4, 0xB4, 0x54, 0xB8, 0x30, 0x21, 0x44, 0x16, 0x11, 0xDA, 0x5D, + 0x1F, 0x6B, 0x62, 0xE6, 0xF1, 0x69, 0xED, 0x8B, 0xE5, 0x09, 0xF9, 0x0A, 0x36, 0x42, 0xA8, 0xF0, + 0x79, 0xE8, 0x1E, 0x59, 0x85, 0x66, 0x15, 0xFE, 0x15, 0xB2, 0x55, 0x8B, 0x54, 0xCD, 0xF6, 0x89, + 0x97, 0xC9, 0x56, 0x6A, 0x11, 0x39, 0xBF, 0x03, 0x32, 0x4C, 0xCF, 0x4A, 0x38, 0x2C, 0xAC, 0xA4, + 0xBE, 0x67, 0x01, 0x2B, 0xC2, 0x0A, 0xB9, 0x9B, 0x77, 0xB5, 0xB0, 0xD2, 0xB9, 0xD7, 0x33, 0xAE, + 0xD5, 0xB6, 0x8D, 0x0A, 0x3A, 0xC9, 0xF7, 0xC4, 0xEA, 0x64, 0x76, 0x77, 0x0F, 0x46, 0x99, 0x6A, + 0x17, 0x1D, 0x1A, 0xE4, 0x28, 0x8A, 0xAA, 0xFF, 0x1F, 0xC3, 0x20, 0x27, 0x8F, 0x86, 0x51, 0x9D, + 0xE3, 0xB4, 0x5E, 0x43, 0xF0, 0x1C, 0x9B, 0x1C, 0xD2, 0xB6, 0x60, 0x0B, 0x96, 0xD9, 0x19, 0xBD, + 0x79, 0x7B, 0x33, 0xB3, 0xBF, 0xCF, 0x75, 0xF2, 0xD5, 0x9E, 0xB9, 0x9B, 0x3A, 0xA4, 0xEA, 0x01, + 0xFD, 0x1F, 0xC4, 0x2E, 0x25, 0x24, 0x64, 0x28, 0xE7, 0x72, 0xAA, 0xF2, 0x7D, 0x76, 0xEE, 0xAA, + 0x0D, 0xF2, 0x58, 0x87, 0xEB, 0x56, 0x5C, 0xF8, 0x48, 0x26, 0xFC, 0x83, 0xD6, 0x99, 0x56, 0x74, + 0x3B, 0xBD, 0x4A, 0xC3, 0x20, 0xDA, 0xC5, 0xA9, 0x36, 0x1C, 0xBA, 0x9B, 0x64, 0xFA, 0xB5, 0x1A, + 0xB8, 0x9F, 0x8B, 0xD8, 0xE9, 0x0C, 0xB1, 0xA1, 0x73, 0xD6, 0xF7, 0x08, 0xBE, 0x73, 0xCB, 0xCC, + 0x25, 0x22, 0xDB, 0x03, 0xF4, 0xD1, 0xE8, 0xDD, 0x4D, 0xF8, 0x21, 0xB9, 0xB2, 0x97, 0x35, 0x72, + 0x6F, 0xDC, 0x43, 0x2C, 0xDC, 0x88, 0xC6, 0x00, 0xB4, 0xA9, 0x39, 0xED, 0x7E, 0x27, 0xE7, 0x79, + 0x1E, 0xEA, 0x77, 0x84, 0x75, 0x4F, 0xAE, 0x76, 0x86, 0xEF, 0x27, 0xFE, 0xDC, 0x86, 0xF7, 0xAB, + 0x5F, 0x33, 0x05, 0x25, 0xB9, 0xAF, 0x4F, 0x8B, 0x25, 0xC9, 0x10, 0xAE, 0x90, 0x5C, 0x19, 0x69, + 0xA6, 0x8C, 0xCE, 0x0B, 0xDD, 0x11, 0x45, 0x85, 0x05, 0x87, 0xE4, 0xA1, 0x3C, 0xD0, 0xDF, 0xB5, + 0x56, 0xB0, 0x44, 0xF4, 0x05, 0x04, 0xF3, 0x35, 0x0E, 0x1E, 0x4A, 0x5C, 0x13, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ] + + let boundary = "boundary123" + var message = ArraySlice( + """ + --\(boundary)\r + Content-Disposition: form-data; name="id"\r + Content-Type: text/plain\r + \r + 123e4567-e89b-12d3-a456-426655440000\r + --\(boundary)\r + Content-Disposition: form-data; name="address"\r + Content-Type: application/json\r + \r + {\r + "street": "3, Garden St",\r + "city": "Hillsbery, UT"\r + }\r + --\(boundary)\r + Content-Disposition: form-data; name="profileImage"; filename="image1.png"\r + Content-Type: image/png\r + \r\n + """.utf8) + message.append(contentsOf: pngData) + message.append(contentsOf: "\r\n--\(boundary)--".utf8) + + let stream = makeParsingStream(for: message) + let sequence = MultipartParserAsyncSequence(boundary: boundary, buffer: stream) + + var parts: [MultipartSection>] = [] + for try await part in sequence { + parts.append(part) + } + + var expectedFields: [HTTPField] = [ + .init(name: .contentDisposition, value: "form-data; name=\"id\""), + .init(name: .contentType, value: "text/plain"), + .init(name: .contentDisposition, value: "form-data; name=\"address\""), + .init(name: .contentType, value: "application/json"), + .init(name: .contentDisposition, value: "form-data; name=\"profileImage\"; filename=\"image1.png\""), + .init(name: .contentType, value: "image/png"), + ] + + var expectedBodies: ArraySlice = [] + expectedBodies.append(contentsOf: "123e4567-e89b-12d3-a456-426655440000".utf8) + expectedBodies.append( + contentsOf: """ + {\r + "street": "3, Garden St",\r + "city": "Hillsbery, UT"\r + } + """.utf8) + expectedBodies.append(contentsOf: pngData) + + var actualBodies: ArraySlice = [] + + for part in parts { + switch part { + case .headerFields(let field): + #expect(field.first == expectedFields.removeFirst()) + case .bodyChunk(let chunk): + actualBodies.append(contentsOf: chunk) + case .boundary: break + } + } + + #expect(actualBodies == expectedBodies) + } + + @Test("Parse non ASCII header") + func parseNonASCIIHeader() async throws { + let filename = "Non-ASCII filé namé.txt" + let data = ArraySlice( + """ + ------WebKitFormBoundaryPVOZifB9OqEwP2fn\r + Content-Disposition: form-data; name="test"; filename="\(filename)"\r + \r + eqw-dd-sa----123;1[234\r + ------WebKitFormBoundaryPVOZifB9OqEwP2fn--\r\n + """.utf8) + + let stream = makeParsingStream(for: data) + let sequence = MultipartParserAsyncSequence(boundary: "----WebKitFormBoundaryPVOZifB9OqEwP2fn", buffer: stream) + + for try await part in sequence { + switch part { + case .bodyChunk(let chunk): + print(String(decoding: chunk, as: UTF8.self)) + case .headerFields(let field): + print(field) + case .boundary: break + } + } + } + + @Test("Parse Synchronously") + func parseSynchronously() async throws { + let boundary = "boundary123" + let message = """ + --\(boundary)\r + Content-Disposition: form-data; name="id"\r + Content-Type: text/plain\r + \r + 123e4567-e89b-12d3-a456-426655440000\r + --\(boundary)-- + """ + + let parts = try MultipartParser<[UInt8]>(boundary: boundary) + .parse([UInt8](message.utf8)) + + #expect(parts.count == 1) + #expect( + parts[0].headerFields + == .init([ + .init(name: .contentDisposition, value: "form-data; name=\"id\""), + .init(name: .contentType, value: "text/plain"), + ])) + #expect(parts[0].body == Array("123e4567-e89b-12d3-a456-426655440000".utf8)) + } + + private func makeParsingStream(for message: Body) -> AsyncStream + where Body.SubSequence: Sendable { + AsyncStream { continuation in + var offset = message.startIndex + while offset < message.endIndex { + let endIndex = min(message.endIndex, message.index(offset, offsetBy: 16)) + continuation.yield(message[offset.. = try MultipartSerializer(boundary: "boundary123").serialize(parts: example) + let expected = ArraySlice( + """ + --boundary123\r + Content-Disposition: form-data; name="file"; filename="hello.txt"\r + Content-Type: text/plain\r + \r + Hello, world!\r + --boundary123--\r\n + """.utf8) + #expect(serialized == expected) + } +}