├── .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://swiftpackageindex.com/FelixHerrmann/swift-multipart-formdata)
4 | [](https://swiftpackageindex.com/FelixHerrmann/swift-multipart-formdata)
5 | [](https://github.com/FelixHerrmann/swift-multipart-formdata/actions/workflows/swift.yml)
6 | [](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 | 
102 | 
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 |
--------------------------------------------------------------------------------