Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions Sources/MultipartKit/MultipartFormData.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import Collections
import Foundation

/// Internal representation of parsed multipart form data with support for hierarchical structures.
///
/// This type is used by the `FormDataDecoder` to represent the hierarchical structure
/// of multipart form data, with support for nested objects and arrays through
/// field name notation like `user[address][street]`.
enum MultipartFormData<Body: MultipartPartBodyElement>: Sendable {
typealias Keyed = OrderedDictionary<String, MultipartFormData>

/// A single multipart part containing field data.
case single(MultipartPart<Body>)

/// An array of form data items (represents indexed fields like `items[]`).
case array([MultipartFormData])

/// A keyed dictionary of form data items (represents nested objects like `user[name]`).
case keyed(Keyed)

/// Special case when the nesting depth limit has been exceeded.
case nestingDepthExceeded

init(parts: [MultipartPart<Body>], nestingDepth: Int) {
self = .empty
for part in parts {
let path = part.name.map(makePath) ?? []
let name = try? part.contentDisposition?.name
let path = name.map(makePath) ?? []
insert(part, at: path, remainingNestingDepth: nestingDepth)
}
}
Expand All @@ -36,6 +49,10 @@ enum MultipartFormData<Body: MultipartPartBodyElement>: Sendable {
return part
}

/// Whether this form data has exceeded the configured nesting depth.
///
/// Used during decoding to detect and handle excessive nesting that could
/// lead to stack overflow or other resource issues.
var hasExceededNestingDepth: Bool {
guard case .nestingDepthExceeded = self else {
return false
Expand All @@ -44,6 +61,14 @@ enum MultipartFormData<Body: MultipartPartBodyElement>: Sendable {
}
}

/// Parses a string with bracket notation into a path of components.
///
/// For example:
/// - `"user[address][street]"` becomes `["user", "address", "street"]`
/// - `"items[0][name]"` becomes `["items", "0", "name"]`
///
/// This function handles complex cases like brackets within values and
/// properly manages the path segments.
private func makePath(from string: String) -> ArraySlice<String> {
// This is a bit of a hack to handle brackets in the path. For example
// `foo[a]a[b]` has to be decoded as `["foo", "a]a[b"]`,
Expand Down Expand Up @@ -85,27 +110,45 @@ private func makePath(from string: String) -> ArraySlice<String> {
}

extension MultipartFormData {
/// Converts the hierarchical form data structure back to flat multipart parts.
///
/// This method is used by `FormDataEncoder` to convert the structured form data
/// back to a flat list of parts with appropriate `name` attributes in their
/// Content-Disposition headers.
func namedParts() -> [MultipartPart<Body>] {
Self.namedParts(from: self)
}

private static func namedParts(from data: MultipartFormData, path: String? = nil) -> [MultipartPart<Body>] {
switch data {
case .single(let part):
// Create a new part with the updated name parameter
[createPartWithName(part, name: path)]
case .array(let array):
return array.enumerated().flatMap { offset, element in
// For arrays, index each element and process recursively
array.enumerated().flatMap { offset, element in
namedParts(from: element, path: path.map { "\($0)[\(offset)]" })
}
case .single(var part):
part.name = path
return [part]
case .keyed(let dictionary):
return dictionary.flatMap { key, value in
// For objects, process each key-value pair recursively
dictionary.flatMap { key, value in
namedParts(from: value, path: path.map { "\($0)[\(key)]" } ?? key)
}
case .nestingDepthExceeded:
return []
[]
}
}

/// Creates a new part with the given name parameter in its Content-Disposition header.
private static func createPartWithName(_ part: MultipartPart<Body>, name: String?) -> MultipartPart<Body> {
var headerFields = part.headerFields
headerFields.setParameter(.contentDisposition, "name", to: name)

return MultipartPart(
headerFields: headerFields,
body: part.body
)
}
}

extension MultipartFormData {
Expand Down
172 changes: 167 additions & 5 deletions Sources/MultipartKit/MultipartPart.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import HTTPTypes
public import HTTPTypes

public typealias MultipartPartBodyElement = RangeReplaceableCollection<UInt8> & Sendable

Expand All @@ -24,9 +24,171 @@ public struct MultipartPart<Body: MultipartPartBodyElement>: Sendable {
self.body = body
}

/// 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") }
/// Parses and returns the Content-Disposition information from the part's headers.
///
/// - Throws: `ContentDisposition.Error` if the header has an invalid format, or is missing required fields.
/// - Returns: A parsed `ContentDisposition` instance.
public var contentDisposition: ContentDisposition? {
get throws(ContentDisposition.Error) {
Comment on lines 30 to 33
Copy link
Member

Choose a reason for hiding this comment

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

This claims to throw, but actually returns nil. Either change the behavior or remove both the doc comment and the annotation.

Copy link
Member Author

@ptoffy ptoffy Oct 27, 2025

Choose a reason for hiding this comment

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

It returns nil if there's no such header and throws if it's there but unable to parse it (so we also get diagnostics via the error). It makes sense in my head but happy to change the behaviour to just do one of the two

guard let field = self.headerFields[.contentDisposition] else {
return nil
}
return try .init(from: field)
}
}
}

/// Represents a parsed Content-Disposition header field for multipart messages.
///
/// The Content-Disposition header is defined in RFC 6266 (HTTP) and RFC 7578 (multipart/form-data)
/// and provides metadata about each part, including:
/// - The disposition type (form-data, attachment, inline)
/// - The "name" parameter that identifies the form field (required for form-data)
/// - An optional "filename" parameter for file uploads
/// - Any additional custom parameters
public struct ContentDisposition: Sendable {
/// The original header field value.
var underlyingField: String

/// The type of content disposition, indicating how the content should be handled.
///
/// The disposition type provides a hint to the recipient about how the content
/// should be presented or processed.
public let dispositionType: DispositionType

/// The name parameter of the Content-Disposition header.
///
/// This is a required parameter for multipart/form-data and represents
/// the name of the form field associated with this part.
public var name: String?

/// The optional filename parameter of the Content-Disposition header.
///
/// When present, this indicates the part contains an uploaded file and
/// provides the original filename from the client.
public let filename: String?

/// Additional parameters included in the Content-Disposition header.
///
/// These are any parameters beyond the standard "name" and "filename" parameters.
public let additionalParameters: [String: String]

/// Initializes a ContentDisposition by parsing a raw header field value.
///
/// - Parameter field: The raw Content-Disposition header field value.
/// - Throws: `ContentDisposition.Error` if the header has an invalid format, contains an
/// unrecognized disposition type, or is missing required fields.
public init(from field: HTTPFields.Value) throws(ContentDisposition.Error) {
self.underlyingField = field

var parameters =
field
.split(separator: ";")
.map { $0.trimmingCharacters(in: .whitespaces) }

let dispositionType = parameters.removeFirst()
guard let type = DispositionType(rawValue: dispositionType) else {
throw Error.invalidDispositionType(String(dispositionType))
}

self.dispositionType = type

var name: String?
var filename: String?
var additionalParameters: [String: String] = [:]

for parameter in parameters {
if parameter.starts(with: "name=") {
if name != nil { throw Error.duplicateField("name") }
name = parameter.dropFirst(5)
.trimmingCharacters(in: .quotes)
} else if parameter.starts(with: "filename=") {
if filename != nil { throw Error.duplicateField("filename") }
filename = parameter.dropFirst(9)
.trimmingCharacters(in: .quotes)
} else {
var split = parameter.split(separator: "=")

let name = String(split.removeFirst())
let value = String(split.removeFirst().trimmingCharacters(in: .quotes))

additionalParameters[name] = value
}
}

// The name parameter is required when dealing with the form-data type
if type == .formData, name == nil {
throw Error.missingField("name")
}

self.name = name
self.filename = filename
self.additionalParameters = additionalParameters
}

/// The type of content disposition as defined in HTTP standards.
/// Each disposition type indicates a different way the content should be handled.
public enum DispositionType: String, Sendable {
/// Indicates this part is a form field in a multipart/form-data submission.
/// This is the standard disposition type used in HTML form submissions.
case formData = "form-data"

/// Indicates the content should be downloaded and saved locally rather than displayed.
/// Attachment is commonly used for file downloads where the user is expected
/// to save the content rather than view it in the browser or application.
case attachment

/// Indicates the content should be displayed inline within the context it was received.
/// Inline content is typically rendered directly within a browser window
/// or application view, rather than requiring a separate download step.
case inline
}

/// Errors that can occur when parsing Content-Disposition headers.
public struct Error: Swift.Error, Equatable {
/// The underlying error type.
enum Backing: Equatable {
/// The disposition type is not "form-data" as required by RFC 7578.
case invalidDispositionType(String)

/// A field appears more than once in the header.
case duplicateField(String)

/// A required field is missing from the header.
case missingField(String)

/// The Content-Disposition header is not present.
case missingContentDisposition
}

/// The backing error value.
let backing: Backing

/// Creates an error indicating an incorrect disposition type.
///
/// - Parameter type: The invalid disposition type found in the header.
/// - Returns: An error instance.
public static func invalidDispositionType(_ type: String) -> Self {
self.init(backing: .invalidDispositionType(type))
}

/// Creates an error indicating a duplicate field in the header.
///
/// - Parameter field: The name of the duplicate field.
/// - Returns: An error instance.
public static func duplicateField(_ field: String) -> Self {
self.init(backing: .duplicateField(field))
}

/// Creates an error indicating a missing required field.
///
/// - Parameter field: The name of the missing field.
/// - Returns: An error instance.
public static func missingField(_ field: String) -> Self {
self.init(backing: .missingField(field))
}

/// An error indicating the Content-Disposition header is missing.
public static let missingContentDisposition = Self(backing: .missingContentDisposition)
}
}
2 changes: 1 addition & 1 deletion Sources/MultipartKit/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension HTTPFields {
_ name: HTTPField.Name,
_ key: String,
to value: String?,
defaultValue: String
defaultValue: String = "form-data"
) {
var current: [String]

Expand Down
82 changes: 82 additions & 0 deletions Tests/MultipartKitTests/ContentDispositionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import HTTPTypes
import MultipartKit
import Testing

@Suite("Content-Disposition Tests")
struct ContentDispositionTests {
@Test("Parse Content-Disposition")
func testContentDispositionParsing() throws {
let part1 = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "form-data; name=\"fieldName\""],
body: []
)

let disposition1 = try #require(try part1.contentDisposition)
#expect(disposition1.name == "fieldName")
#expect(disposition1.filename == nil)
#expect(disposition1.additionalParameters.isEmpty == true)

let part2 = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "form-data; name=\"file\"; filename=\"example.txt\""],
body: []
)

let disposition2 = try #require(try part2.contentDisposition)
#expect(disposition2.name == "file")
#expect(disposition2.filename == "example.txt")
#expect(disposition2.additionalParameters.isEmpty == true)

let part3 = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "form-data; name=\"user\"; size=1024; custom=\"value\""],
body: []
)

let disposition3 = try #require(try part3.contentDisposition)
#expect(disposition3.name == "user")
#expect(disposition3.filename == nil)
#expect(disposition3.additionalParameters["size"] == "1024")
#expect(disposition3.additionalParameters["custom"] == "value")
}

@Test("Correct Content-Disposition Errors")
func testContentDispositionErrors() {
let part1 = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "file; name=\"file\""],
body: []
)

#expect(throws: ContentDisposition.Error.invalidDispositionType("file")) {
try part1.contentDisposition
}

let part2 = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "form-data; filename=\"example.txt\""],
body: []
)

#expect(throws: ContentDisposition.Error.missingField("name")) {
try part2.contentDisposition
}

let part3 = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "form-data; name=\"file\"; name=\"duplicate\""],
body: []
)

#expect(throws: ContentDisposition.Error.duplicateField("name")) {
try part3.contentDisposition
}
}

@Test("Parse Quoted Content-Disposition Field")
func testQuotedParameterParsing() throws {
let part = MultipartPart<[UInt8]>(
headerFields: [.contentDisposition: "form-data; name=\"user data\"; filename=\"file with spaces.txt\""],
body: []
)

let disposition = try #require(try part.contentDisposition)
#expect(disposition.name == "user data")
#expect(disposition.filename == "file with spaces.txt")
}
}
Loading
Loading