├── .spi.yml ├── Tests ├── .swiftlint.yml └── MultipartFormDataTests │ ├── ContentTypeTests.swift │ ├── HTTPHeaderParameterTests.swift │ ├── HTTPHeaderFieldTests.swift │ ├── SubpartTests.swift │ ├── MediaTypeTests.swift │ ├── URLRequestTests.swift │ ├── Builder │ ├── BodyDataBuilderTests.swift │ ├── HTTPHeaderBuilderTests.swift │ └── MultipartFormDataBuilderTests.swift │ ├── ContentDispositionTests.swift │ ├── BoundaryTests.swift │ └── MultipartFormDataTests.swift ├── .github ├── dependabot.yml └── workflows │ ├── swiftlint.yml │ └── swift.yml ├── .gitignore ├── Sources ├── .swiftlint.yml └── MultipartFormData │ ├── Internal │ ├── Data+helpers.swift │ └── String+helpers.swift │ ├── Resources │ └── PrivacyInfo.xcprivacy │ ├── Builder │ ├── MultipartFormDataBuilder.swift │ ├── BodyDataBuilder.swift │ └── HTTPHeaderBuilder.swift │ ├── ContentType.swift │ ├── URLRequest+MultipartFormData.swift │ ├── HTTPHeaderParameter.swift │ ├── HTTPHeaderField.swift │ ├── ContentDisposition.swift │ ├── Subpart.swift │ ├── Boundary.swift │ ├── MediaType.swift │ └── MultipartFormData.swift ├── Package.swift ├── LICENSE ├── .swiftlint.yml └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [MultipartFormData] 5 | -------------------------------------------------------------------------------- /Tests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - empty_xctest_method 3 | - single_test_class 4 | - test_case_accessibility 5 | - xct_specific_matcher 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Sources/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - explicit_acl 3 | - explicit_type_interface 4 | - fatal_error_message 5 | 6 | explicit_type_interface: 7 | allow_redundancy: true 8 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Internal/Data+helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+helpers.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | internal static let _dash = Data("--".utf8) 12 | internal static let _crlf = Data("\r\n".utf8) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Internal/String+helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+helpers.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 30.12.21. 6 | // 7 | 8 | extension String { 9 | internal init(_ staticString: StaticString) { 10 | // swiftlint:disable:next optional_data_string_conversion 11 | self = staticString.withUTF8Buffer { String(decoding: $0, as: UTF8.self) } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Resources/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - '.github/workflows/swiftlint.yml' 9 | - '**/.swiftlint.yml' 10 | - '**.swift' 11 | pull_request: 12 | branches: 13 | - master 14 | paths: 15 | - '.github/workflows/swiftlint.yml' 16 | - '**/.swiftlint.yml' 17 | - '**.swift' 18 | workflow_dispatch: 19 | 20 | jobs: 21 | swiftlint: 22 | name: SwiftLint 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v6 26 | - uses: norio-nomura/action-swiftlint@3.2.1 27 | with: 28 | args: --strict 29 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | strategy: 14 | matrix: 15 | os: [macos-latest, ubuntu-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v6 19 | - run: swift build -v 20 | 21 | test: 22 | name: Test 23 | needs: build 24 | strategy: 25 | matrix: 26 | os: [macos-latest, ubuntu-latest] 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - uses: actions/checkout@v6 30 | - run: swift test -v 31 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-multipart-formdata", 8 | products: [ 9 | .library( 10 | name: "MultipartFormData", 11 | targets: ["MultipartFormData"] 12 | ), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "MultipartFormData", 17 | resources: [.process("Resources")] 18 | ), 19 | .testTarget( 20 | name: "MultipartFormDataTests", 21 | dependencies: ["MultipartFormData"] 22 | ), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/ContentTypeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentTypeTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class ContentTypeTests: XCTestCase { 12 | func testBoundaryParameters() throws { 13 | let contentType = ContentType(boundary: try Boundary(uncheckedBoundary: "test")) 14 | XCTAssertEqual(contentType.parameters[0], HTTPHeaderParameter("boundary", value: "test")) 15 | } 16 | 17 | func testData() { 18 | let contentType = ContentType(mediaType: .textPlain, parameters: [HTTPHeaderParameter("test", value: "a")]) 19 | XCTAssertEqual(contentType.data, Data("Content-Type: text/plain; test=\"a\"".utf8)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/HTTPHeaderParameterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPHeaderParameterTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class HTTPHeaderParameterTests: XCTestCase { 12 | func testArrayRawValue() { 13 | let singleParameter = [ 14 | HTTPHeaderParameter("test", value: "a") 15 | ] 16 | XCTAssertEqual(singleParameter.rawValue, "test=\"a\"") 17 | 18 | let parameters = [ 19 | HTTPHeaderParameter("test", value: "a"), 20 | HTTPHeaderParameter("test", value: "a"), 21 | HTTPHeaderParameter("test", value: "a"), 22 | ] 23 | XCTAssertEqual(parameters.rawValue, "test=\"a\"; test=\"a\"; test=\"a\"") 24 | } 25 | 26 | func testDebugDescription() { 27 | let parameter = HTTPHeaderParameter("test", value: "a") 28 | let expectedDescription = "test=\"a\"" 29 | XCTAssertEqual(parameter.debugDescription, expectedDescription) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/HTTPHeaderFieldTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPHeaderFieldTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 05.03.23. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class HTTPHeaderFieldTests: XCTestCase { 12 | func testDebugDescription() { 13 | let parameter = HTTPHeaderParameter("name", value: "value") 14 | let testHeaderField = TestHeaderField(value: "value", parameters: [parameter]) 15 | 16 | let expectedDescription = "Test: value; name=\"value\"" 17 | XCTAssertEqual(testHeaderField.debugDescription, expectedDescription) 18 | } 19 | } 20 | 21 | extension HTTPHeaderFieldTests { 22 | private struct TestHeaderField: HTTPHeaderField { 23 | static let name: String = "Test" 24 | 25 | var value: String 26 | 27 | var parameters: [HTTPHeaderParameter] 28 | 29 | init(value: String, parameters: [HTTPHeaderParameter]) { 30 | self.value = value 31 | self.parameters = parameters 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Felix Herrmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Builder/MultipartFormDataBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormDataBuilder.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | /// Build the subparts for a ``MultipartFormData``. 9 | @resultBuilder 10 | public enum MultipartFormDataBuilder { 11 | // swiftlint:disable missing_docs 12 | 13 | public static func buildExpression(_ expression: Subpart) -> [Subpart] { 14 | return [expression] 15 | } 16 | 17 | public static func buildBlock(_ components: [Subpart]...) -> [Subpart] { 18 | return components.flatMap { $0 } 19 | } 20 | 21 | public static func buildArray(_ components: [[Subpart]]) -> [Subpart] { 22 | return components.flatMap { $0 } 23 | } 24 | 25 | // swiftlint:disable:next discouraged_optional_collection 26 | public static func buildOptional(_ component: [Subpart]?) -> [Subpart] { 27 | return component ?? [] 28 | } 29 | 30 | public static func buildEither(first component: [Subpart]) -> [Subpart] { 31 | return component 32 | } 33 | 34 | public static func buildEither(second component: [Subpart]) -> [Subpart] { 35 | return component 36 | } 37 | // swiftlint:enable missing_docs 38 | } 39 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Builder/BodyDataBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BodyDataBuilder.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Build data for the ``Subpart``'s body. 11 | /// 12 | /// The returning object is always a single `Data` instance, it will be combined in the case of 13 | /// multiple `Data` components. 14 | @resultBuilder 15 | public enum BodyDataBuilder { 16 | // swiftlint:disable missing_docs 17 | 18 | public static func buildExpression(_ expression: Data) -> Data { 19 | return expression 20 | } 21 | 22 | public static func buildBlock(_ components: Data...) -> Data { 23 | return components.reduce(Data(), +) 24 | } 25 | 26 | public static func buildArray(_ components: [Data]) -> Data { 27 | return components.reduce(Data(), +) 28 | } 29 | 30 | public static func buildOptional(_ component: Data?) -> Data { 31 | return component ?? Data() 32 | } 33 | 34 | public static func buildEither(first component: Data) -> Data { 35 | return component 36 | } 37 | 38 | public static func buildEither(second component: Data) -> Data { 39 | return component 40 | } 41 | // swiftlint:enable missing_docs 42 | } 43 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/ContentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentType.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | /// A `Content-Type` header field of an HTTP request. 9 | public struct ContentType: HTTPHeaderField { 10 | 11 | public static let name: String = "Content-Type" 12 | 13 | public var value: String { 14 | return mediaType.rawValue 15 | } 16 | 17 | /// The media type (MIME type) of the content type. 18 | /// 19 | /// This property is used for the ``value``. 20 | public var mediaType: MediaType 21 | 22 | public var parameters: [HTTPHeaderParameter] 23 | 24 | /// Creates a new ``ContentType`` object. 25 | /// - Parameters: 26 | /// - mediaType: The media type (MIME type) of the content type. 27 | /// - parameters: The additional parameters of the header field. 28 | public init(mediaType: MediaType, parameters: [HTTPHeaderParameter] = []) { 29 | self.mediaType = mediaType 30 | self.parameters = parameters 31 | } 32 | } 33 | 34 | // MARK: - Helpers 35 | 36 | extension ContentType { 37 | internal init(boundary: Boundary) { 38 | self.mediaType = .multipartFormData 39 | self.parameters = [HTTPHeaderParameter("boundary", value: boundary.rawValue)] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/URLRequest+MultipartFormData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequest+MultipartFormData.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | #if canImport(FoundationNetworking) 10 | import FoundationNetworking 11 | #endif 12 | 13 | extension URLRequest { 14 | /// Creates a `URLRequest` from a ``MultipartFormData``. 15 | /// 16 | /// This initializer will set the `httpMethod` to `"POST"` and configures header and 17 | /// body appropriately for the multipart/form-data. 18 | /// 19 | /// - Parameters: 20 | /// - url: The URL for the request. 21 | /// - multipartFormData: The multipart/form-data for the request. 22 | public init(url: URL, multipartFormData: MultipartFormData) { 23 | self.init(url: url) 24 | httpMethod = "POST" 25 | updateHeaderField(with: multipartFormData.contentType) 26 | httpBody = multipartFormData.httpBody 27 | } 28 | } 29 | 30 | // MARK: - Header Fields 31 | 32 | extension URLRequest { 33 | /// Updates the corresponding header field with a``HTTPHeaderField`` object. 34 | /// - Parameter headerField: The new header field object. 35 | public mutating func updateHeaderField(with headerField: Field) { 36 | setValue(headerField.parameterizedValue, forHTTPHeaderField: Field.name) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/SubpartTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubpartTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class SubpartTests: XCTestCase { 12 | func testDataGeneration() throws { 13 | let subpart = Subpart( 14 | contentDisposition: ContentDisposition(name: "a"), 15 | contentType: ContentType(mediaType: .textPlain), 16 | body: Data("a".utf8) 17 | ) 18 | let expectedData = Data([ 19 | "Content-Disposition: form-data; name=\"a\"", 20 | "Content-Type: text/plain", 21 | "", 22 | "a", 23 | ].joined(separator: "\r\n").utf8) 24 | XCTAssertEqual(subpart.data, expectedData) 25 | } 26 | 27 | func testDebugDescription() { 28 | let subpart = Subpart( 29 | contentDisposition: ContentDisposition(name: "a"), 30 | contentType: ContentType(mediaType: .textPlain), 31 | body: Data("a".utf8) 32 | ) 33 | 34 | let expectedDescription = [ 35 | "Content-Disposition: form-data; name=\"a\"", 36 | "Content-Type: text/plain", 37 | "", 38 | "a", 39 | ].joined(separator: "\r\n") 40 | XCTAssertEqual(subpart.debugDescription, expectedDescription) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/HTTPHeaderParameter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPHeaderParameter.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | /// A parameter for an ``HTTPHeaderField``. 9 | public struct HTTPHeaderParameter: Sendable, Hashable { 10 | 11 | /// The name of the parameter. 12 | public var name: String 13 | 14 | /// The value of the parameter. 15 | public var value: String 16 | 17 | /// Creates a new ``HTTPHeaderParameter`` object. 18 | /// - Parameters: 19 | /// - name: The name of the parameter. 20 | /// - value: The value of the parameter. 21 | public init(_ name: String, value: String) { 22 | self.name = name 23 | self.value = value 24 | } 25 | } 26 | 27 | // MARK: - CustomDebugStringConvertible 28 | 29 | extension HTTPHeaderParameter: CustomDebugStringConvertible { 30 | public var debugDescription: String { 31 | return rawValue 32 | } 33 | } 34 | 35 | // MARK: - Data 36 | 37 | extension HTTPHeaderParameter { 38 | /// The raw string representation of a header parameter. 39 | public var rawValue: String { 40 | return "\(name)=\"\(value)\"" 41 | } 42 | } 43 | 44 | extension Array { 45 | /// The raw string representation of multiple header parameters. 46 | public var rawValue: String { 47 | return map(\.rawValue).joined(separator: "; ") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/MediaTypeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaTypeTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | #if canImport(UniformTypeIdentifiers) 10 | import UniformTypeIdentifiers 11 | #endif 12 | @testable import MultipartFormData 13 | 14 | final class MediaTypeTests: XCTestCase { 15 | func testRawValue() { 16 | let mediaType = MediaType(type: "type", subtype: "subtype") 17 | XCTAssertEqual(mediaType.rawValue, "type/subtype") 18 | } 19 | 20 | func testDebugDescription() { 21 | let mediaType = MediaType(type: "type", subtype: "subtype") 22 | let expectedDescription = "type/subtype" 23 | XCTAssertEqual(mediaType.debugDescription, expectedDescription) 24 | } 25 | #if canImport(UniformTypeIdentifiers) 26 | 27 | @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) 28 | func testFromUTTypeConversion() throws { 29 | let uniformType = try XCTUnwrap(UTType("public.comma-separated-values-text")) 30 | let mediaType = try XCTUnwrap(MediaType(uniformType: uniformType)) 31 | XCTAssertEqual(mediaType, .textCsv) 32 | } 33 | 34 | @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) 35 | func testToUTTypeConversion() throws { 36 | let uniformType = try XCTUnwrap(UTType(mediaType: .applicationJson)) 37 | XCTAssertEqual(uniformType.identifier, "public.json") 38 | } 39 | #endif 40 | } 41 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/HTTPHeaderField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPHeaderField.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A header field of an HTTP request. 11 | public protocol HTTPHeaderField: Sendable, Hashable, CustomDebugStringConvertible { 12 | 13 | /// The name of the header field. 14 | /// 15 | /// This value is case-insensitive. 16 | static var name: String { get } 17 | 18 | /// The value of the header field. 19 | /// 20 | /// Whitespace before the value is ignored. 21 | var value: String { get } 22 | 23 | /// The additional parameters of the header field. 24 | var parameters: [HTTPHeaderParameter] { get set } 25 | } 26 | 27 | // MARK: - CustomDebugStringConvertible 28 | 29 | extension HTTPHeaderField { 30 | /// A textual representation of this instance, suitable for debugging. 31 | public var debugDescription: String { 32 | return rawValue 33 | } 34 | } 35 | 36 | // MARK: - Data 37 | 38 | extension HTTPHeaderField { 39 | /// The actual header field value resulting from ``value`` and ``parameters``. 40 | public var parameterizedValue: String { 41 | if parameters.isEmpty { return value } 42 | return "\(value); \(parameters.rawValue)" 43 | } 44 | 45 | /// The raw string representation of a header field. 46 | public var rawValue: String { 47 | return "\(Self.name): \(parameterizedValue)" 48 | } 49 | 50 | /// The data representation of a header field. 51 | public var data: Data { 52 | return Data(rawValue.utf8) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/URLRequestTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequestTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 30.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | #if canImport(FoundationNetworking) 11 | import FoundationNetworking 12 | #endif 13 | 14 | final class URLRequestTests: XCTestCase { 15 | func testFormDataInit() throws { 16 | let boundary = try Boundary(uncheckedBoundary: "test") 17 | let multipartFormData = MultipartFormData(boundary: boundary, body: [ 18 | Subpart(contentDisposition: ContentDisposition(name: "a"), body: Data()) 19 | ]) 20 | // swiftlint:disable:next force_unwrapping 21 | let request = URLRequest(url: URL(string: "https://test.com/test")!, multipartFormData: multipartFormData) 22 | 23 | XCTAssertEqual(request.httpMethod, "POST") 24 | XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "multipart/form-data; boundary=\"test\"") 25 | 26 | let expectedBody = Data([ 27 | "--test", 28 | "Content-Disposition: form-data; name=\"a\"", 29 | "", 30 | "", 31 | "--test--\r\n", 32 | ].joined(separator: "\r\n").utf8) 33 | XCTAssertEqual(request.httpBody, expectedBody) 34 | } 35 | 36 | func testHeaderField() { 37 | // swiftlint:disable:next force_unwrapping 38 | var request = URLRequest(url: URL(string: "https://test.com/test")!) 39 | let contentType = ContentType(mediaType: .textPlain, parameters: [HTTPHeaderParameter("test", value: "a")]) 40 | request.updateHeaderField(with: contentType) 41 | 42 | XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "text/plain; test=\"a\"") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/Builder/BodyDataBuilderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BodyDataBuilderTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class BodyDataBuilderTests: XCTestCase { 12 | func testSingleData() { 13 | let data = _buildData { 14 | Data("a".utf8) 15 | } 16 | XCTAssertEqual(data, Data("a".utf8)) 17 | } 18 | 19 | func testMultipleData() { 20 | let data = _buildData { 21 | Data("a".utf8) 22 | Data("b".utf8) 23 | Data("c".utf8) 24 | Data("d".utf8) 25 | } 26 | XCTAssertEqual(data, Data("abcd".utf8)) 27 | } 28 | 29 | func testAllBuildMethods() { 30 | let data = _buildData { 31 | // buildArray(_:) 32 | for index in 0...2 { 33 | Data(index.description.utf8) 34 | } 35 | 36 | // buildOptional(_:) 37 | if Bool(truncating: 1) { 38 | Data("true".utf8) 39 | } 40 | if Bool(truncating: 0) { 41 | Data("false".utf8) 42 | } 43 | 44 | // buildEither(first:) 45 | if Bool(truncating: 1) { 46 | Data("first".utf8) 47 | } else { 48 | Data("second".utf8) 49 | } 50 | 51 | // buildEither(second:) 52 | if Bool(truncating: 0) { 53 | Data("first".utf8) 54 | } else { 55 | Data("second".utf8) 56 | } 57 | } 58 | XCTAssertEqual(data, Data("012truefirstsecond".utf8)) 59 | } 60 | } 61 | 62 | extension BodyDataBuilderTests { 63 | private func _buildData(@BodyDataBuilder builder: () throws -> Data) rethrows -> Data { 64 | return try builder() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/ContentDispositionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentDispositionTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class ContentDispositionTests: XCTestCase { 12 | func testUncheckedInitValid() { 13 | XCTAssertNoThrow(try ContentDisposition(uncheckedName: "a", uncheckedFilename: "a")) 14 | } 15 | 16 | func testUncheckedInitInvalid() throws { 17 | // https://stackoverflow.com/questions/33558933/why-is-the-return-value-of-string-addingpercentencoding-optional 18 | let bytes: [UInt8] = [0xD8, 0x00] 19 | 20 | // Ensure the non-encodable string can be created, e.g. on iOS 18 it no longer works. 21 | guard let nonPercentEncodableString = String(bytes: bytes, encoding: .utf16BigEndian) else { 22 | throw XCTSkip("UTF16 byte encoding failed") 23 | } 24 | 25 | // Ensure the percent-encoding fails on the current platform, e.g. on Linux it encodes. 26 | guard nonPercentEncodableString.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) == nil else { 27 | throw XCTSkip("percent encoding didn't fail") 28 | } 29 | 30 | XCTAssertThrowsError(try ContentDisposition(uncheckedName: nonPercentEncodableString, uncheckedFilename: nil)) 31 | XCTAssertThrowsError(try ContentDisposition(uncheckedName: "", uncheckedFilename: nonPercentEncodableString)) 32 | } 33 | 34 | func testParameters() throws { 35 | let contentDisposition = ContentDisposition(name: "a", filename: "a") 36 | XCTAssertEqual(contentDisposition.parameters[0], HTTPHeaderParameter("name", value: "a")) 37 | XCTAssertEqual(contentDisposition.parameters[1], HTTPHeaderParameter("filename", value: "a")) 38 | } 39 | 40 | func testData() throws { 41 | let contentDisposition = ContentDisposition(name: "a", filename: "a") 42 | XCTAssertEqual(contentDisposition.data, Data("Content-Disposition: form-data; name=\"a\"; filename=\"a\"".utf8)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/BoundaryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BoundaryTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class BoundaryTests: XCTestCase { 12 | func testEmpty() { 13 | XCTAssertThrowsError(try Boundary(uncheckedBoundary: "")) { error in 14 | XCTAssertEqual(error as? Boundary.InvalidBoundaryError, .empty) 15 | } 16 | } 17 | 18 | func testTooLong() { 19 | var tooLongBoundary = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrs" 20 | 21 | XCTAssertEqual(tooLongBoundary.count, 71) 22 | XCTAssertThrowsError(try Boundary(uncheckedBoundary: tooLongBoundary)) { error in 23 | XCTAssertEqual(error as? Boundary.InvalidBoundaryError, .tooLong) 24 | } 25 | 26 | tooLongBoundary.removeLast() 27 | XCTAssertEqual(tooLongBoundary.count, 70) 28 | XCTAssertNoThrow(try Boundary(uncheckedBoundary: tooLongBoundary)) 29 | } 30 | 31 | func testNoAscii() { 32 | let noAsciiString = "abcdefghijklmnopqrstuvwxyz\u{80}" 33 | XCTAssertTrue(noAsciiString.contains { !$0.isASCII }) 34 | XCTAssertThrowsError(try Boundary(uncheckedBoundary: noAsciiString)) { error in 35 | XCTAssertEqual(error as? Boundary.InvalidBoundaryError, .noASCII) 36 | } 37 | 38 | let asciiString = "abcdefghijklmnopqrstuvwxyz" 39 | XCTAssertFalse(asciiString.contains { !$0.isASCII }) 40 | XCTAssertNoThrow(try Boundary(uncheckedBoundary: asciiString)) 41 | } 42 | 43 | func testRandom() { 44 | for _ in 0...100_000 { 45 | let randomBoundary = Boundary.random() 46 | let asciiString = String(data: randomBoundary._asciiData, encoding: .ascii) 47 | XCTAssertNotNil(asciiString) 48 | XCTAssertEqual(asciiString, randomBoundary.rawValue) 49 | } 50 | } 51 | 52 | func testDebugDescription() throws { 53 | let boundary = try Boundary(uncheckedBoundary: "test") 54 | 55 | let expectedDescription = "test" 56 | XCTAssertEqual(boundary.debugDescription, expectedDescription) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/Builder/HTTPHeaderBuilderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPHeaderBuilderTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class HTTPHeaderBuilderTests: XCTestCase { 12 | func testAvailableHeaderCombinations() { 13 | let dispositionResult = _buildHeader { 14 | ContentDisposition(name: "a") 15 | } 16 | XCTAssertEqual(dispositionResult._contentDisposition, ContentDisposition(name: "a")) 17 | XCTAssertNil(dispositionResult._contentType) 18 | 19 | let dispositionTypeResult = _buildHeader { 20 | ContentDisposition(name: "a") 21 | ContentType(mediaType: .textPlain) 22 | } 23 | XCTAssertEqual(dispositionTypeResult._contentDisposition, ContentDisposition(name: "a")) 24 | XCTAssertEqual(dispositionTypeResult._contentType, ContentType(mediaType: .textPlain)) 25 | 26 | let typeDispositionResult = _buildHeader { 27 | ContentType(mediaType: .textPlain) 28 | ContentDisposition(name: "a") 29 | } 30 | XCTAssertEqual(typeDispositionResult._contentDisposition, ContentDisposition(name: "a")) 31 | XCTAssertEqual(typeDispositionResult._contentType, ContentType(mediaType: .textPlain)) 32 | } 33 | 34 | func testAllBuildMethods() { 35 | let buildResult = _buildHeader { 36 | // buildEither(first:) 37 | if Bool(truncating: 1) { 38 | ContentDisposition(name: "a") 39 | } else { 40 | ContentDisposition(name: "a") 41 | } 42 | } 43 | XCTAssertEqual(buildResult._contentDisposition, ContentDisposition(name: "a")) 44 | 45 | let buildResult2 = _buildHeader { 46 | // buildEither(second:) 47 | if Bool(truncating: 0) { 48 | ContentDisposition(name: "b") 49 | } else { 50 | ContentDisposition(name: "b") 51 | } 52 | } 53 | XCTAssertEqual(buildResult2._contentDisposition, ContentDisposition(name: "b")) 54 | } 55 | } 56 | 57 | extension HTTPHeaderBuilderTests { 58 | private func _buildHeader( 59 | @HTTPHeaderBuilder builder: () throws -> HTTPHeaderBuilder.BuildResult 60 | ) rethrows -> HTTPHeaderBuilder.BuildResult { 61 | return try builder() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_comma 3 | opt_in_rules: 4 | - anonymous_argument_in_multiline_closure 5 | - array_init 6 | - attributes 7 | - closure_body_length 8 | - closure_end_indentation 9 | - closure_spacing 10 | - collection_alignment 11 | - comma_inheritance 12 | - contains_over_filter_count 13 | - contains_over_filter_is_empty 14 | - contains_over_first_not_nil 15 | - contains_over_range_nil_comparison 16 | - convenience_type 17 | - discouraged_assert 18 | - discouraged_none_name 19 | - discouraged_object_literal 20 | - discouraged_optional_boolean 21 | - discouraged_optional_collection 22 | - empty_collection_literal 23 | - empty_count 24 | - empty_string 25 | - enum_case_associated_values_count 26 | - expiring_todo 27 | - explicit_init 28 | - file_header 29 | - file_name 30 | - file_name_no_space 31 | - first_where 32 | - flatmap_over_map_reduce 33 | - force_unwrapping 34 | - function_default_parameter_at_end 35 | - identical_operands 36 | - implicitly_unwrapped_optional 37 | - indentation_width 38 | - joined_default_parameter 39 | - last_where 40 | - legacy_multiple 41 | - legacy_objc_type 42 | - let_var_whitespace 43 | - literal_expression_end_indentation 44 | - local_doc_comment 45 | - lower_acl_than_parent 46 | - missing_docs 47 | - modifier_order 48 | - multiline_arguments 49 | - multiline_arguments_brackets 50 | - multiline_function_chains 51 | - multiline_literal_brackets 52 | - multiline_parameters 53 | - multiline_parameters_brackets 54 | - no_extension_access_modifier 55 | - nslocalizedstring_key 56 | - number_separator 57 | - operator_usage_whitespace 58 | - optional_enum_case_matching 59 | - overridden_super_call 60 | - pattern_matching_keywords 61 | - prefer_self_type_over_type_of_self 62 | - prefer_zero_over_explicit_init 63 | - prohibited_super_call 64 | - raw_value_for_camel_cased_codable_enum 65 | - reduce_into 66 | - redundant_nil_coalescing 67 | - redundant_type_annotation 68 | - return_value_from_void_function 69 | - self_binding 70 | - shorthand_optional_binding 71 | - sorted_first_last 72 | - static_operator 73 | - toggle_bool 74 | - trailing_closure 75 | - unavailable_function 76 | - unneeded_parentheses_in_closure_argument 77 | - unowned_variable_capture 78 | - untyped_error_in_catch 79 | - vertical_parameter_alignment_on_call 80 | - vertical_whitespace_closing_braces 81 | - weak_delegate 82 | - yoda_condition 83 | 84 | analyzer_rules: 85 | - unused_declaration 86 | - unused_import 87 | 88 | identifier_name: 89 | allowed_symbols: 90 | - '_' 91 | 92 | line_length: 93 | - 130 94 | - 200 95 | 96 | trailing_whitespace: 97 | ignores_empty_lines: true 98 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Builder/HTTPHeaderBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPHeaderBuilder.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | /// Build the header fields for a ``Subpart``. 9 | /// 10 | /// The only allowed combinations of header fields are: 11 | /// - ``ContentDisposition`` 12 | /// - ``ContentDisposition`` + ``ContentType`` 13 | /// - ``ContentType`` + ``ContentDisposition`` 14 | /// 15 | /// The returning object is of type ``BuildResult``. 16 | @resultBuilder 17 | public enum HTTPHeaderBuilder { 18 | // swiftlint:disable missing_docs 19 | 20 | public static func buildBlock(_ contentDisposition: ContentDisposition) -> BuildResult { 21 | return BuildResult(_contentDisposition: contentDisposition, _contentType: nil) 22 | } 23 | 24 | public static func buildBlock(_ contentDisposition: ContentDisposition, _ contentType: ContentType) -> BuildResult { 25 | return BuildResult(_contentDisposition: contentDisposition, _contentType: contentType) 26 | } 27 | 28 | public static func buildBlock(_ contentType: ContentType, _ contentDisposition: ContentDisposition) -> BuildResult { 29 | return BuildResult(_contentDisposition: contentDisposition, _contentType: contentType) 30 | } 31 | 32 | public static func buildBlock(_ component: BuildResult) -> BuildResult { 33 | return component 34 | } 35 | 36 | public static func buildEither(first component: BuildResult) -> BuildResult { 37 | return component 38 | } 39 | 40 | public static func buildEither(second component: BuildResult) -> BuildResult { 41 | return component 42 | } 43 | // swiftlint:enable missing_docs 44 | } 45 | 46 | // MARK: - Build Errors 47 | 48 | extension HTTPHeaderBuilder { 49 | // swiftlint:disable missing_docs 50 | 51 | @available(*, unavailable, message: "Missing a ContentDisposition") 52 | public static func buildBlock(_ contentType: ContentType) -> BuildResult { 53 | fatalError("unavailable") 54 | } 55 | 56 | @available(*, unavailable, message: "Only a single ContentDisposition is allowed") 57 | public static func buildBlock(_ contentDispositions: ContentDisposition...) -> BuildResult { 58 | fatalError("unavailable") 59 | } 60 | 61 | @available(*, unavailable, message: "Only a single ContentType is allowed") 62 | public static func buildBlock(_ contentDisposition: ContentDisposition, _ contentTypes: ContentType...) -> BuildResult { 63 | fatalError("unavailable") 64 | } 65 | // swiftlint:enable missing_docs 66 | } 67 | 68 | // MARK: - Build Result 69 | 70 | extension HTTPHeaderBuilder { 71 | /// The return type of ``HTTPHeaderBuilder``. 72 | /// 73 | /// This is just a wrapper around ``ContentDisposition`` + ``ContentType`` and only for internal usage. 74 | public struct BuildResult { 75 | internal let _contentDisposition: ContentDisposition 76 | internal let _contentType: ContentType? 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/ContentDisposition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentDisposition.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A `Content-Disposition` header field of an HTTP request. 11 | public struct ContentDisposition: HTTPHeaderField { 12 | 13 | public static let name: String = "Content-Disposition" 14 | 15 | public var value: String { 16 | return "form-data" 17 | } 18 | 19 | public var parameters: [HTTPHeaderParameter] 20 | } 21 | 22 | // MARK: - Percent Encoding 23 | 24 | extension ContentDisposition { 25 | /// Represents an error for a name that can not be percent encoded. 26 | public struct PercentEncodingError: Error, CustomDebugStringConvertible { 27 | /// The initial value that could not be percent encoded. 28 | public var initialValue: String 29 | 30 | public var debugDescription: String { 31 | return "PercentEncodingError: \(initialValue) can not be percent-encoded!" 32 | } 33 | } 34 | 35 | /// Creates a new ``ContentDisposition`` object. 36 | /// 37 | /// This initializer ensures the correct encodings of the parameters. 38 | /// The ``parameters`` property can me modified but the `name` and `filename` elements should not be touched. 39 | /// 40 | /// - Throws: A ``PercentEncodingError`` if one of the names can not be percent encoded. 41 | /// 42 | /// - Parameters: 43 | /// - name: The value for the `name` parameter. 44 | /// - filename: The value for the optional `filename` parameter. 45 | public init(uncheckedName name: String, uncheckedFilename filename: String? = nil) throws { 46 | guard let percentEncodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { 47 | throw PercentEncodingError(initialValue: name) 48 | } 49 | if let filename { 50 | guard let percentEncodedFilename = filename.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { 51 | throw PercentEncodingError(initialValue: filename) 52 | } 53 | parameters = [ 54 | HTTPHeaderParameter("name", value: percentEncodedName), 55 | HTTPHeaderParameter("filename", value: percentEncodedFilename) 56 | ] 57 | } else { 58 | parameters = [HTTPHeaderParameter("name", value: percentEncodedName)] 59 | } 60 | } 61 | 62 | /// Creates a new ``ContentDisposition`` object. 63 | /// 64 | /// It is not possible to create a `StaticString` that can not be percent encoded. 65 | /// Therefore, unlike ``init(uncheckedName:uncheckedFilename:)``, this initializer can not throw an error. 66 | /// 67 | /// This initializer ensures the correct encodings of the parameters. 68 | /// The ``parameters`` property can me modified but the `name` and `filename` elements should not be touched. 69 | /// 70 | /// - Parameters: 71 | /// - name: The value for the `name` parameter. 72 | /// - filename: The value for the optional `filename` parameter. 73 | public init(name: StaticString, filename: StaticString? = nil) { 74 | // swiftlint:disable:next force_try 75 | try! self.init(uncheckedName: String(name), uncheckedFilename: filename.map { String($0) }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Subpart.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Subpart.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A subpart of the ``MultipartFormData``'s body. 11 | /// 12 | /// This can either be initialized the standard way or via the result builder initializer. 13 | public struct Subpart: Sendable, Hashable { 14 | 15 | /// The content disposition of the subpart. 16 | public var contentDisposition: ContentDisposition 17 | 18 | /// The optional content type of the subpart. 19 | public var contentType: ContentType? 20 | 21 | /// The body of the subpart. 22 | /// 23 | /// This is plain data. 24 | public var body: Data 25 | 26 | /// Creates a new ``Subpart`` object manually. 27 | /// 28 | /// There is also ``init(header:body:)`` which is more convenient to use. 29 | /// 30 | /// - Parameters: 31 | /// - contentDisposition: The content disposition of the subpart. 32 | /// - contentType: The optional content type of the subpart. 33 | /// - body: The body of the subpart. 34 | public init(contentDisposition: ContentDisposition, contentType: ContentType? = nil, body: Data) { 35 | // swiftlint:disable:previous function_default_parameter_at_end 36 | self.contentDisposition = contentDisposition 37 | self.contentType = contentType 38 | self.body = body 39 | } 40 | } 41 | 42 | // MARK: - Result Builders 43 | 44 | extension Subpart { 45 | /// Creates a new ``Subpart`` object with result builders. 46 | /// 47 | /// The header builder must contain a ``ContentDisposition``, the ``ContentType`` is optional. 48 | /// The order of those two header fields does not matter. 49 | /// 50 | /// The body builder contains the data. 51 | /// It can be a single data instance but also accepts multiple (they will be combined to a single one). 52 | /// 53 | /// ```swift 54 | /// Subpart { 55 | /// ContentDisposition(name: "id") 56 | /// ContentType(mediaType: .textPlain) 57 | /// } body: { 58 | /// Data("1234".utf8) 59 | /// Data("abcd".utf8) 60 | /// } 61 | /// ``` 62 | /// 63 | /// - Throws: Can only throw an error if one of the result builders can throw one. 64 | /// 65 | /// - Parameters: 66 | /// - header: The result builder for the header. 67 | /// - body: The result builder for the body. 68 | public init( 69 | @HTTPHeaderBuilder header: () throws -> HTTPHeaderBuilder.BuildResult, 70 | @BodyDataBuilder body: () throws -> Data 71 | ) rethrows { 72 | let headerFields = try header() 73 | self.contentDisposition = headerFields._contentDisposition 74 | self.contentType = headerFields._contentType 75 | self.body = try body() 76 | } 77 | } 78 | 79 | // MARK: - CustomDebugStringConvertible 80 | 81 | extension Subpart: CustomDebugStringConvertible { 82 | public var debugDescription: String { 83 | return String(bytes: data, encoding: .utf8) ?? "" 84 | } 85 | } 86 | 87 | // MARK: - Data 88 | 89 | extension Subpart { 90 | /// The data representation of a subpart. 91 | public var data: Data { 92 | let contentTypeData: Data = contentType.map { $0.data + ._crlf } ?? Data() 93 | return contentDisposition.data + ._crlf + contentTypeData + ._crlf + body 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/Boundary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Boundary.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The boundary used by the form data. 11 | /// 12 | /// There are 2 ways to create a boundary: 13 | /// 1. ``random()`` type method to generate a random one. 14 | /// 2. ``init(uncheckedBoundary:)`` to create one manually. 15 | /// In this case an error can be thrown because it checks the required format! 16 | public struct Boundary: Sendable, Hashable { 17 | internal let _asciiData: Data 18 | } 19 | 20 | // MARK: - Unchecked Boundary 21 | 22 | extension Boundary { 23 | /// All possible errors from ``init(uncheckedBoundary:)``. 24 | public enum InvalidBoundaryError: Error, CustomDebugStringConvertible { 25 | 26 | /// The given boundary was empty. 27 | /// 28 | /// It must contain at least one character. 29 | case empty 30 | 31 | /// The given boundary is greater than 70 bytes. 32 | /// 33 | /// 1 byte corresponds to 1 character. 34 | case tooLong 35 | 36 | /// The given boundary contains at least on character which is not in ASCII format. 37 | case noASCII 38 | 39 | public var debugDescription: String { 40 | switch self { 41 | case .empty: 42 | return "Boundary must not be empty." 43 | case .tooLong: 44 | return "Boundary is too long. Max size is 70 characters." 45 | case .noASCII: 46 | return "Boundary contains at least one character that is not ASCII compatible." 47 | } 48 | } 49 | } 50 | 51 | /// The maximum allowed size (in bytes). 52 | /// 53 | /// 1 byte corresponds to 1 character. 54 | /// The raw value is `70`. 55 | public static let maxSize: Int = 70 56 | 57 | /// Create a boundary manually from a String. 58 | /// 59 | /// The characters must be in ASCII format and the size must not be greater then ``maxSize``. 60 | /// This initializer check's for a valid format and can throw an error. 61 | /// 62 | /// - Parameter uncheckedBoundary: The unchecked, raw boundary value. 63 | /// - Throws: An error of type ``InvalidBoundaryError``. 64 | public init(uncheckedBoundary: String) throws { 65 | guard !uncheckedBoundary.isEmpty else { 66 | throw InvalidBoundaryError.empty 67 | } 68 | guard uncheckedBoundary.count <= Self.maxSize else { 69 | throw InvalidBoundaryError.tooLong 70 | } 71 | guard let asciiData = uncheckedBoundary.data(using: .ascii) else { 72 | throw InvalidBoundaryError.noASCII 73 | } 74 | self._asciiData = asciiData 75 | } 76 | } 77 | 78 | // MARK: - Random Boundary 79 | 80 | extension Boundary { 81 | /// Generates a random boundary with 16 ASCII characters. 82 | /// 83 | /// A valid boundary is guaranteed and no error can be thrown. 84 | /// 85 | /// - Returns: The generated boundary. 86 | public static func random() -> Boundary { 87 | let first = UInt32.random(in: UInt32.min...UInt32.max) 88 | let second = UInt32.random(in: UInt32.min...UInt32.max) 89 | let rawValue = String(format: "%08x%08x", first, second) 90 | let asciiData = Data(rawValue.utf8) // UTF8 is fine here because we can ensure it's ASCII compatible 91 | return Boundary(_asciiData: asciiData) 92 | } 93 | } 94 | 95 | // MARK: - CustomDebugStringConvertible 96 | 97 | extension Boundary: CustomDebugStringConvertible { 98 | public var debugDescription: String { 99 | return rawValue 100 | } 101 | } 102 | 103 | // MARK: - Data 104 | 105 | extension Boundary { 106 | /// The raw string representation of a boundary. 107 | public var rawValue: String { 108 | return String(bytes: _asciiData, encoding: .ascii) ?? "" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultipartFormData 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFelixHerrmann%2Fswift-multipart-formdata%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/FelixHerrmann/swift-multipart-formdata) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFelixHerrmann%2Fswift-multipart-formdata%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/FelixHerrmann/swift-multipart-formdata) 5 | [![Swift](https://github.com/FelixHerrmann/swift-multipart-formdata/actions/workflows/swift.yml/badge.svg)](https://github.com/FelixHerrmann/swift-multipart-formdata/actions/workflows/swift.yml) 6 | [![SwiftLint](https://github.com/FelixHerrmann/swift-multipart-formdata/actions/workflows/swiftlint.yml/badge.svg)](https://github.com/FelixHerrmann/swift-multipart-formdata/actions/workflows/swiftlint.yml) 7 | 8 | Build multipart/form-data type-safe in Swift. A result builder DSL is also available. 9 | 10 | 11 | ## Installation 12 | 13 | ### [Swift Package Manager](https://swift.org/package-manager/) 14 | 15 | Add the following to the dependencies of your `Package.swift`: 16 | 17 | ```swift 18 | .package(url: "https://github.com/FelixHerrmann/swift-multipart-formdata.git", from: "x.x.x") 19 | ``` 20 | 21 | ### Xcode 22 | 23 | Add the package to your project as shown [here](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). 24 | 25 | ### Manual 26 | 27 | Download the files in the [Sources](/Sources) folder and drag them into you project. 28 | 29 | 30 | ## Example 31 | 32 | ```swift 33 | import MultipartFormData 34 | 35 | let boundary = try Boundary(uncheckedBoundary: "example-boundary") 36 | let multipartFormData = try MultipartFormData(boundary: boundary) { 37 | Subpart { 38 | ContentDisposition(name: "field1") 39 | } body: { 40 | Data("value1".utf8) 41 | } 42 | try Subpart { 43 | ContentDisposition(name: "field2") 44 | ContentType(mediaType: .applicationJson) 45 | } body: { 46 | try JSONSerialization.data(withJSONObject: ["string": "abcd", "int": 1234], options: .prettyPrinted) 47 | } 48 | 49 | let filename = "test.png" 50 | let homeDirectory = FileManager.default.homeDirectoryForCurrentUser 51 | let fileDirectory = homeDirectory.appendingPathComponent("Desktop").appendingPathComponent(filename) 52 | 53 | if FileManager.default.fileExists(atPath: fileDirectory.path) { 54 | try Subpart { 55 | try ContentDisposition(uncheckedName: "field3", uncheckedFilename: filename) 56 | ContentType(mediaType: .applicationOctetStream) 57 | } body: { 58 | try Data(contentsOf: fileDirectory) 59 | } 60 | } 61 | } 62 | 63 | let url = URL(string: "https://example.com/example")! 64 | let request = URLRequest(url: url, multipartFormData: multipartFormData) 65 | let (data, response) = try await URLSession.shared.data(for: request) 66 | ``` 67 | 68 |
69 | The generated HTTP request 70 | 71 | ```http 72 | POST https://example.com/example HTTP/1.1 73 | Content-Length: 428 74 | Content-Type: multipart/form-data; boundary="example-boundary" 75 | 76 | --example-boundary 77 | Content-Disposition: form-data; name="field1" 78 | 79 | value1 80 | --example-boundary 81 | Content-Disposition: form-data; name="field2" 82 | Content-Type: application/json 83 | 84 | { 85 | "string" : "abcd", 86 | "int" : 1234 87 | } 88 | --example-boundary 89 | Content-Disposition: form-data; name="field3"; filename="test.png" 90 | Content-Type: application/octet-stream 91 | 92 | <> 93 | --example-boundary-- 94 | ``` 95 |
96 | 97 | ## Documentation 98 | 99 | For a detailed usage description, you can check out the [documentation](https://swiftpackageindex.com/FelixHerrmann/swift-multipart-formdata/master/documentation/multipartformdata). 100 | 101 | ![docs](https://user-images.githubusercontent.com/42500484/193477691-ff5fa9a7-8a3e-48bd-aade-a853144ab05f.png#gh-light-mode-only) 102 | ![docs](https://user-images.githubusercontent.com/42500484/193477695-cebe2417-6311-4ef0-bbe3-2e5217a89ab9.png#gh-dark-mode-only) 103 | 104 | ## License 105 | 106 | MultipartFormData is available under the MIT license. See the [LICENSE](/LICENSE) file for more info. 107 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/MediaType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaType.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | /// A media type (also known as a **Multipurpose Internet Mail Extensions** or **MIME type**) indicates the nature and 9 | /// format of a document, file, or assortment of bytes. 10 | /// 11 | /// All available types are listed [here](https://www.iana.org/assignments/media-types/media-types.xhtml). 12 | /// 13 | /// The most common once's are conveniently available through type properties. 14 | /// These can also be extended to avoid mistakes. 15 | public struct MediaType: Sendable, Hashable { 16 | 17 | /// The type of media. 18 | public let type: String 19 | 20 | /// The subtype of media. 21 | public let subtype: String 22 | 23 | /// Creates a new ``MediaType`` object. 24 | /// - Parameters: 25 | /// - type: The type of media. 26 | /// - subtype: The subtype of media. 27 | public init(type: String, subtype: String) { 28 | self.type = type 29 | self.subtype = subtype 30 | } 31 | } 32 | 33 | // MARK: - Convenience Type Properties 34 | 35 | // swiftlint:disable missing_docs 36 | extension MediaType { 37 | public static let multipartFormData = MediaType(type: "multipart", subtype: "form-data") 38 | 39 | public static let textPlain = MediaType(type: "text", subtype: "plain") 40 | public static let textCsv = MediaType(type: "text", subtype: "csv") 41 | public static let textHtml = MediaType(type: "text", subtype: "html") 42 | public static let textCss = MediaType(type: "text", subtype: "css") 43 | 44 | public static let imageGif = MediaType(type: "image", subtype: "gif") 45 | public static let imagePng = MediaType(type: "image", subtype: "png") 46 | public static let imageJpeg = MediaType(type: "image", subtype: "jpeg") 47 | public static let imageBmp = MediaType(type: "image", subtype: "bmp") 48 | public static let imageWebp = MediaType(type: "image", subtype: "webp") 49 | public static let imageSvgXml = MediaType(type: "image", subtype: "svg+xml") 50 | 51 | public static let audioMidi = MediaType(type: "audio", subtype: "midi") 52 | public static let audioMpeg = MediaType(type: "audio", subtype: "mpeg") 53 | public static let audioWebm = MediaType(type: "audio", subtype: "webm") 54 | public static let audioOgg = MediaType(type: "audio", subtype: "ogg") 55 | public static let audioWav = MediaType(type: "audio", subtype: "wav") 56 | 57 | public static let videoWebm = MediaType(type: "video", subtype: "webm") 58 | public static let videoOgg = MediaType(type: "video", subtype: "ogg") 59 | 60 | public static let applicationOctetStream = MediaType(type: "application", subtype: "octet-stream") 61 | public static let applicationXml = MediaType(type: "application", subtype: "xml") 62 | public static let applicationJson = MediaType(type: "application", subtype: "json") 63 | public static let applicationJavascript = MediaType(type: "application", subtype: "javascript") 64 | } 65 | // swiftlint:enable missing_docs 66 | 67 | // MARK: - UniformTypeIdentifiers 68 | 69 | #if canImport(UniformTypeIdentifiers) 70 | import UniformTypeIdentifiers 71 | 72 | extension MediaType { 73 | /// Create a media type from a uniform type. 74 | /// - Parameter uniformType: The uniform type (UTType). 75 | @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) 76 | public init?(uniformType: UTType) { 77 | guard let mimeTypeSplit = uniformType.preferredMIMEType?.split(separator: "/") else { return nil } 78 | guard mimeTypeSplit.count == 2 else { return nil } 79 | self.type = String(mimeTypeSplit[0]) 80 | self.subtype = String(mimeTypeSplit[1]) 81 | } 82 | } 83 | 84 | @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) 85 | extension UTType { 86 | /// Create a uniform type from a media type. 87 | /// - Parameter mediaType: The media type. 88 | /// - Parameter supertype: Another UTType instance that the resulting type must conform to; for example, UTTypeData. 89 | public init?(mediaType: MediaType, conformingTo supertype: UTType = .data) { 90 | self.init(mimeType: mediaType.rawValue, conformingTo: supertype) 91 | } 92 | } 93 | #endif 94 | 95 | // MARK: - CustomDebugStringConvertible 96 | 97 | extension MediaType: CustomDebugStringConvertible { 98 | public var debugDescription: String { 99 | return rawValue 100 | } 101 | } 102 | 103 | // MARK: - Data 104 | 105 | extension MediaType { 106 | /// The raw string representation of a media type. 107 | public var rawValue: String { 108 | return "\(type)/\(subtype)" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/MultipartFormDataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormDataTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class MultipartFormDataTests: XCTestCase { 12 | func testContentType() throws { 13 | let boundary = try Boundary(uncheckedBoundary: "test") 14 | let multipartFormData = MultipartFormData(boundary: boundary) 15 | XCTAssertEqual(multipartFormData.contentType.data, Data("Content-Type: multipart/form-data; boundary=\"test\"".utf8)) 16 | } 17 | 18 | func testHTTPBodyGeneration() throws { 19 | let boundary = try Boundary(uncheckedBoundary: "test") 20 | let multipartFormData = MultipartFormData(boundary: boundary, body: [ 21 | Subpart( 22 | contentDisposition: ContentDisposition(name: "text"), 23 | body: Data("a".utf8) 24 | ), 25 | Subpart( 26 | contentDisposition: ContentDisposition(name: "json"), 27 | contentType: ContentType(mediaType: .applicationJson), 28 | body: try JSONSerialization.data(withJSONObject: ["a": 1]) 29 | ), 30 | Subpart( 31 | contentDisposition: ContentDisposition(name: "file", filename: "test.txt"), 32 | contentType: ContentType(mediaType: .applicationOctetStream), 33 | body: Data() 34 | ), 35 | ]) 36 | 37 | let expectedBody = Data([ 38 | "--test", 39 | "Content-Disposition: form-data; name=\"text\"", 40 | "", 41 | "a", 42 | "--test", 43 | "Content-Disposition: form-data; name=\"json\"", 44 | "Content-Type: application/json", 45 | "", 46 | "{\"a\":1}", 47 | "--test", 48 | "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"", 49 | "Content-Type: application/octet-stream", 50 | "", 51 | "", 52 | "--test--\r\n", 53 | ].joined(separator: "\r\n").utf8) 54 | XCTAssertEqual(multipartFormData.httpBody, expectedBody) 55 | } 56 | 57 | func testDebugDescription() throws { 58 | let boundary = try Boundary(uncheckedBoundary: "test") 59 | let multipartFormData = MultipartFormData(boundary: boundary, body: [ 60 | Subpart( 61 | contentDisposition: ContentDisposition(name: "text"), 62 | body: Data("a".utf8) 63 | ), 64 | Subpart( 65 | contentDisposition: ContentDisposition(name: "json"), 66 | contentType: ContentType(mediaType: .applicationJson), 67 | body: try JSONSerialization.data(withJSONObject: ["a": 1]) 68 | ), 69 | Subpart( 70 | contentDisposition: ContentDisposition(name: "file", filename: "test.txt"), 71 | contentType: ContentType(mediaType: .applicationOctetStream), 72 | body: Data() 73 | ), 74 | ]) 75 | 76 | let expectedDescription = [ 77 | "Content-Type: multipart/form-data; boundary=\"test\"", 78 | "", 79 | "--test", 80 | "Content-Disposition: form-data; name=\"text\"", 81 | "", 82 | "a", 83 | "--test", 84 | "Content-Disposition: form-data; name=\"json\"", 85 | "Content-Type: application/json", 86 | "", 87 | "{\"a\":1}", 88 | "--test", 89 | "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"", 90 | "Content-Type: application/octet-stream", 91 | "", 92 | "", 93 | "--test--\r\n", 94 | ].joined(separator: "\r\n") 95 | XCTAssertEqual(multipartFormData.debugDescription, expectedDescription) 96 | } 97 | 98 | func testBuilderInit() throws { 99 | let boundary = try Boundary(uncheckedBoundary: "test") 100 | let jsonData = try JSONSerialization.data(withJSONObject: ["a": 1]) 101 | let multipartFormData = MultipartFormData(boundary: boundary) { 102 | Subpart { 103 | ContentDisposition(name: "json") 104 | ContentType(mediaType: .applicationJson) 105 | } body: { 106 | jsonData 107 | } 108 | } 109 | 110 | XCTAssertEqual(multipartFormData.boundary, boundary) 111 | XCTAssertEqual(multipartFormData.body.first?.contentType?.mediaType, .applicationJson) 112 | XCTAssertEqual(multipartFormData.body.first?.body, jsonData) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataTests/Builder/MultipartFormDataBuilderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormDataBuilderTests.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 30.12.21. 6 | // 7 | 8 | import XCTest 9 | @testable import MultipartFormData 10 | 11 | final class MultipartFormDataBuilderTests: XCTestCase { 12 | func testSingleSubpart() throws { 13 | let subparts = _buildSubparts { 14 | Subpart { 15 | ContentDisposition(name: "a") 16 | } body: { 17 | Data("a".utf8) 18 | } 19 | } 20 | let expectedSubparts = [ 21 | Subpart(contentDisposition: ContentDisposition(name: "a"), body: Data("a".utf8)) 22 | ] 23 | XCTAssertEqual(subparts, expectedSubparts) 24 | } 25 | 26 | func testMultipleSubparts() throws { 27 | let subparts = _buildSubparts { 28 | Subpart { 29 | ContentDisposition(name: "a") 30 | } body: { 31 | Data("a".utf8) 32 | } 33 | Subpart { 34 | ContentDisposition(name: "b") 35 | } body: { 36 | Data("b".utf8) 37 | } 38 | Subpart { 39 | ContentDisposition(name: "c") 40 | } body: { 41 | Data("c".utf8) 42 | } 43 | } 44 | let expectedSubparts = [ 45 | Subpart(contentDisposition: ContentDisposition(name: "a"), body: Data("a".utf8)), 46 | Subpart(contentDisposition: ContentDisposition(name: "b"), body: Data("b".utf8)), 47 | Subpart(contentDisposition: ContentDisposition(name: "c"), body: Data("c".utf8)), 48 | ] 49 | XCTAssertEqual(subparts, expectedSubparts) 50 | } 51 | 52 | // swiftlint:disable function_body_length 53 | // swiftlint:disable closure_body_length 54 | func testAllBuildMethods() throws { 55 | let subparts = try _buildSubparts { 56 | // buildArray(_:) 57 | for index in 0...2 { 58 | try Subpart { 59 | try ContentDisposition(uncheckedName: index.description) 60 | } body: { 61 | Data(index.description.utf8) 62 | } 63 | } 64 | 65 | // buildOptional(_:) 66 | if Bool(truncating: 1) { 67 | Subpart { 68 | ContentDisposition(name: "true") 69 | } body: { 70 | Data("true".utf8) 71 | } 72 | } 73 | if Bool(truncating: 0) { 74 | Subpart { 75 | ContentDisposition(name: "false") 76 | } body: { 77 | Data("false".utf8) 78 | } 79 | } 80 | 81 | // buildEither(first:) 82 | if Bool(truncating: 1) { 83 | Subpart { 84 | ContentDisposition(name: "first") 85 | } body: { 86 | Data("first".utf8) 87 | } 88 | } else { 89 | Subpart { 90 | ContentDisposition(name: "second") 91 | } body: { 92 | Data("second".utf8) 93 | } 94 | } 95 | 96 | // buildEither(second:) 97 | if Bool(truncating: 0) { 98 | Subpart { 99 | ContentDisposition(name: "first") 100 | } body: { 101 | Data("first".utf8) 102 | } 103 | } else { 104 | Subpart { 105 | ContentDisposition(name: "second") 106 | } body: { 107 | Data("second".utf8) 108 | } 109 | } 110 | } 111 | let expectedSubparts = [ 112 | Subpart(contentDisposition: ContentDisposition(name: "0"), body: Data("0".utf8)), 113 | Subpart(contentDisposition: ContentDisposition(name: "1"), body: Data("1".utf8)), 114 | Subpart(contentDisposition: ContentDisposition(name: "2"), body: Data("2".utf8)), 115 | Subpart(contentDisposition: ContentDisposition(name: "true"), body: Data("true".utf8)), 116 | Subpart(contentDisposition: ContentDisposition(name: "first"), body: Data("first".utf8)), 117 | Subpart(contentDisposition: ContentDisposition(name: "second"), body: Data("second".utf8)), 118 | ] 119 | XCTAssertEqual(subparts, expectedSubparts) 120 | } 121 | // swiftlint:enable function_body_length 122 | // swiftlint:enable closure_body_length 123 | } 124 | 125 | extension MultipartFormDataBuilderTests { 126 | private func _buildSubparts(@MultipartFormDataBuilder builder: () throws -> [Subpart]) rethrows -> [Subpart] { 127 | return try builder() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/MultipartFormData/MultipartFormData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormData.swift 3 | // swift-multipart-formdata 4 | // 5 | // Created by Felix Herrmann on 29.12.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Generates multipart/form-data for an HTTP request. 11 | /// 12 | /// Multipart form data is an encoding format devised as a means of encoding an HTTP form for posting up to a server. 13 | /// For more informations and a detailed description see [RFC7578](https://datatracker.ietf.org/doc/html/rfc7578). 14 | /// 15 | /// There are two ways to create a ``MultipartFormData``: 16 | /// - manually with ``init(boundary:body:)`` 17 | /// - via result builder with ``init(boundary:builder:)`` 18 | /// 19 | /// The ``boundary`` can be created manually but using a randomly generated one is fine in most situations. 20 | /// 21 | /// To create a request from ``MultipartFormData`` use ``httpBody`` and configure the `Content-Type` header 22 | /// field appropriately. There is a dedicated `URLRequest` initializer that handles this configuration. 23 | /// 24 | /// - Note: The ``debugDescription`` is overloaded and will print the form-data request in a human readable format. 25 | /// 26 | /// ```swift 27 | /// let boundary = try Boundary(uncheckedBoundary: "example-boundary") 28 | /// let multipartFormData = try MultipartFormData(boundary: boundary) { 29 | /// Subpart { 30 | /// ContentDisposition(name: "field1") 31 | /// } body: { 32 | /// Data("value1".utf8) 33 | /// } 34 | /// try Subpart { 35 | /// ContentDisposition(name: "field2") 36 | /// ContentType(mediaType: .applicationJson) 37 | /// } body: { 38 | /// try JSONSerialization.data(withJSONObject: ["string": "abcd", "int": 1234], options: .prettyPrinted) 39 | /// } 40 | /// 41 | /// let filename = "test.png" 42 | /// let homeDirectory = FileManager.default.homeDirectoryForCurrentUser 43 | /// let fileDirectory = homeDirectory.appendingPathComponent("Desktop").appendingPathComponent(filename) 44 | /// 45 | /// if FileManager.default.fileExists(atPath: fileDirectory.path) { 46 | /// try Subpart { 47 | /// try ContentDisposition(uncheckedName: "field3", uncheckedFilename: filename) 48 | /// ContentType(mediaType: .applicationOctetStream) 49 | /// } body: { 50 | /// try Data(contentsOf: fileDirectory) 51 | /// } 52 | /// } 53 | /// } 54 | /// 55 | /// let url = URL(string: "https://example.com/example")! 56 | /// let request = URLRequest(url: url, multipartFormData: multipartFormData) 57 | /// let (data, response) = try await URLSession.shared.data(for: request) 58 | /// ``` 59 | /// 60 | /// ```http 61 | /// POST https://example.com/example HTTP/1.1 62 | /// Content-Length: 428 63 | /// Content-Type: multipart/form-data; boundary="example-boundary" 64 | /// 65 | /// --example-boundary 66 | /// Content-Disposition: form-data; name="field1" 67 | /// 68 | /// value1 69 | /// --example-boundary 70 | /// Content-Disposition: form-data; name="field2" 71 | /// Content-Type: application/json 72 | /// 73 | /// { 74 | /// "string" : "abcd", 75 | /// "int" : 1234 76 | /// } 77 | /// --example-boundary 78 | /// Content-Disposition: form-data; name="field3"; filename="test.png" 79 | /// Content-Type: application/octet-stream 80 | /// 81 | /// <> 82 | /// --example-boundary-- 83 | /// ``` 84 | public struct MultipartFormData: Sendable, Hashable { 85 | 86 | /// The boundary to separate the subparts of the ``body`` with. 87 | public let boundary: Boundary 88 | 89 | /// The content type for the request header field. 90 | /// 91 | /// Do not modify the `mediaType` and the boundary element of the `parameters`. 92 | public var contentType: ContentType 93 | 94 | /// The body represented by subparts. 95 | public var body: [Subpart] 96 | 97 | /// Creates a new ``MultipartFormData`` object manually. 98 | /// 99 | /// There is also ``init(boundary:builder:)`` which is more convenient to use. 100 | /// 101 | /// - Parameters: 102 | /// - boundary: The boundary for the multipart/form-data. By default it generates a random one. 103 | /// - body: The body based on ``Subpart`` elements. By default it is empty. 104 | public init(boundary: Boundary = .random(), body: [Subpart] = []) { 105 | self.contentType = ContentType(boundary: boundary) 106 | self.body = body 107 | self.boundary = boundary 108 | } 109 | } 110 | 111 | // MARK: - HTTP Request 112 | 113 | extension MultipartFormData { 114 | /// The generated body data for the HTTP request. 115 | /// 116 | /// This combines all the data from the subparts into one big data object. 117 | public var httpBody: Data { 118 | let bodyData: Data = body 119 | .map { ._dash + boundary._asciiData + ._crlf + $0.data + ._crlf } 120 | .reduce(Data(), +) 121 | return bodyData + ._dash + boundary._asciiData + ._dash + ._crlf 122 | } 123 | } 124 | 125 | // MARK: - Result Builders 126 | 127 | extension MultipartFormData { 128 | /// Creates a new ``MultipartFormData`` object with a result builder. 129 | /// 130 | /// The builder consists of a single or multiple ``Subpart``s. 131 | /// For more information how to build a ``Subpart`` check it's documentation. 132 | /// 133 | /// ```swift 134 | /// try MultipartFormData { 135 | /// Subpart { 136 | /// ContentDisposition(name: "field1") 137 | /// } body: { 138 | /// Data("value1".utf8) 139 | /// } 140 | /// try Subpart { 141 | /// ContentDisposition(name: "field2") 142 | /// ContentType(mediaType: .applicationJson) 143 | /// } body: { 144 | /// try JSONSerialization.data(withJSONObject: ["key": "value"]) 145 | /// } 146 | /// } 147 | /// ``` 148 | /// 149 | /// - Throws: Can only throw an error if the result builder can throw one. 150 | /// 151 | /// - Parameters: 152 | /// - boundary: The boundary for the multipart/form-data. By default it generates a random one. 153 | /// - builder: The result builder for the subparts. 154 | public init(boundary: Boundary = .random(), @MultipartFormDataBuilder builder: () throws -> [Subpart]) rethrows { 155 | self.contentType = ContentType(boundary: boundary) 156 | self.body = try builder() 157 | self.boundary = boundary 158 | } 159 | } 160 | 161 | // MARK: - CustomDebugStringConvertible 162 | 163 | extension MultipartFormData: CustomDebugStringConvertible { 164 | public var debugDescription: String { 165 | let bytes: Data = contentType.data + ._crlf + ._crlf + httpBody 166 | return String(bytes: bytes, encoding: .utf8) ?? "" 167 | } 168 | } 169 | --------------------------------------------------------------------------------