-
-
Notifications
You must be signed in to change notification settings - Fork 46
Add Content-Disposition type
#139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
958a783
de759f7
b65916c
2dafc3c
026615f
345cd8e
00ff5e1
068744f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This claims to throw, but actually returns
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It returns |
||
| 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. | ||
ptoffy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) } | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| let dispositionType = parameters.removeFirst() | ||
ptoffy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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") } | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| name = parameter.dropFirst(5) | ||
| .trimmingCharacters(in: .quotes) | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } else if parameter.starts(with: "filename=") { | ||
| if filename != nil { throw Error.duplicateField("filename") } | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| filename = parameter.dropFirst(9) | ||
| .trimmingCharacters(in: .quotes) | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } else { | ||
| var split = parameter.split(separator: "=") | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
ptoffy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| let name = String(split.removeFirst()) | ||
| let value = String(split.removeFirst().trimmingCharacters(in: .quotes)) | ||
ptoffy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
| } | ||
| } | ||
| 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") | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.